Files
Athena-Deck/auth.py
T

141 lines
6.3 KiB
Python

"""Single administrator credentials, independent API token and atomic persistence."""
import hashlib
import hmac
import json
import os
from pathlib import Path
import re
import secrets
import threading
import time
ITERATIONS = 600000
def password_hash(password):
if not isinstance(password, str) or not 16 <= len(password) <= 256:
raise ValueError('Das Kennwort muss 16 bis 256 Zeichen lang sein.')
salt = secrets.token_bytes(16)
return dict(salt=salt.hex(), hash=hashlib.pbkdf2_hmac('sha256', password.encode(), salt, ITERATIONS).hex())
def verify_password(password, record):
if not isinstance(password, str) or len(password) > 256 or not record:
return False
actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(record['salt']), ITERATIONS).hex()
return hmac.compare_digest(actual, record['hash'])
def token_hash(token):
if not isinstance(token, str) or not re.fullmatch(r'[A-Za-z0-9_-]{32,256}', token):
raise ValueError('Der API-Token muss 32 bis 256 Zeichen enthalten: Buchstaben, Ziffern, - oder _.')
return hashlib.sha256(token.encode()).hexdigest()
def normalize(record):
# Existing 0.2 installs keep their password; API access stays disabled until a token is set.
if record and set(record) == {'salt', 'hash'}:
return dict(version=1, password=record, revision=record['hash'], api_token_hash=None,
password_changed_at=None, token_changed_at=None)
return record
def validate_record(record):
if not isinstance(record, dict) or set(record) != {'version','password','revision','api_token_hash','password_changed_at','token_changed_at'}:
raise ValueError('Ungültiger Zugangsdatenstand.')
p = record['password']
if record['version'] != 1 or not isinstance(p, dict) or set(p) != {'salt','hash'}:
raise ValueError('Ungültiger Zugangsdatenstand.')
if not re.fullmatch('[a-f0-9]{32}', str(p['salt'])) or not re.fullmatch('[a-f0-9]{64}', str(p['hash'])):
raise ValueError('Ungültiger Zugangsdatenstand.')
if not re.fullmatch('[a-f0-9]{32,64}', str(record['revision'])):
raise ValueError('Ungültiger Zugangsdatenstand.')
if record['api_token_hash'] is not None and not re.fullmatch('[a-f0-9]{64}', str(record['api_token_hash'])):
raise ValueError('Ungültiger Zugangsdatenstand.')
for field in ('password_changed_at','token_changed_at'):
if record[field] is not None and (not isinstance(record[field], (float,int)) or not 0 < record[field] < 1e12):
raise ValueError('Ungültiger Zugangsdatenstand.')
return record
def initial_record(password, token):
if password == token:
raise ValueError('Oberflächenkennwort und API-Token müssen verschieden sein.')
now = time.time()
return dict(version=1, password=password_hash(password), revision=secrets.token_hex(16),
api_token_hash=token_hash(token), password_changed_at=now, token_changed_at=now)
def atomic_write(path, record):
path = Path(path)
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = path.with_name(path.name+'.'+secrets.token_hex(8)+'.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd,'w') as stream:
json.dump(record,stream)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary,path)
finally:
temporary.unlink(missing_ok=True)
class CredentialStore:
def __init__(self, path=None, rpc=None):
self.path = Path(path) if path else None
self.rpc = rpc
self.lock = threading.RLock()
def read(self):
with self.lock:
if self.rpc:
record = self.rpc({'action':'credentials-read'})['credentials']
else:
try:
record = json.loads(self.path.read_text())
except FileNotFoundError:
return None
return validate_record(normalize(record)) if record else None
def write(self, record, expected):
validate_record(record)
if self.rpc:
self.rpc({'action':'credentials-write','expected_revision':expected,'credentials':record})
else:
current = self.read()
if (current['revision'] if current else None) != expected:
raise ValueError('Zugangsdaten wurden inzwischen geändert. Bitte erneut anmelden.')
atomic_write(self.path, record)
def setup(self, password, token):
with self.lock:
if self.read() is not None:
raise ValueError('Die Ersteinrichtung ist bereits abgeschlossen.')
record = initial_record(password,token)
self.write(record,None)
return record
def change(self, kind, current_password, value):
with self.lock:
record = self.read()
if not record or not verify_password(current_password,record['password']):
raise ValueError('Das aktuelle Kennwort ist nicht korrekt.')
expected = record['revision']
if kind == 'password':
if verify_password(value,record['password']):
raise ValueError('Bitte ein anderes neues Kennwort wählen.')
if record['api_token_hash'] and isinstance(value,str) and hmac.compare_digest(hashlib.sha256(value.encode()).hexdigest(),record['api_token_hash']):
raise ValueError('Oberflächenkennwort und API-Token müssen verschieden sein.')
record.update(password=password_hash(value), password_changed_at=time.time(), revision=secrets.token_hex(16))
elif kind == 'token':
digest = token_hash(value)
if verify_password(value,record['password']):
raise ValueError('Oberflächenkennwort und API-Token müssen verschieden sein.')
if record['api_token_hash'] and hmac.compare_digest(digest,record['api_token_hash']):
raise ValueError('Bitte einen anderen neuen API-Token wählen.')
record.update(api_token_hash=digest,token_changed_at=time.time(),revision=secrets.token_hex(16))
else:
raise ValueError('Unbekannte Zugangsdatenaktion.')
self.write(record,expected)
return record