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
|
2020-09-03 18:13:29 +00:00
|
|
|
import secrets
|
2021-06-09 15:19:30 +00:00
|
|
|
import datetime
|
2020-11-23 09:51:26 +00:00
|
|
|
from utils import isSystemAccount
|
2020-12-23 10:57:44 +00:00
|
|
|
from utils import hasUsersPath
|
2019-07-03 18:24:44 +00:00
|
|
|
|
2020-04-01 19:29:56 +00:00
|
|
|
|
2020-12-22 18:06:23 +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')
|
|
|
|
|
|
|
|
|
2020-12-22 18:06:23 +00:00
|
|
|
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')
|
|
|
|
|
2020-09-03 18:13:29 +00:00
|
|
|
|
2020-09-03 18:48:32 +00:00
|
|
|
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
|
|
|
"""
|
2020-09-03 18:48:32 +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
|
2020-09-03 18:48:32 +00:00
|
|
|
for ch in string1:
|
|
|
|
if ch != string2[ctr]:
|
2020-09-03 18:07:02 +00:00
|
|
|
matched = False
|
2020-09-03 18:13:29 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
2020-12-22 18:06:23 +00:00
|
|
|
def _verifyPassword(storedPassword: str, providedPassword: str) -> bool:
|
2020-09-03 18:48:32 +00:00
|
|
|
"""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:]
|
2020-12-22 18:06:23 +00:00
|
|
|
pwHash = _getPasswordHash(salt, providedPassword)
|
2020-09-03 18:48:32 +00:00
|
|
|
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
|
|
|
|
|
|
|
def authorizeBasic(baseDir: str, path: str, authHeader: str,
|
|
|
|
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:
|
2021-07-25 21:18:38 +00:00
|
|
|
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
|
2020-12-23 10:57:44 +00:00
|
|
|
if not hasUsersPath(path):
|
2019-07-04 08:56:15 +00:00
|
|
|
if debug:
|
2020-11-23 09:51:26 +00:00
|
|
|
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:
|
2020-11-23 09:51:26 +00:00
|
|
|
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]
|
2020-11-23 09:51:26 +00:00
|
|
|
if isSystemAccount(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]
|
2020-11-23 09:51:26 +00:00
|
|
|
if isSystemAccount(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-06-22 12:29:17 +00:00
|
|
|
passwordFile = baseDir + '/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
|
|
|
|
|
|
|
def storeBasicCredentials(baseDir: 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
|
|
|
|
2020-04-01 19:29:56 +00:00
|
|
|
if not os.path.isdir(baseDir + '/accounts'):
|
|
|
|
os.mkdir(baseDir + '/accounts')
|
2019-07-03 18:24:44 +00:00
|
|
|
|
2020-04-01 19:29:56 +00:00
|
|
|
passwordFile = baseDir + '/accounts/passwords'
|
2020-12-22 18:06:23 +00:00
|
|
|
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-11-25 21:18:53 +00:00
|
|
|
except OSError as e:
|
2021-11-25 22:22:54 +00:00
|
|
|
print('EX: unable to save password ' + passwordFile +
|
2021-11-25 21:18:53 +00:00
|
|
|
' ' + str(e))
|
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
|
|
|
|
|
|
|
def removePassword(baseDir: 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
|
|
|
|
"""
|
2020-04-01 19:29:56 +00:00
|
|
|
passwordFile = baseDir + '/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-11-25 21:18:53 +00:00
|
|
|
except OSError as e:
|
2021-11-25 22:22:54 +00:00
|
|
|
print('EX: unable to remove password from file ' + str(e))
|
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
|
|
|
|
|
|
|
def authorize(baseDir: 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 '):
|
2020-04-01 19:29:56 +00:00
|
|
|
return authorizeBasic(baseDir, 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
|
|
|
|
2021-10-29 22:26:45 +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-06-09 15:19:30 +00:00
|
|
|
def recordLoginFailure(baseDir: str, ipAddress: str,
|
|
|
|
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
|
|
|
|
|
|
|
|
failureLog = baseDir + '/accounts/loginfailures.log'
|
2021-06-21 22:53:04 +00:00
|
|
|
writeType = 'a+'
|
2021-06-09 15:19:30 +00:00
|
|
|
if not os.path.isfile(failureLog):
|
2021-06-21 22:53:04 +00:00
|
|
|
writeType = 'w+'
|
2021-06-09 15:19:30 +00:00
|
|
|
currTime = datetime.datetime.utcnow()
|
2021-11-26 12:28:20 +00:00
|
|
|
currTimeStr = currTime.strftime("%Y-%m-%d %H:%M:%SZ")
|
2021-06-21 22:53:04 +00:00
|
|
|
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-11-26 12:28:20 +00:00
|
|
|
fp.write(currTimeStr + ' ' +
|
2021-06-21 22:53:04 +00:00
|
|
|
'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))
|