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:
moneyfy
2026-09-09 13:40:37 +02:00
co-authored by Claude Opus 5
parent 70d73cf8d3
commit b586d27b77
46 changed files with 4866 additions and 21 deletions
+243
View File
@@ -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)