epicyon/auth.py

313 lines
11 KiB
Python
Raw Normal View History

2020-04-01 19:29:56 +00:00
__filename__ = "auth.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
2024-01-21 19:01:20 +00:00
__version__ = "1.5.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
from flags import is_system_account
from flags import is_memorial_account
2024-05-12 12:35:26 +00:00
from utils import data_dir
2021-12-26 12:19:00 +00:00
from utils import has_users_path
2022-06-10 09:24:11 +00:00
from utils import text_in_file
2022-06-21 11:58:50 +00:00
from utils import remove_eol
2023-11-20 22:27:58 +00:00
from utils import date_utcnow
2019-07-03 18:24:44 +00:00
2020-04-01 19:29:56 +00:00
2021-12-29 21:55:09 +00:00
def _hash_password(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')
2021-12-29 23:10:55 +00:00
def _get_password_hash(salt: str, provided_password: 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',
2021-12-29 23:10:55 +00:00
provided_password.encode('utf-8'),
2020-04-01 19:29:56 +00:00
salt.encode('ascii'),
100000)
2020-09-03 18:07:02 +00:00
return binascii.hexlify(pwdhash).decode('ascii')
2021-12-29 21:55:09 +00:00
def constant_time_string_check(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
2021-12-29 23:10:55 +00:00
for char in string1:
if char != 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
2021-12-29 23:10:55 +00:00
def _verify_password(stored_password: str, provided_password: str) -> bool:
"""Verify a stored password against one provided by user
"""
2021-12-29 23:10:55 +00:00
if not stored_password:
return False
2021-12-29 23:10:55 +00:00
if not provided_password:
return False
2021-12-29 23:10:55 +00:00
salt = stored_password[:64]
stored_password = stored_password[64:]
pw_hash = _get_password_hash(salt, provided_password)
return constant_time_string_check(pw_hash, stored_password)
2021-12-28 21:36:27 +00:00
def create_basic_auth_header(nickname: str, password: str) -> str:
2019-07-03 18:24:44 +00:00
"""This is only used by tests
"""
2021-12-29 23:10:55 +00:00
auth_str = \
2022-06-21 11:58:50 +00:00
remove_eol(nickname) + \
2020-05-22 11:32:38 +00:00
':' + \
2022-06-21 11:58:50 +00:00
remove_eol(password)
2021-12-29 23:10:55 +00:00
return 'Basic ' + \
base64.b64encode(auth_str.encode('utf-8')).decode('utf-8')
2019-07-03 18:24:44 +00:00
2020-04-01 19:29:56 +00:00
2021-12-29 23:10:55 +00:00
def authorize_basic(base_dir: str, path: str, auth_header: str,
2021-12-28 21:36:27 +00:00
debug: bool) -> bool:
2019-07-03 18:24:44 +00:00
"""HTTP basic auth
"""
2021-12-29 23:10:55 +00:00
if ' ' not in auth_header:
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):
2022-02-24 16:55:37 +00:00
if not path.startswith('/calendars/'):
if debug:
print('DEBUG: basic auth - ' +
'path for Authorization does not contain a user')
return False
if path.startswith('/calendars/'):
path_users_section = path.split('/calendars/')[1]
nickname_from_path = path_users_section
if '/' in nickname_from_path:
nickname_from_path = nickname_from_path.split('/')[0]
if '?' in nickname_from_path:
nickname_from_path = nickname_from_path.split('?')[0]
else:
path_users_section = path.split('/users/')[1]
if '/' not in path_users_section:
if debug:
print('DEBUG: basic auth - this is not a users endpoint')
return False
nickname_from_path = path_users_section.split('/')[0]
2021-12-29 23:10:55 +00:00
if is_system_account(nickname_from_path):
print('basic auth - attempted login using system account ' +
2021-12-29 23:10:55 +00:00
nickname_from_path + ' in path')
return False
2022-06-21 11:58:50 +00:00
base64_str1 = auth_header.split(' ')[1]
base64_str = remove_eol(base64_str1)
2021-12-29 23:10:55 +00:00
plain = base64.b64decode(base64_str).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
2021-12-29 23:10:55 +00:00
if nickname != nickname_from_path:
2019-07-04 08:56:15 +00:00
if debug:
2021-12-29 23:10:55 +00:00
print('DEBUG: Nickname given in the path (' + nickname_from_path +
2020-04-01 19:29:56 +00:00
') does not match the one in the Authorization header (' +
nickname + ')')
2019-07-04 08:56:15 +00:00
return False
2023-08-30 17:15:31 +00:00
if is_memorial_account(base_dir, nickname):
print('basic auth - attempted login using memorial account ' +
nickname + ' in Auth header')
return False
2024-05-12 12:35:26 +00:00
password_file = data_dir(base_dir) + '/passwords'
2021-12-29 23:10:55 +00:00
if not os.path.isfile(password_file):
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
2021-12-29 23:10:55 +00:00
provided_password = plain.split(':')[1]
2021-11-26 12:28:20 +00:00
try:
2024-07-16 10:53:02 +00:00
with open(password_file, 'r', encoding='utf-8') as fp_pass:
for line in fp_pass:
2021-11-26 12:28:20 +00:00
if not line.startswith(nickname + ':'):
continue
2022-06-21 11:58:50 +00:00
stored_password_base = line.split(':')[1]
stored_password = remove_eol(stored_password_base)
2021-12-29 23:10:55 +00:00
success = _verify_password(stored_password, provided_password)
2021-11-26 12:28:20 +00:00
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 +
2021-12-29 23:10:55 +00:00
' in ' + password_file)
2019-07-03 18:24:44 +00:00
return False
2020-04-01 19:29:56 +00:00
2021-12-28 21:36:27 +00:00
def store_basic_credentials(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
2022-06-21 11:58:50 +00:00
nickname = remove_eol(nickname).strip()
password = remove_eol(password).strip()
2019-07-03 18:24:44 +00:00
2024-05-12 12:35:26 +00:00
dir_str = data_dir(base_dir)
if not os.path.isdir(dir_str):
os.mkdir(dir_str)
2019-07-03 18:24:44 +00:00
2024-05-12 12:35:26 +00:00
password_file = dir_str + '/passwords'
2021-12-29 23:10:55 +00:00
store_str = nickname + ':' + _hash_password(password)
if os.path.isfile(password_file):
2022-06-10 09:24:11 +00:00
if text_in_file(nickname + ':', password_file):
2021-11-25 18:42:38 +00:00
try:
2024-07-16 10:53:02 +00:00
with open(password_file, 'r', encoding='utf-8') as fp_in:
2022-06-09 14:46:30 +00:00
with open(password_file + '.new', 'w+',
encoding='utf-8') as fout:
2024-07-16 10:53:02 +00:00
for line in fp_in:
2021-11-25 18:42:38 +00:00
if not line.startswith(nickname + ':'):
fout.write(line)
else:
2021-12-29 23:10:55 +00:00
fout.write(store_str + '\n')
2021-12-25 15:28:52 +00:00
except OSError as ex:
2021-12-29 23:10:55 +00:00
print('EX: unable to save password ' + password_file +
2021-12-25 15:28:52 +00:00
' ' + str(ex))
2021-11-25 18:42:38 +00:00
return False
try:
2021-12-29 23:10:55 +00:00
os.rename(password_file + '.new', password_file)
2021-11-25 18:42:38 +00:00
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:
2024-07-16 10:53:02 +00:00
with open(password_file, 'a+', encoding='utf-8') as fp_pass:
fp_pass.write(store_str + '\n')
2021-11-25 18:42:38 +00:00
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:
2024-07-16 10:53:02 +00:00
with open(password_file, 'w+', encoding='utf-8') as fp_pass:
fp_pass.write(store_str + '\n')
2021-11-25 18:42:38 +00:00
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-29 21:55:09 +00:00
def remove_password(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
"""
2024-05-12 12:35:26 +00:00
password_file = data_dir(base_dir) + '/passwords'
2021-12-29 23:10:55 +00:00
if os.path.isfile(password_file):
2021-11-25 18:42:38 +00:00
try:
2024-07-16 10:53:02 +00:00
with open(password_file, 'r', encoding='utf-8') as fp_in:
2022-06-09 14:46:30 +00:00
with open(password_file + '.new', 'w+',
2024-07-16 10:53:02 +00:00
encoding='utf-8') as fp_out:
for line in fp_in:
2021-11-25 18:42:38 +00:00
if not line.startswith(nickname + ':'):
2024-07-16 10:53:02 +00:00
fp_out.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:
2021-12-29 23:10:55 +00:00
os.rename(password_file + '.new', password_file)
2021-11-25 18:42:38 +00:00
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-29 23:10:55 +00:00
def authorize(base_dir: str, path: str, auth_header: str, debug: bool) -> bool:
2019-07-05 09:51:58 +00:00
"""Authorize using http header
"""
2021-12-29 23:10:55 +00:00
if auth_header.lower().startswith('basic '):
return authorize_basic(base_dir, path, auth_header, 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-12-28 21:36:27 +00:00
def create_password(length: int):
2021-12-29 23:10:55 +00:00
valid_chars = 'abcdefghijklmnopqrstuvwxyz' + \
2020-04-01 19:29:56 +00:00
'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
2021-12-29 23:10:55 +00:00
return ''.join((secrets.choice(valid_chars) for i in range(length)))
2021-06-09 14:27:35 +00:00
2021-12-29 23:10:55 +00:00
def record_login_failure(base_dir: str, ip_address: str,
count_dict: {}, fail_time: int,
log_to_file: 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
"""
2021-12-29 23:10:55 +00:00
if not count_dict.get(ip_address):
while len(count_dict.items()) > 100:
oldest_time = 0
oldest_ip = None
for ip_addr, ip_item in count_dict.items():
if oldest_time == 0 or ip_item['time'] < oldest_time:
oldest_time = ip_item['time']
oldest_ip = ip_addr
if oldest_ip:
del count_dict[oldest_ip]
count_dict[ip_address] = {
2021-06-09 14:27:35 +00:00
"count": 1,
2021-12-29 23:10:55 +00:00
"time": fail_time
2021-06-09 14:27:35 +00:00
}
else:
2021-12-29 23:10:55 +00:00
count_dict[ip_address]['count'] += 1
count_dict[ip_address]['time'] = fail_time
fail_count = count_dict[ip_address]['count']
if fail_count > 4:
print('WARN: ' + str(ip_address) + ' failed to log in ' +
str(fail_count) + ' times')
2021-06-09 15:19:30 +00:00
2021-12-29 23:10:55 +00:00
if not log_to_file:
2021-06-09 15:19:30 +00:00
return
2024-05-12 12:35:26 +00:00
failure_log = data_dir(base_dir) + '/loginfailures.log'
2021-12-29 23:10:55 +00:00
write_type = 'a+'
if not os.path.isfile(failure_log):
write_type = 'w+'
2023-11-20 22:27:58 +00:00
curr_time = date_utcnow()
2021-12-29 23:10:55 +00:00
curr_time_str = curr_time.strftime("%Y-%m-%d %H:%M:%SZ")
try:
2022-06-09 14:46:30 +00:00
with open(failure_log, write_type, encoding='utf-8') as fp_fail:
# here we use a similar format to an ssh log, so that
# systems such as fail2ban can parse it
2021-12-29 23:10:55 +00:00
fp_fail.write(curr_time_str + ' ' +
'ip-127-0-0-1 sshd[20710]: ' +
'Disconnecting invalid user epicyon ' +
ip_address + ' port 443: ' +
'Too many authentication failures [preauth]\n')
2021-11-25 18:42:38 +00:00
except OSError:
2021-12-29 23:10:55 +00:00
print('EX: record_login_failure failed ' + str(failure_log))