diff --git a/README.md b/README.md
index ce9a2d5f8..d505d7633 100644
--- a/README.md
+++ b/README.md
@@ -82,7 +82,7 @@ Type=simple
User=epicyon
Group=epicyon
WorkingDirectory=/opt/epicyon
-ExecStart=/usr/bin/python3 /opt/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open
+ExecStart=/usr/bin/python3 /opt/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open --logLoginFailures
Environment=USER=epicyon
Environment=PYTHONUNBUFFERED=true
Restart=always
@@ -208,6 +208,8 @@ And restart the web server:
systemctl restart nginx
```
+If you need to use **fail2ban** then failed login attempts can be found in *accounts/loginfailures.log*.
+
If you are using the [Caddy web server](https://caddyserver.com) then see *caddy.example.conf*
## Running Static Analysis
diff --git a/auth.py b/auth.py
index 5d3dbdf8e..f74fc1c6d 100644
--- a/auth.py
+++ b/auth.py
@@ -11,6 +11,7 @@ import hashlib
import binascii
import os
import secrets
+import datetime
from utils import isSystemAccount
from utils import hasUsersPath
@@ -204,3 +205,47 @@ def createPassword(length=10):
validChars = 'abcdefghijklmnopqrstuvwxyz' + \
'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
return ''.join((secrets.choice(validChars) for i in range(length)))
+
+
+def recordLoginFailure(baseDir: str, ipAddress: str,
+ countDict: {}, failTime: int,
+ logToFile: bool) -> None:
+ """Keeps ip addresses and the number of times login failures
+ occured for them in a dict
+ """
+ if not countDict.get(ipAddress):
+ while len(countDict.items()) > 100:
+ oldestTime = 0
+ oldestIP = None
+ for ipAddr, ipItem in countDict.items():
+ if oldestTime == 0 or ipItem['time'] < oldestTime:
+ oldestTime = ipItem['time']
+ oldestIP = ipAddr
+ if oldestIP:
+ del countDict[oldestIP]
+ countDict[ipAddress] = {
+ "count": 1,
+ "time": failTime
+ }
+ else:
+ countDict[ipAddress]['count'] += 1
+ countDict[ipAddress]['time'] = failTime
+ failCount = countDict[ipAddress]['count']
+ if failCount > 4:
+ print('WARN: ' + str(ipAddress) + ' failed to log in ' +
+ str(failCount) + ' times')
+
+ if not logToFile:
+ return
+
+ failureLog = baseDir + '/accounts/loginfailures.log'
+ writeType = 'a+'
+ if not os.path.isfile(failureLog):
+ writeType = 'w+'
+ currTime = datetime.datetime.utcnow()
+ try:
+ with open(failureLog, writeType) as fp:
+ fp.write(currTime.strftime("%Y-%m-%d %H:%M:%SZ") +
+ ' ' + ipAddress + '\n')
+ except BaseException:
+ pass
diff --git a/daemon.py b/daemon.py
index 705b9e40d..7d3bc702e 100644
--- a/daemon.py
+++ b/daemon.py
@@ -101,6 +101,7 @@ from skills import noOfActorSkills
from skills import actorHasSkill
from skills import actorSkillValue
from skills import setActorSkillLevel
+from auth import recordLoginFailure
from auth import authorize
from auth import createPassword
from auth import createBasicAuthHeader
@@ -206,6 +207,7 @@ from shares import addShare
from shares import removeShare
from shares import expireShares
from categories import setHashtagCategory
+from utils import isLocalNetworkAddress
from utils import permittedDir
from utils import isAccountDir
from utils import getOccupationSkills
@@ -1437,15 +1439,33 @@ class PubServer(BaseHTTPRequestHandler):
return
authHeader = \
createBasicAuthHeader(loginNickname, loginPassword)
+ if self.headers.get('X-Forward-For'):
+ ipAddress = self.headers['X-Forward-For']
+ elif self.headers.get('X-Forwarded-For'):
+ ipAddress = self.headers['X-Forwarded-For']
+ else:
+ ipAddress = self.client_address[0]
+ if not domain.endswith('.onion'):
+ if not isLocalNetworkAddress(ipAddress):
+ print('Login attempt from IP: ' + str(ipAddress))
if not authorizeBasic(baseDir, '/users/' +
loginNickname + '/outbox',
authHeader, False):
print('Login failed: ' + loginNickname)
self._clearLoginDetails(loginNickname, callingDomain)
- self.server.lastLoginFailure = int(time.time())
+ failTime = int(time.time())
+ self.server.lastLoginFailure = failTime
+ if not domain.endswith('.onion'):
+ if not isLocalNetworkAddress(ipAddress):
+ recordLoginFailure(baseDir, ipAddress,
+ self.server.loginFailureCount,
+ failTime,
+ self.server.logLoginFailures)
self.server.POSTbusy = False
return
else:
+ if self.server.loginFailureCount.get(ipAddress):
+ del self.server.loginFailureCount[ipAddress]
if isSuspended(baseDir, loginNickname):
msg = \
htmlSuspended(self.server.cssCache,
@@ -14829,7 +14849,8 @@ def loadTokens(baseDir: str, tokensDict: {}, tokensLookup: {}) -> None:
break
-def runDaemon(city: str,
+def runDaemon(logLoginFailures: bool,
+ city: str,
showNodeInfoAccounts: bool,
showNodeInfoVersion: bool,
brochMode: bool,
@@ -15097,6 +15118,8 @@ def runDaemon(city: str,
httpd.allowDeletion = allowDeletion
httpd.lastLoginTime = 0
httpd.lastLoginFailure = 0
+ httpd.loginFailureCount = {}
+ httpd.logLoginFailures = logLoginFailures
httpd.maxReplies = maxReplies
httpd.tokens = {}
httpd.tokensLookup = {}
diff --git a/epicyon.py b/epicyon.py
index c33449033..c1dfa5794 100644
--- a/epicyon.py
+++ b/epicyon.py
@@ -291,6 +291,11 @@ parser.add_argument("--iconsAsButtons",
type=str2bool, nargs='?',
const=True, default=False,
help="Show header icons as buttons")
+parser.add_argument("--logLoginFailures",
+ dest='logLoginFailures',
+ type=str2bool, nargs='?',
+ const=True, default=False,
+ help="Whether to log longin failures")
parser.add_argument("--rssIconAtTop",
dest='rssIconAtTop',
type=str2bool, nargs='?',
@@ -2510,6 +2515,11 @@ brochMode = \
if brochMode is not None:
args.brochMode = bool(brochMode)
+logLoginFailures = \
+ getConfigParam(baseDir, 'logLoginFailures')
+if logLoginFailures is not None:
+ args.logLoginFailures = bool(logLoginFailures)
+
showNodeInfoAccounts = \
getConfigParam(baseDir, 'showNodeInfoAccounts')
if showNodeInfoAccounts is not None:
@@ -2539,7 +2549,8 @@ if setTheme(baseDir, themeName, domain,
print('Theme set to ' + themeName)
if __name__ == "__main__":
- runDaemon(args.city,
+ runDaemon(args.logLoginFailures,
+ args.city,
args.showNodeInfoAccounts,
args.showNodeInfoVersion,
args.brochMode,
diff --git a/tests.py b/tests.py
index d8d91c159..c6501392d 100644
--- a/tests.py
+++ b/tests.py
@@ -518,8 +518,9 @@ def createServerAlice(path: str, domain: str, port: int,
showNodeInfoAccounts = True
showNodeInfoVersion = True
city = 'London, England'
+ logLoginFailures = False
print('Server running: Alice')
- runDaemon(city,
+ runDaemon(logLoginFailures, city,
showNodeInfoAccounts,
showNodeInfoVersion,
brochMode,
@@ -620,8 +621,9 @@ def createServerBob(path: str, domain: str, port: int,
showNodeInfoAccounts = True
showNodeInfoVersion = True
city = 'London, England'
+ logLoginFailures = False
print('Server running: Bob')
- runDaemon(city,
+ runDaemon(logLoginFailures, city,
showNodeInfoAccounts,
showNodeInfoVersion,
brochMode,
@@ -677,8 +679,9 @@ def createServerEve(path: str, domain: str, port: int, federationList: [],
showNodeInfoAccounts = True
showNodeInfoVersion = True
city = 'London, England'
+ logLoginFailures = False
print('Server running: Eve')
- runDaemon(city,
+ runDaemon(logLoginFailures, city,
showNodeInfoAccounts,
showNodeInfoVersion,
brochMode,
diff --git a/utils.py b/utils.py
index 512b81708..0612247c1 100644
--- a/utils.py
+++ b/utils.py
@@ -669,6 +669,16 @@ def getLocalNetworkAddresses() -> []:
return ('localhost', '127.0.', '192.168', '10.0.')
+def isLocalNetworkAddress(ipAddress: str) -> bool:
+ """
+ """
+ localIPs = getLocalNetworkAddresses()
+ for ipAddr in localIPs:
+ if ipAddress.startswith(ipAddr):
+ return True
+ return False
+
+
def dangerousMarkup(content: str, allowLocalNetworkAccess: bool) -> bool:
"""Returns true if the given content contains dangerous html markup
"""
diff --git a/website/EN/index.html b/website/EN/index.html
index 8ccea5135..37da861a8 100644
--- a/website/EN/index.html
+++ b/website/EN/index.html
@@ -1343,7 +1343,7 @@
User=epicyon
Group=epicyon
WorkingDirectory=/opt/epicyon
- ExecStart=/usr/bin/python3 /opt/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open --debug
+ ExecStart=/usr/bin/python3 /opt/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open --debug --logLoginFailures
Environment=USER=epicyon
Environment=PYTHONUNBUFFERED=true
Restart=always
@@ -1492,6 +1492,9 @@
+ If you need to use fail2ban then failed login attempts can be found in accounts/loginfailures.log. +
If you are using the Caddy web server then see caddy.example.conf