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,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))
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user