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 @@
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 then see caddy.example.conf