Add first-run admin setup and independent password and API token rotation

This commit is contained in:
Mikei386
2026-09-28 15:02:41 +02:00
parent e86e540ae4
commit 9f3a13e45f
20 changed files with 651 additions and 76 deletions
+140
View File
@@ -0,0 +1,140 @@
"""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