"""Passwortrichtlinie: global vom Admin einstellbar, serverseitig durchgesetzt. Die Werte liegen in ``app_settings`` (nicht in ``user_settings`` – die Richtlinie gilt für die ganze Instanz). Das Frontend wertet dieselben Regeln nochmal lokal aus, um beim Tippen eine Checkliste abzuhaken; verbindlich ist ausschließlich diese Datei. Zur wiederkehrenden Frage „kann man bestehende Passwörter gegen eine neue Richtlinie prüfen?": nicht über den Hash – der gibt das Passwort nicht her. Wohl aber beim Anmelden, denn dort liegt das Klartext-Passwort für einen Moment vor. Genau das macht ``routers/auth.login``. """ from __future__ import annotations from dataclasses import asdict, dataclass from fastapi import HTTPException, status from sqlalchemy.orm import Session from ..models import AppSetting # Grenzen für die Mindestlänge – nach unten, damit die Richtlinie nicht sinnlos # wird, nach oben, damit sich niemand selbst aussperrt. MIN_LENGTH_FLOOR = 8 MIN_LENGTH_CEILING = 128 def is_special(c: str) -> bool: """Sonderzeichen = alles, was weder Buchstabe/Ziffer noch Leerraum ist. Bewusst nicht über eine feste ASCII-Liste: „§" oder „€" sollen zählen, Umlaute wie „ü" dagegen nicht (die sind Buchstaben).""" return not c.isalnum() and not c.isspace() @dataclass class Policy: min_length: int = 10 require_upper: bool = True require_lower: bool = True require_digit: bool = True require_special: bool = False mfa_required: bool = False def as_dict(self) -> dict: return asdict(self) # Schlüssel in app_settings <-> Feldname im Datenobjekt. _KEYS = { "password_min_length": "min_length", "password_require_upper": "require_upper", "password_require_lower": "require_lower", "password_require_digit": "require_digit", "password_require_special": "require_special", "mfa_required": "mfa_required", } # Menschenlesbare Begründung je Regel – wird bei Verstößen zusammengesetzt. RULE_TEXTS = { "length": "mindestens {n} Zeichen", "upper": "mindestens einen Grossbuchstaben", "lower": "mindestens einen Kleinbuchstaben", "digit": "mindestens eine Ziffer", "special": "mindestens ein Sonderzeichen", } def _clamp_length(value: int) -> int: return max(MIN_LENGTH_FLOOR, min(MIN_LENGTH_CEILING, value)) def load(db: Session) -> Policy: """Liest die Richtlinie; fehlende Schlüssel fallen auf die Vorgaben zurück.""" stored = {row.key: row.value for row in db.query(AppSetting).all()} pol = Policy() for key, field in _KEYS.items(): raw = stored.get(key) if raw is None: continue if field == "min_length": try: pol.min_length = _clamp_length(int(raw)) except ValueError: pass else: setattr(pol, field, raw == "true") return pol def save(db: Session, pol: Policy) -> Policy: """Schreibt die Richtlinie; die Mindestlänge wird auf den erlaubten Bereich begrenzt.""" pol.min_length = _clamp_length(pol.min_length) values = pol.as_dict() for key, field in _KEYS.items(): value = values[field] text = str(value) if field == "min_length" else ("true" if value else "false") row = db.get(AppSetting, key) if row is None: db.add(AppSetting(key=key, value=text)) else: row.value = text db.commit() return pol def violations(password: str, pol: Policy) -> list[str]: """Gibt die Kennungen der *nicht* erfüllten Regeln zurück (leer = in Ordnung).""" offen: list[str] = [] if len(password) < pol.min_length: offen.append("length") if pol.require_upper and not any(c.isupper() for c in password): offen.append("upper") if pol.require_lower and not any(c.islower() for c in password): offen.append("lower") if pol.require_digit and not any(c.isdigit() for c in password): offen.append("digit") if pol.require_special and not any(is_special(c) for c in password): offen.append("special") return offen def assert_ok(db: Session, password: str) -> None: """Wirft 400 mit einer Klartext-Begründung, wenn das Passwort nicht genügt.""" pol = load(db) offen = violations(password, pol) if not offen: return teile = [RULE_TEXTS[r].format(n=pol.min_length) for r in offen] raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Das Passwort erfüllt die Richtlinie nicht: " + ", ".join(teile) + ".", )