From fde879b9984a631c9936ff0260f97a28e9453359 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 9 Jun 2021 14:44:31 +0100 Subject: [PATCH 1/7] Show failed login attempts --- daemon.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/daemon.py b/daemon.py index 705b9e40d..3425b074a 100644 --- a/daemon.py +++ b/daemon.py @@ -1437,15 +1437,42 @@ class PubServer(BaseHTTPRequestHandler): return authHeader = \ createBasicAuthHeader(loginNickname, loginPassword) + ipAddress = self.client_address[0] + 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 self.server.loginFailureCount.get(ipAddress): + while len(self.server.loginFailureCount.items()) > 100: + oldestTime = 0 + oldestIP = None + for ipAddr, ipItem in self.server.loginFailureCount: + if oldestTime == 0 or ipItem['time'] < oldestTime: + oldestTime = ipItem['time'] + oldestIP = ipAddr + if oldestTime > 0: + del self.server.loginFailureCount[oldestIP] + self.server.loginFailureCount[ipAddress] = { + "count": 1, + "time": failTime + } + else: + self.server.loginFailureCount[ipAddress]['count'] += 1 + failCount = \ + self.server.loginFailureCount[ipAddress]['count'] + if failCount > 4: + print('WARN: ' + str(ipAddress) + + ' failed to log in ' + str(failCount) + ' times') + self.server.loginFailureCount[ipAddress]['time'] = failTime 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, @@ -15097,6 +15124,7 @@ def runDaemon(city: str, httpd.allowDeletion = allowDeletion httpd.lastLoginTime = 0 httpd.lastLoginFailure = 0 + httpd.loginFailureCount = {} httpd.maxReplies = maxReplies httpd.tokens = {} httpd.tokensLookup = {} From f4b0491c34316e7f21252e9ce5468cc7e81e1dd6 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 9 Jun 2021 15:01:26 +0100 Subject: [PATCH 2/7] Get forwarded IP address --- daemon.py | 55 ++++++++++++++++++++++++++++++++----------------------- utils.py | 10 ++++++++++ 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/daemon.py b/daemon.py index 3425b074a..e2420666c 100644 --- a/daemon.py +++ b/daemon.py @@ -206,6 +206,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,7 +1438,10 @@ class PubServer(BaseHTTPRequestHandler): return authHeader = \ createBasicAuthHeader(loginNickname, loginPassword) - ipAddress = self.client_address[0] + if self.headers.get('X-Forwarded-For'): + ipAddress = self.headers['X-Forwarded-For'] + else: + ipAddress = self.client_address[0] print('Login attempt from IP: ' + str(ipAddress)) if not authorizeBasic(baseDir, '/users/' + loginNickname + '/outbox', @@ -1446,28 +1450,33 @@ class PubServer(BaseHTTPRequestHandler): self._clearLoginDetails(loginNickname, callingDomain) failTime = int(time.time()) self.server.lastLoginFailure = failTime - if not self.server.loginFailureCount.get(ipAddress): - while len(self.server.loginFailureCount.items()) > 100: - oldestTime = 0 - oldestIP = None - for ipAddr, ipItem in self.server.loginFailureCount: - if oldestTime == 0 or ipItem['time'] < oldestTime: - oldestTime = ipItem['time'] - oldestIP = ipAddr - if oldestTime > 0: - del self.server.loginFailureCount[oldestIP] - self.server.loginFailureCount[ipAddress] = { - "count": 1, - "time": failTime - } - else: - self.server.loginFailureCount[ipAddress]['count'] += 1 - failCount = \ - self.server.loginFailureCount[ipAddress]['count'] - if failCount > 4: - print('WARN: ' + str(ipAddress) + - ' failed to log in ' + str(failCount) + ' times') - self.server.loginFailureCount[ipAddress]['time'] = failTime + if not isLocalNetworkAddress(ipAddress): + countDict = self.server.loginFailureCount + 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 oldestTime > 0: + del countDict[oldestIP] + countDict[ipAddress] = { + "count": 1, + "time": failTime + } + else: + countDict[ipAddress]['count'] += 1 + failCount = \ + countDict[ipAddress]['count'] + if failCount > 4: + print('WARN: ' + str(ipAddress) + + ' failed to log in ' + + str(failCount) + ' times') + countDict[ipAddress]['time'] = \ + failTime self.server.POSTbusy = False return else: 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 """ From 685ed0c22e1ef630d63cd5ca517e5c849c6b2157 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 9 Jun 2021 15:27:35 +0100 Subject: [PATCH 3/7] Tidying --- auth.py | 27 +++++++++++++++++++++++++++ daemon.py | 33 ++++++--------------------------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/auth.py b/auth.py index 5d3dbdf8e..ae5755167 100644 --- a/auth.py +++ b/auth.py @@ -204,3 +204,30 @@ def createPassword(length=10): validChars = 'abcdefghijklmnopqrstuvwxyz' + \ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' return ''.join((secrets.choice(validChars) for i in range(length))) + + +def recordLoginFailure(ipAddress: str, countDict: {}, failTime: int) -> 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 + failCount = countDict[ipAddress]['count'] + if failCount > 4: + print('WARN: ' + str(ipAddress) + ' failed to log in ' + + str(failCount) + ' times') + countDict[ipAddress]['time'] = failTime diff --git a/daemon.py b/daemon.py index e2420666c..16d68c616 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 @@ -1442,7 +1443,8 @@ class PubServer(BaseHTTPRequestHandler): ipAddress = self.headers['X-Forwarded-For'] else: ipAddress = self.client_address[0] - print('Login attempt from IP: ' + str(ipAddress)) + if not isLocalNetworkAddress(ipAddress): + print('Login attempt from IP: ' + str(ipAddress)) if not authorizeBasic(baseDir, '/users/' + loginNickname + '/outbox', authHeader, False): @@ -1451,32 +1453,9 @@ class PubServer(BaseHTTPRequestHandler): failTime = int(time.time()) self.server.lastLoginFailure = failTime if not isLocalNetworkAddress(ipAddress): - countDict = self.server.loginFailureCount - 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 oldestTime > 0: - del countDict[oldestIP] - countDict[ipAddress] = { - "count": 1, - "time": failTime - } - else: - countDict[ipAddress]['count'] += 1 - failCount = \ - countDict[ipAddress]['count'] - if failCount > 4: - print('WARN: ' + str(ipAddress) + - ' failed to log in ' + - str(failCount) + ' times') - countDict[ipAddress]['time'] = \ - failTime + recordLoginFailure(ipAddress, + self.server.loginFailureCount, + failTime) self.server.POSTbusy = False return else: From 1929f97bf4a62d118e658ff0d04c3919488b2075 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 9 Jun 2021 16:19:30 +0100 Subject: [PATCH 4/7] Option to log login failures to file --- auth.py | 22 ++++++++++++++++++++-- daemon.py | 19 ++++++++++++------- epicyon.py | 13 ++++++++++++- tests.py | 9 ++++++--- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/auth.py b/auth.py index ae5755167..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 @@ -206,7 +207,9 @@ def createPassword(length=10): return ''.join((secrets.choice(validChars) for i in range(length))) -def recordLoginFailure(ipAddress: str, countDict: {}, failTime: int) -> None: +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 """ @@ -226,8 +229,23 @@ def recordLoginFailure(ipAddress: str, countDict: {}, failTime: int) -> None: } 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') - countDict[ipAddress]['time'] = failTime + + 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 16d68c616..f0469e682 100644 --- a/daemon.py +++ b/daemon.py @@ -1443,8 +1443,9 @@ class PubServer(BaseHTTPRequestHandler): ipAddress = self.headers['X-Forwarded-For'] else: ipAddress = self.client_address[0] - if not isLocalNetworkAddress(ipAddress): - print('Login attempt from IP: ' + str(ipAddress)) + 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): @@ -1452,10 +1453,12 @@ class PubServer(BaseHTTPRequestHandler): self._clearLoginDetails(loginNickname, callingDomain) failTime = int(time.time()) self.server.lastLoginFailure = failTime - if not isLocalNetworkAddress(ipAddress): - recordLoginFailure(ipAddress, - self.server.loginFailureCount, - 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: @@ -14844,7 +14847,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, @@ -15113,6 +15117,7 @@ def runDaemon(city: str, 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, From bb3847481f0d4ee6694d539814205caf1669f5db Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 9 Jun 2021 17:34:21 +0100 Subject: [PATCH 5/7] X-Forward --- daemon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/daemon.py b/daemon.py index f0469e682..d5beec53c 100644 --- a/daemon.py +++ b/daemon.py @@ -1439,8 +1439,8 @@ class PubServer(BaseHTTPRequestHandler): return authHeader = \ createBasicAuthHeader(loginNickname, loginPassword) - if self.headers.get('X-Forwarded-For'): - ipAddress = self.headers['X-Forwarded-For'] + if self.headers.get('X-Forward-For'): + ipAddress = self.headers['X-Forward-For'] else: ipAddress = self.client_address[0] if not domain.endswith('.onion'): From ec61f16d2e42ae9643f680bfe1642101aa7b312e Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 9 Jun 2021 17:35:22 +0100 Subject: [PATCH 6/7] forward or forwarded --- daemon.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/daemon.py b/daemon.py index d5beec53c..7d3bc702e 100644 --- a/daemon.py +++ b/daemon.py @@ -1441,6 +1441,8 @@ class PubServer(BaseHTTPRequestHandler): 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'): From c2ed66e81d1f7c68b269236d77fac9028c00ae10 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Wed, 9 Jun 2021 18:17:20 +0100 Subject: [PATCH 7/7] Mention fail2ban --- README.md | 4 +++- website/EN/index.html | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fc9fa0755..d3aa297e1 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,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 @@ -204,6 +204,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/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