"""Authentifizierung: Provider-Abstraktion, Anmeldung und Token-Rotation. Die Anmeldung läuft grundsätzlich über einen `AuthProvider`. Aktuell existiert nur der lokale Provider; die Abstraktion samt `app_user.external_subject` ist bewusst vorhanden, damit ein OIDC-Provider später ohne Umbau ergänzt werden kann. """ import logging from dataclasses import dataclass from datetime import UTC, datetime from typing import Protocol, runtime_checkable from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.errors import AuthError, ValidationError from app.core.security import ( create_access_token, create_refresh_token, decode_token, hash_password, needs_rehash, verify_password, ) from app.models import AppUser, RefreshToken logger = logging.getLogger(__name__) MIN_PASSWORD_LENGTH = 10 @dataclass(frozen=True, slots=True) class Credentials: """Anmeldedaten des lokalen Providers.""" username: str password: str @dataclass(frozen=True, slots=True) class TokenPair: """Frisch ausgestelltes Token-Paar samt Ablaufzeitpunkten.""" access_token: str access_expires_at: datetime refresh_token: str refresh_expires_at: datetime @runtime_checkable class AuthProvider(Protocol): """Ein Verfahren, das einen Benutzer identifiziert.""" name: str async def authenticate(self, session: AsyncSession, credentials: object) -> AppUser: ... class LocalAuthProvider: """Benutzername und Passwort gegen den Argon2-Hash in der Datenbank.""" name = "local" async def authenticate(self, session: AsyncSession, credentials: object) -> AppUser: if not isinstance(credentials, Credentials): raise AuthError("Ungültige Anmeldedaten.", code="invalid_credentials") user = await get_user_by_username(session, credentials.username) # Auch ohne Treffer wird geprüft, damit die Antwortzeit nichts verrät. password_hash = user.password_hash if user else None if not verify_password(credentials.password, password_hash) or user is None: raise AuthError("Benutzername oder Passwort ist falsch.", code="invalid_credentials") if user.password_hash and needs_rehash(user.password_hash): user.password_hash = hash_password(credentials.password) user.last_login_at = datetime.now(UTC) await session.flush() return user # TODO(OIDC): Sobald die Anbindung umgesetzt wird, hier einen `OidcAuthProvider` # registrieren, der `OIDC_ISSUER`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` aus den # Settings nutzt und den Benutzer über `app_user.external_subject` auflöst. _PROVIDERS: dict[str, AuthProvider] = {"local": LocalAuthProvider()} def get_auth_provider(name: str = "local") -> AuthProvider: """Liefert den registrierten Provider.""" provider = _PROVIDERS.get(name) if provider is None: raise AuthError(f"Unbekanntes Anmeldeverfahren: {name}", code="unknown_provider") return provider # --- Benutzer ------------------------------------------------------------------ async def get_user_by_username(session: AsyncSession, username: str) -> AppUser | None: stmt = select(AppUser).where(func.lower(AppUser.username) == username.strip().lower()) return (await session.execute(stmt)).scalar_one_or_none() async def get_user(session: AsyncSession, user_id: int) -> AppUser | None: return await session.get(AppUser, user_id) def validate_password(password: str) -> None: """Mindestanforderungen an ein neues Passwort.""" if len(password) < MIN_PASSWORD_LENGTH: raise ValidationError( f"Das Passwort muss mindestens {MIN_PASSWORD_LENGTH} Zeichen lang sein.", code="password_too_short", ) async def change_password( session: AsyncSession, user: AppUser, current_password: str, new_password: str ) -> None: """Ändert das Passwort und macht alle bestehenden Sitzungen ungültig.""" if not verify_password(current_password, user.password_hash): raise AuthError("Das aktuelle Passwort ist falsch.", code="invalid_credentials") if current_password == new_password: raise ValidationError( "Das neue Passwort muss sich vom bisherigen unterscheiden.", code="password_unchanged", ) validate_password(new_password) user.password_hash = hash_password(new_password) user.must_change_password = False await revoke_all_refresh_tokens(session, user.id) await session.flush() async def ensure_admin_user(session: AsyncSession) -> AppUser | None: """Legt beim Erststart den konfigurierten Administrator an. Passiert nur, wenn noch kein Benutzer existiert und ein Passwort gesetzt ist. Der Benutzer muss das Passwort bei der ersten Anmeldung ändern. """ existing = (await session.execute(select(func.count()).select_from(AppUser))).scalar_one() if existing: return None if not settings.moneyfy_admin_password: logger.warning( "Kein Benutzer vorhanden und MONEYFY_ADMIN_PASSWORD ist nicht gesetzt – " "es kann sich niemand anmelden." ) return None user = AppUser( username=settings.moneyfy_admin_user, password_hash=hash_password(settings.moneyfy_admin_password), must_change_password=True, ) session.add(user) await session.flush() logger.info("Administrator '%s' angelegt (Passwortwechsel erforderlich).", user.username) return user # --- Sitzungen ----------------------------------------------------------------- async def issue_tokens(session: AsyncSession, user: AppUser) -> TokenPair: """Stellt ein neues Token-Paar aus und hinterlegt das Refresh-Token.""" access_token, _, access_expires = create_access_token(user.id) refresh_token, jti, refresh_expires = create_refresh_token(user.id) session.add(RefreshToken(jti=jti, user_id=user.id, expires_at=refresh_expires)) await session.flush() return TokenPair( access_token=access_token, access_expires_at=access_expires, refresh_token=refresh_token, refresh_expires_at=refresh_expires, ) async def rotate_tokens(session: AsyncSession, refresh_token: str) -> tuple[AppUser, TokenPair]: """Prüft ein Refresh-Token, sperrt es und stellt ein neues Paar aus.""" payload = decode_token(refresh_token, "refresh") jti = payload["jti"] stored = ( await session.execute(select(RefreshToken).where(RefreshToken.jti == jti)) ).scalar_one_or_none() if stored is None: raise AuthError("Die Sitzung ist nicht mehr gültig.", code="invalid_token") if stored.revoked_at is not None: # Ein bereits verwendetes Token deutet auf Diebstahl hin – alles sperren. await revoke_all_refresh_tokens(session, stored.user_id) raise AuthError("Die Sitzung wurde beendet.", code="token_reused") if stored.expires_at <= datetime.now(UTC): raise AuthError("Die Sitzung ist abgelaufen.", code="token_expired") user = await get_user(session, stored.user_id) if user is None: raise AuthError("Der Benutzer existiert nicht mehr.", code="invalid_token") stored.revoked_at = datetime.now(UTC) await session.flush() return user, await issue_tokens(session, user) async def revoke_refresh_token(session: AsyncSession, refresh_token: str | None) -> None: """Sperrt genau ein Refresh-Token. Ungültige Token werden still ignoriert.""" if not refresh_token: return try: payload = decode_token(refresh_token, "refresh") except AuthError: return stored = ( await session.execute(select(RefreshToken).where(RefreshToken.jti == payload["jti"])) ).scalar_one_or_none() if stored is not None and stored.revoked_at is None: stored.revoked_at = datetime.now(UTC) await session.flush() async def revoke_all_refresh_tokens(session: AsyncSession, user_id: int) -> None: """Sperrt alle offenen Sitzungen eines Benutzers.""" stmt = select(RefreshToken).where( RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None) ) for token in (await session.execute(stmt)).scalars(): token.revoked_at = datetime.now(UTC) await session.flush() async def purge_expired_refresh_tokens(session: AsyncSession) -> int: """Räumt abgelaufene Token auf. Wird beim Start und vom Scheduler aufgerufen.""" stmt = select(RefreshToken).where(RefreshToken.expires_at <= datetime.now(UTC)) tokens = list((await session.execute(stmt)).scalars()) for token in tokens: await session.delete(token) await session.flush() return len(tokens)