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