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
+106
View File
@@ -0,0 +1,106 @@
"""Anmeldung, Abmeldung, Token-Erneuerung und Passwortwechsel."""
from fastapi import APIRouter, Request, Response, status
from app.api.deps import CurrentUser, DbSession
from app.core.cookies import REFRESH_COOKIE, clear_auth_cookies, set_auth_cookies
from app.core.errors import AuthError
from app.schemas.auth import ChangePasswordRequest, LoginRequest, UserOut
from app.schemas.common import ErrorResponse, MessageResponse
from app.services.auth import (
Credentials,
change_password,
get_auth_provider,
issue_tokens,
revoke_refresh_token,
rotate_tokens,
)
router = APIRouter(prefix="/auth", tags=["auth"])
UNAUTHORIZED = {status.HTTP_401_UNAUTHORIZED: {"model": ErrorResponse}}
@router.post(
"/login",
response_model=UserOut,
responses=UNAUTHORIZED,
summary="Anmelden",
description="Prüft die Zugangsdaten und legt Access- und Refresh-Token als "
"httpOnly-Cookies ab.",
)
async def login(payload: LoginRequest, response: Response, session: DbSession) -> UserOut:
provider = get_auth_provider("local")
user = await provider.authenticate(
session, Credentials(username=payload.username, password=payload.password)
)
tokens = await issue_tokens(session, user)
await session.commit()
set_auth_cookies(
response,
tokens.access_token,
tokens.access_expires_at,
tokens.refresh_token,
tokens.refresh_expires_at,
)
return UserOut.model_validate(user)
@router.post(
"/refresh",
response_model=UserOut,
responses=UNAUTHORIZED,
summary="Sitzung erneuern",
description="Tauscht das Refresh-Token gegen ein neues Paar. Das alte Token "
"wird dabei gesperrt (Rotation).",
)
async def refresh(request: Request, response: Response, session: DbSession) -> UserOut:
token = request.cookies.get(REFRESH_COOKIE)
if not token:
clear_auth_cookies(response)
raise AuthError("Nicht angemeldet.", code="not_authenticated")
user, tokens = await rotate_tokens(session, token)
await session.commit()
set_auth_cookies(
response,
tokens.access_token,
tokens.access_expires_at,
tokens.refresh_token,
tokens.refresh_expires_at,
)
return UserOut.model_validate(user)
@router.post(
"/logout",
response_model=MessageResponse,
summary="Abmelden",
description="Sperrt das aktuelle Refresh-Token und löscht beide Cookies.",
)
async def logout(request: Request, response: Response, session: DbSession) -> MessageResponse:
await revoke_refresh_token(session, request.cookies.get(REFRESH_COOKIE))
await session.commit()
clear_auth_cookies(response)
return MessageResponse(detail="Abgemeldet.")
@router.post(
"/change-password",
response_model=MessageResponse,
responses=UNAUTHORIZED,
summary="Passwort ändern",
description="Ändert das Passwort und beendet dabei alle bestehenden Sitzungen.",
)
async def change_own_password(
payload: ChangePasswordRequest,
response: Response,
user: CurrentUser,
session: DbSession,
) -> MessageResponse:
await change_password(session, user, payload.current_password, payload.new_password)
await session.commit()
clear_auth_cookies(response)
return MessageResponse(detail="Passwort geändert. Bitte neu anmelden.")