epicyon/auth.py

291 lines
10 KiB
Python
Raw Normal View History

2020-04-01 19:29:56 +00:00
__filename__ = "auth.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
2021-01-26 10:07:42 +00:00
__version__ = "1.2.0"
2020-04-01 19:29:56 +00:00
__maintainer__ = "Bob Mottram"
2021-09-10 16:14:50 +00:00
__email__ = "bob@libreserver.org"
2020-04-01 19:29:56 +00:00
__status__ = "Production"
2021-06-15 15:08:12 +00:00
__module_group__ = "Security"
2019-07-03 18:24:44 +00:00
import base64
import hashlib
import binascii
import os
import secrets
2021-06-09 15:19:30 +00:00
import datetime
2021-12-27 15:41:04 +00:00
from utils import is_system_account
2021-12-26 12:19:00 +00:00
from utils import has_users_path
2019-07-03 18:24:44 +00:00
2020-04-01 19:29:56 +00:00
def _hashPassword(password: str) -> str:
2019-07-03 18:24:44 +00:00
"""Hash a password for storing
"""
2020-04-01 19:29:56 +00:00
salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii')
pwdhash = hashlib.pbkdf2_hmac('sha512',
password.encode('utf-8'),
salt, 100000)
pwdhash = binascii.hexlify(pwdhash)
return (salt + pwdhash).decode('ascii')
def _getPasswordHash(salt: str, providedPassword: str) -> str:
2020-09-03 18:07:02 +00:00
"""Returns the hash of a password
2019-07-03 18:24:44 +00:00
"""
2020-04-01 19:29:56 +00:00
pwdhash = hashlib.pbkdf2_hmac('sha512',
providedPassword.encode('utf-8'),
salt.encode('ascii'),
100000)
2020-09-03 18:07:02 +00:00
return binascii.hexlify(pwdhash).decode('ascii')
def constantTimeStringCheck(string1: str, string2: str) -> bool:
"""Compares two string and returns if they are the same
using a constant amount of time
See https://sqreen.github.io/DevelopersSecurityBestPractices/
timing-attack/python
2020-09-03 18:07:02 +00:00
"""
# strings must be of equal length
if len(string1) != len(string2):
2020-09-03 18:07:02 +00:00
return False
ctr = 0
matched = True
for ch in string1:
if ch != string2[ctr]:
2020-09-03 18:07:02 +00:00
matched = False
else:
# this is to make the timing more even
# and not provide clues
matched = matched
2020-09-03 18:07:02 +00:00
ctr += 1
return matched
2020-04-01 19:29:56 +00:00
def _verifyPassword(storedPassword: str, providedPassword: str) -> bool:
"""Verify a stored password against one provided by user
"""
if not storedPassword:
return False
if not providedPassword:
return False
salt = storedPassword[:64]
storedPassword = storedPassword[64:]
pwHash = _getPasswordHash(salt, providedPassword)
return constantTimeStringCheck(pwHash, storedPassword)
2020-04-01 19:29:56 +00:00
def createBasicAuthHeader(nickname: str, password: str) -> str:
2019-07-03 18:24:44 +00:00
"""This is only used by tests
"""
2020-05-22 11:32:38 +00:00
authStr = \
nickname.replace('\n', '').replace('\r', '') + \
':' + \
password.replace('\n', '').replace('\r', '')
2020-04-01 19:29:56 +00:00
return 'Basic ' + base64.b64encode(authStr.encode('utf-8')).decode('utf-8')
2019-07-03 18:24:44 +00:00
2020-04-01 19:29:56 +00:00
2021-12-25 16:17:53 +00:00
def authorizeBasic(base_dir: str, path: str, authHeader: str,
2020-04-01 19:29:56 +00:00
debug: bool) -> bool:
2019-07-03 18:24:44 +00:00
"""HTTP basic auth
"""
if ' ' not in authHeader:
2019-07-04 08:56:15 +00:00
if debug:
print('DEBUG: basic auth - Authorisation header does not ' +
2020-03-30 19:09:45 +00:00
'contain a space character')
2019-07-03 18:24:44 +00:00
return False
2021-12-26 12:19:00 +00:00
if not has_users_path(path):
2019-07-04 08:56:15 +00:00
if debug:
print('DEBUG: basic auth - ' +
'path for Authorization does not contain a user')
2019-07-04 08:56:15 +00:00
return False
2020-04-01 19:29:56 +00:00
pathUsersSection = path.split('/users/')[1]
2019-07-04 08:56:15 +00:00
if '/' not in pathUsersSection:
if debug:
print('DEBUG: basic auth - this is not a users endpoint')
2019-07-04 08:56:15 +00:00
return False
2020-04-01 19:29:56 +00:00
nicknameFromPath = pathUsersSection.split('/')[0]
2021-12-27 15:41:04 +00:00
if is_system_account(nicknameFromPath):
print('basic auth - attempted login using system account ' +
nicknameFromPath + ' in path')
return False
2020-05-22 11:32:38 +00:00
base64Str = \
authHeader.split(' ')[1].replace('\n', '').replace('\r', '')
2020-04-01 19:29:56 +00:00
plain = base64.b64decode(base64Str).decode('utf-8')
2019-07-03 18:24:44 +00:00
if ':' not in plain:
2019-07-04 08:56:15 +00:00
if debug:
2020-11-23 10:18:52 +00:00
print('DEBUG: basic auth header does not contain a ":" ' +
2020-03-30 19:09:45 +00:00
'separator for username:password')
2019-07-03 18:24:44 +00:00
return False
2020-04-01 19:29:56 +00:00
nickname = plain.split(':')[0]
2021-12-27 15:41:04 +00:00
if is_system_account(nickname):
print('basic auth - attempted login using system account ' + nickname +
' in Auth header')
return False
2020-04-01 19:29:56 +00:00
if nickname != nicknameFromPath:
2019-07-04 08:56:15 +00:00
if debug:
2020-04-01 19:29:56 +00:00
print('DEBUG: Nickname given in the path (' + nicknameFromPath +
') does not match the one in the Authorization header (' +
nickname + ')')
2019-07-04 08:56:15 +00:00
return False
2021-12-25 16:17:53 +00:00
passwordFile = base_dir + '/accounts/passwords'
2019-07-03 18:24:44 +00:00
if not os.path.isfile(passwordFile):
2019-07-04 08:56:15 +00:00
if debug:
print('DEBUG: passwords file missing')
2019-07-03 18:24:44 +00:00
return False
2020-04-01 19:29:56 +00:00
providedPassword = plain.split(':')[1]
2021-11-26 12:28:20 +00:00
try:
with open(passwordFile, 'r') as passfile:
for line in passfile:
if not line.startswith(nickname + ':'):
continue
storedPassword = \
line.split(':')[1].replace('\n', '').replace('\r', '')
success = _verifyPassword(storedPassword, providedPassword)
if not success:
if debug:
print('DEBUG: Password check failed for ' + nickname)
return success
except OSError:
print('EX: failed to open password file')
return False
2020-04-01 19:29:56 +00:00
print('DEBUG: Did not find credentials for ' + nickname +
' in ' + passwordFile)
2019-07-03 18:24:44 +00:00
return False
2020-04-01 19:29:56 +00:00
2021-12-25 16:17:53 +00:00
def storeBasicCredentials(base_dir: str, nickname: str, password: str) -> bool:
2019-07-05 09:51:58 +00:00
"""Stores login credentials to a file
"""
2019-07-03 18:24:44 +00:00
if ':' in nickname or ':' in password:
return False
2020-05-22 11:32:38 +00:00
nickname = nickname.replace('\n', '').replace('\r', '').strip()
password = password.replace('\n', '').replace('\r', '').strip()
2019-07-03 18:24:44 +00:00
2021-12-25 16:17:53 +00:00
if not os.path.isdir(base_dir + '/accounts'):
os.mkdir(base_dir + '/accounts')
2019-07-03 18:24:44 +00:00
2021-12-25 16:17:53 +00:00
passwordFile = base_dir + '/accounts/passwords'
storeStr = nickname + ':' + _hashPassword(password)
2019-07-03 18:24:44 +00:00
if os.path.isfile(passwordFile):
2020-04-01 19:29:56 +00:00
if nickname + ':' in open(passwordFile).read():
2021-11-25 18:42:38 +00:00
try:
with open(passwordFile, 'r') as fin:
with open(passwordFile + '.new', 'w+') as fout:
for line in fin:
if not line.startswith(nickname + ':'):
fout.write(line)
else:
fout.write(storeStr + '\n')
2021-12-25 15:28:52 +00:00
except OSError as ex:
2021-11-25 22:22:54 +00:00
print('EX: unable to save password ' + passwordFile +
2021-12-25 15:28:52 +00:00
' ' + str(ex))
2021-11-25 18:42:38 +00:00
return False
try:
os.rename(passwordFile + '.new', passwordFile)
except OSError:
2021-11-25 22:22:54 +00:00
print('EX: unable to save password 2')
2021-11-25 18:42:38 +00:00
return False
2019-07-03 18:24:44 +00:00
else:
# append to password file
2021-11-25 18:42:38 +00:00
try:
with open(passwordFile, 'a+') as passfile:
passfile.write(storeStr + '\n')
except OSError:
2021-11-25 22:22:54 +00:00
print('EX: unable to append password')
2021-11-25 18:42:38 +00:00
return False
2019-07-03 18:24:44 +00:00
else:
2021-11-25 18:42:38 +00:00
try:
with open(passwordFile, 'w+') as passfile:
passfile.write(storeStr + '\n')
except OSError:
2021-11-25 22:22:54 +00:00
print('EX: unable to create password file')
2021-11-25 18:42:38 +00:00
return False
2019-07-03 18:24:44 +00:00
return True
2020-04-01 19:29:56 +00:00
2021-12-25 16:17:53 +00:00
def removePassword(base_dir: str, nickname: str) -> None:
2019-07-05 09:51:58 +00:00
"""Removes the password entry for the given nickname
This is called during account removal
"""
2021-12-25 16:17:53 +00:00
passwordFile = base_dir + '/accounts/passwords'
2019-07-05 09:49:57 +00:00
if os.path.isfile(passwordFile):
2021-11-25 18:42:38 +00:00
try:
with open(passwordFile, 'r') as fin:
with open(passwordFile + '.new', 'w+') as fout:
for line in fin:
if not line.startswith(nickname + ':'):
fout.write(line)
2021-12-25 15:28:52 +00:00
except OSError as ex:
print('EX: unable to remove password from file ' + str(ex))
2021-11-25 18:42:38 +00:00
return
try:
os.rename(passwordFile + '.new', passwordFile)
except OSError:
2021-11-25 22:22:54 +00:00
print('EX: unable to remove password from file 2')
2021-11-25 18:42:38 +00:00
return
2019-07-05 09:49:57 +00:00
2020-04-01 19:29:56 +00:00
2021-12-25 16:17:53 +00:00
def authorize(base_dir: str, path: str, authHeader: str, debug: bool) -> bool:
2019-07-05 09:51:58 +00:00
"""Authorize using http header
"""
2019-07-03 18:24:44 +00:00
if authHeader.lower().startswith('basic '):
2021-12-25 16:17:53 +00:00
return authorizeBasic(base_dir, path, authHeader, debug)
2019-07-03 18:24:44 +00:00
return False
2019-07-05 11:27:18 +00:00
2020-04-01 19:29:56 +00:00
def createPassword(length: int):
2020-04-01 19:29:56 +00:00
validChars = 'abcdefghijklmnopqrstuvwxyz' + \
'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
2020-07-08 15:09:27 +00:00
return ''.join((secrets.choice(validChars) for i in range(length)))
2021-06-09 14:27:35 +00:00
2021-12-25 16:17:53 +00:00
def recordLoginFailure(base_dir: str, ipAddress: str,
2021-06-09 15:19:30 +00:00
countDict: {}, failTime: int,
logToFile: bool) -> None:
2021-06-09 14:27:35 +00:00
"""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
2021-06-09 15:19:30 +00:00
countDict[ipAddress]['time'] = failTime
2021-06-09 14:27:35 +00:00
failCount = countDict[ipAddress]['count']
if failCount > 4:
print('WARN: ' + str(ipAddress) + ' failed to log in ' +
str(failCount) + ' times')
2021-06-09 15:19:30 +00:00
if not logToFile:
return
2021-12-25 16:17:53 +00:00
failureLog = base_dir + '/accounts/loginfailures.log'
writeType = 'a+'
2021-06-09 15:19:30 +00:00
if not os.path.isfile(failureLog):
writeType = 'w+'
2021-12-26 13:17:46 +00:00
curr_time = datetime.datetime.utcnow()
curr_timeStr = curr_time.strftime("%Y-%m-%d %H:%M:%SZ")
try:
with open(failureLog, writeType) as fp:
# here we use a similar format to an ssh log, so that
# systems such as fail2ban can parse it
2021-12-26 13:17:46 +00:00
fp.write(curr_timeStr + ' ' +
'ip-127-0-0-1 sshd[20710]: ' +
'Disconnecting invalid user epicyon ' +
ipAddress + ' port 443: ' +
'Too many authentication failures [preauth]\n')
2021-11-25 18:42:38 +00:00
except OSError:
2021-10-29 16:31:20 +00:00
print('EX: recordLoginFailure failed ' + str(failureLog))