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
+95
View File
@@ -0,0 +1,95 @@
"""Passwort-Hashing und JWT-Ausstellung."""
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any, Literal
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from app.core.config import settings
from app.core.errors import AuthError
ALGORITHM = "HS256"
TokenType = Literal["access", "refresh"]
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
"""Erzeugt einen Argon2id-Hash."""
return _hasher.hash(password)
def verify_password(password: str, password_hash: str | None) -> bool:
"""Prüft ein Passwort gegen den Hash. Fehlender Hash gilt immer als falsch."""
if not password_hash:
return False
try:
return _hasher.verify(password_hash, password)
except (VerifyMismatchError, InvalidHashError, ValueError):
return False
def needs_rehash(password_hash: str) -> bool:
"""True, wenn der Hash mit veralteten Parametern erzeugt wurde."""
try:
return _hasher.check_needs_rehash(password_hash)
except (InvalidHashError, ValueError):
return True
def create_token(
subject: int,
token_type: TokenType,
*,
expires_in: timedelta,
jti: str | None = None,
) -> tuple[str, str, datetime]:
"""Erzeugt ein signiertes JWT und liefert (Token, jti, Ablaufzeitpunkt)."""
now = datetime.now(UTC)
expires_at = now + expires_in
token_id = jti or uuid.uuid4().hex
payload: dict[str, Any] = {
"sub": str(subject),
"typ": token_type,
"jti": token_id,
"iat": int(now.timestamp()),
"exp": int(expires_at.timestamp()),
"iss": settings.app_name,
}
token = jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
return token, token_id, expires_at
def create_access_token(subject: int) -> tuple[str, str, datetime]:
return create_token(
subject, "access", expires_in=timedelta(minutes=settings.access_token_ttl_minutes)
)
def create_refresh_token(subject: int) -> tuple[str, str, datetime]:
return create_token(
subject, "refresh", expires_in=timedelta(days=settings.refresh_token_ttl_days)
)
def decode_token(token: str, expected_type: TokenType) -> dict[str, Any]:
"""Prüft Signatur, Ablauf und Tokenart. Wirft `AuthError` bei jedem Problem."""
try:
payload = jwt.decode(
token,
settings.secret_key,
algorithms=[ALGORITHM],
issuer=settings.app_name,
options={"require": ["exp", "sub", "jti"]},
)
except jwt.ExpiredSignatureError as exc:
raise AuthError("Die Sitzung ist abgelaufen.", code="token_expired") from exc
except jwt.PyJWTError as exc:
raise AuthError("Ungültiges Token.", code="invalid_token") from exc
if payload.get("typ") != expected_type:
raise AuthError("Ungültige Tokenart.", code="invalid_token")
return payload