feat(api): Core-API mit Authentifizierung, CRUD und Monatsreport
- Anmeldung über Argon2id und JWT in httpOnly-Cookies, Refresh mit echter Rotation über die neue Tabelle refresh_token - AuthProvider-Protokoll als Vorbereitung für OIDC, Administrator-Anlage beim Erststart mit erzwungenem Passwortwechsel - CRUD für Konten, Kategorien (zweistufiger Baum), Firmen, Recurrences, Preisversionen, Buchungen, Budgets, Vorlagen und Sparziele - Fälligkeiten mit Overlay-Logik: abrufen, bestätigen, auslassen, zurücksetzen - Kontosalden zum Stichtag, Monatsübersicht mit Plan-Ist-Vergleich - SECRET_KEY jetzt mindestens 32 Zeichen; Platzhalter in Produktion abgelehnt - 61 neue Integrationstests, insgesamt 148 grün Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user