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,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