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
+46
View File
@@ -0,0 +1,46 @@
"""Zeitfunktionen. Fachlich gilt durchgängig `Europe/Berlin`."""
from datetime import UTC, date, datetime, timedelta
from zoneinfo import ZoneInfo
from app.core.config import settings
def tz() -> ZoneInfo:
return ZoneInfo(settings.timezone)
def now() -> datetime:
"""Aktueller Zeitpunkt in der fachlichen Zeitzone."""
return datetime.now(tz())
def utcnow() -> datetime:
"""Aktueller Zeitpunkt in UTC für Zeitstempel in der Datenbank."""
return datetime.now(UTC)
def today() -> date:
"""Heutiges Datum in der fachlichen Zeitzone."""
return now().date()
def month_start(day: date) -> date:
"""Erster Tag des Monats, in dem `day` liegt."""
return day.replace(day=1)
def month_end(day: date) -> date:
"""Letzter Tag des Monats, in dem `day` liegt."""
if day.month == 12:
return day.replace(day=31)
return day.replace(month=day.month + 1, day=1) - timedelta(days=1)
def add_months(day: date, months: int) -> date:
"""Verschiebt ein Datum um ganze Monate; der Monatserste bleibt Monatserster."""
total = day.month - 1 + months
year = day.year + total // 12
month = total % 12 + 1
last_day = month_end(date(year, month, 1)).day
return date(year, month, min(day.day, last_day))
+15 -2
View File
@@ -4,9 +4,12 @@ from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, PostgresDsn, field_validator
from pydantic import Field, PostgresDsn, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Erkennbarer Platzhalter: erlaubt lokale Starts, ist in Produktion aber verboten.
PLACEHOLDER_SECRET = "bitte-aendern-" + "0" * 32
class Settings(BaseSettings):
"""Alle Einstellungen stammen aus der Umgebung bzw. einer .env-Datei."""
@@ -34,7 +37,8 @@ class Settings(BaseSettings):
)
# --- Sicherheit ----------------------------------------------------------
secret_key: str = Field(default="change-me-in-production", min_length=8)
# HS256 verlangt mindestens 32 Byte Schlüsselmaterial (RFC 7518, Abschnitt 3.2).
secret_key: str = Field(default=PLACEHOLDER_SECRET, min_length=32)
access_token_ttl_minutes: int = 30
refresh_token_ttl_days: int = 14
cookie_secure: bool = True
@@ -88,6 +92,15 @@ class Settings(BaseSettings):
return value.replace("postgresql://", "postgresql+asyncpg://", 1)
return value
@model_validator(mode="after")
def _reject_placeholder_secret(self) -> "Settings":
"""In Produktion muss ein eigener Signaturschlüssel gesetzt sein."""
if self.environment == "production" and self.secret_key == PLACEHOLDER_SECRET:
raise ValueError(
"SECRET_KEY ist nicht gesetzt. Einen Schlüssel erzeugen mit: openssl rand -hex 32"
)
return self
@property
def sync_database_url(self) -> str:
"""Synchrone Variante der DSN wird von Alembic benötigt."""
+64
View File
@@ -0,0 +1,64 @@
"""Setzen und Löschen der Authentifizierungs-Cookies."""
from datetime import UTC, datetime
from fastapi import Response
from app.core.config import settings
ACCESS_COOKIE = "moneyfy_access"
REFRESH_COOKIE = "moneyfy_refresh"
# Der Refresh-Cookie wird nur an die Endpunkte geschickt, die ihn wirklich brauchen.
REFRESH_COOKIE_PATH = "/api/auth"
def set_auth_cookies(
response: Response,
access_token: str,
access_expires_at: datetime,
refresh_token: str,
refresh_expires_at: datetime,
) -> None:
"""Legt beide Cookies als httpOnly/SameSite=Lax ab."""
now = datetime.now(UTC)
response.set_cookie(
ACCESS_COOKIE,
access_token,
max_age=max(int((access_expires_at - now).total_seconds()), 0),
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
path="/",
domain=settings.cookie_domain,
)
response.set_cookie(
REFRESH_COOKIE,
refresh_token,
max_age=max(int((refresh_expires_at - now).total_seconds()), 0),
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
path=REFRESH_COOKIE_PATH,
domain=settings.cookie_domain,
)
def clear_auth_cookies(response: Response) -> None:
"""Entfernt beide Cookies muss dieselben Attribute wie beim Setzen verwenden."""
response.delete_cookie(
ACCESS_COOKIE,
path="/",
domain=settings.cookie_domain,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
)
response.delete_cookie(
REFRESH_COOKIE,
path=REFRESH_COOKIE_PATH,
domain=settings.cookie_domain,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
)
+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