Files
timemaster/backend/app/routers/auth.py
T
patrick a870ac64a5
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled
fix: Refresh-Endpoint bevorzugt Body-Token über Cookie (Token-Rotation Test)
Body-Token hat Vorrang wenn explizit angegeben — verhindert dass
httpx-Cookie-Jar im Test den alten Token mit dem neuen Cookie überschreibt.
Browser-Clients senden keinen Body, nutzen weiterhin Cookie.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:14:44 +02:00

346 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.crypto import decrypt_value, encrypt_value
from app.core.database import get_db
from app.core.dependencies import CurrentUser
from app.core.limiter import limiter
from app.core.security import hash_password, verify_password
from app.models.audit_log import AuditLog
from app.schemas.auth import (
LoginRequest,
MessageResponse,
PasswordResetConfirm,
PasswordResetRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
TotpConfirmRequest,
TotpDisableRequest,
TotpLoginRequest,
TotpSetupResponse,
)
from app.schemas.user import InviteAccept, UserOut
from app.services.auth_service import auth_service
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
router = APIRouter(prefix="/auth", tags=["Auth"])
_COOKIE_NAME = "refresh_token"
_COOKIE_PATH = "/"
_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 Tage
def _set_refresh_cookie(response: Response, token: str) -> None:
"""Setzt den Refresh-Token als HttpOnly+SameSite=Strict Cookie."""
from app.core.config import settings
response.set_cookie(
key=_COOKIE_NAME,
value=token,
httponly=True,
secure=settings.is_production,
samesite="strict",
max_age=_COOKIE_MAX_AGE,
path=_COOKIE_PATH,
)
def _delete_refresh_cookie(response: Response) -> None:
"""Löscht den Refresh-Token-Cookie."""
response.delete_cookie(key=_COOKIE_NAME, path=_COOKIE_PATH)
@router.post("/register", response_model=TokenResponse, status_code=201)
@limiter.limit("3/hour")
async def register(request: Request, data: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Create a new company + admin account."""
return await auth_service.register(data, db)
@router.post("/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def login(request: Request, response: Response, data: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await auth_service.login(data, db, request)
if result.refresh_token:
_set_refresh_cookie(response, result.refresh_token)
result.refresh_token = None # Nicht im JSON-Body zurückgeben
return result
@router.post("/refresh", response_model=TokenResponse)
@limiter.limit("30/minute")
async def refresh(request: Request, response: Response, data: RefreshRequest | None = None, db: AsyncSession = Depends(get_db)):
# Body-Token hat Vorrang wenn explizit angegeben (API-Clients, Tests, Replay-Detection)
# Cookie als Fallback für Browser-Clients
token = (data.refresh_token if data and data.refresh_token else None) or request.cookies.get(_COOKIE_NAME)
if not token:
raise HTTPException(status_code=401, detail="Kein Refresh-Token")
result = await auth_service.refresh(token, db)
if result.refresh_token:
_set_refresh_cookie(response, result.refresh_token)
result.refresh_token = None # Nicht im JSON-Body zurückgeben
return result
@router.post("/logout", response_model=MessageResponse)
@limiter.limit("60/minute")
async def logout(request: Request, response: Response, data: RefreshRequest | None = None, db: AsyncSession = Depends(get_db)):
# Body-Token hat Vorrang wenn explizit angegeben, Cookie als Fallback
token = (data.refresh_token if data and data.refresh_token else None) or request.cookies.get(_COOKIE_NAME)
if token:
await auth_service.logout(token, db)
_delete_refresh_cookie(response)
return MessageResponse(message="Logged out successfully")
@router.post("/password-reset", response_model=MessageResponse)
@limiter.limit("3/hour")
async def request_password_reset(request: Request, data: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
result = await auth_service.request_password_reset(data.email, db)
if result == "ldap":
from fastapi import HTTPException
raise HTTPException(
status_code=400,
detail="Dein Konto wird über LDAP verwaltet. Bitte setze dein Passwort direkt beim LDAP-Administrator zurück.",
)
return MessageResponse(message="Falls diese E-Mail-Adresse registriert ist, wurde ein Reset-Link verschickt.")
@router.post("/password-reset/confirm", response_model=MessageResponse)
@limiter.limit("5/hour")
async def confirm_password_reset(request: Request, data: PasswordResetConfirm, db: AsyncSession = Depends(get_db)):
await auth_service.confirm_password_reset(data.token, data.new_password, db)
return MessageResponse(message="Password updated successfully")
@router.post("/invite/accept", response_model=UserOut)
@limiter.limit("10/hour")
async def accept_invite(request: Request, data: InviteAccept, db: AsyncSession = Depends(get_db)):
from app.services.user_service import user_service
user = await user_service.accept_invite(data, db)
return UserOut.model_validate(user)
@router.get("/me", response_model=UserOut)
async def me(current_user: CurrentUser):
return UserOut.model_validate(current_user)
@router.post("/change-password", response_model=MessageResponse)
async def change_password(
data: ChangePasswordRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Passwort ändern (eingeloggter User, benötigt aktuelles Passwort)."""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Aktuelles Passwort ist falsch")
import re
if not re.search(r'[A-Z]', data.new_password) or not re.search(r'[0-9]', data.new_password):
raise HTTPException(
status_code=400,
detail="Neues Passwort muss mindestens 1 Großbuchstaben und 1 Zahl enthalten"
)
current_user.password_hash = hash_password(data.new_password)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="password_changed",
entity_type="user",
entity_id=current_user.id,
))
await db.commit()
return MessageResponse(message="Passwort erfolgreich geändert")
# ── TOTP / 2FA ────────────────────────────────────────────────────────────────
def _totp_plain(user) -> str | None:
"""Gibt das entschlüsselte TOTP-Secret zurück, oder None."""
if not user.totp_secret:
return None
try:
return decrypt_value(user.totp_secret)
except ValueError:
# Fallback: Secret war noch im Klartext (Legacy-Daten vor 0026-Migration)
return user.totp_secret
@router.post("/totp/setup", response_model=TotpSetupResponse)
async def totp_setup(current_user: CurrentUser):
"""Generiert ein neues TOTP-Secret und gibt die otpauth-URI zurück. Noch nicht aktiviert."""
# Fix K-4: Verhindere Überschreiben eines aktiven TOTP-Secrets mit gestohlenem Access-Token
if current_user.totp_enabled:
raise HTTPException(
status_code=400,
detail="TOTP ist bereits aktiv. Bitte zuerst deaktivieren (POST /auth/totp/disable).",
)
import pyotp
secret = pyotp.random_base32()
issuer = "TimeMaster"
label = current_user.email
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=issuer)
# Secret Fernet-verschlüsselt speichern (noch nicht totp_enabled)
current_user.totp_secret = encrypt_value(secret)
# Hinweis: DB-Commit passiert NICHT hier erst nach verify in /totp/confirm
# Damit das Secret nicht verloren geht, sofort speichern
return TotpSetupResponse(secret=secret, otpauth_uri=uri)
@router.post("/totp/setup/save", response_model=MessageResponse)
async def totp_setup_save(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Speichert das generierte Secret temporär (ohne Aktivierung)."""
import pyotp
if not current_user.totp_secret:
secret = pyotp.random_base32()
current_user.totp_secret = encrypt_value(secret)
await db.commit()
return MessageResponse(message="Secret gespeichert")
@router.post("/totp/confirm", response_model=MessageResponse)
async def totp_confirm(
data: TotpConfirmRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Bestätigt den ersten TOTP-Code und aktiviert 2FA."""
import pyotp
plain_secret = _totp_plain(current_user)
if not plain_secret:
raise HTTPException(400, "Kein TOTP-Secret vorhanden. Zuerst /totp/setup aufrufen.")
totp = pyotp.TOTP(plain_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
current_user.totp_enabled = True
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="totp_enabled",
entity_type="user",
entity_id=current_user.id,
))
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung aktiviert")
@router.post("/totp/disable", response_model=MessageResponse)
async def totp_disable(
data: TotpDisableRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Deaktiviert TOTP. Benötigt aktuelles Passwort + gültigen TOTP-Code."""
import pyotp
if not verify_password(data.password, current_user.password_hash or ""):
raise HTTPException(400, "Passwort falsch")
if not current_user.totp_enabled or not current_user.totp_secret:
raise HTTPException(400, "2FA ist nicht aktiv")
plain_secret = _totp_plain(current_user)
totp = pyotp.TOTP(plain_secret or "")
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger TOTP-Code")
current_user.totp_enabled = False
current_user.totp_secret = None
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="totp_disabled",
entity_type="user",
entity_id=current_user.id,
))
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung deaktiviert")
TOTP_MAX_ATTEMPTS = 5
TOTP_LOCKOUT_SECONDS = 900 # 15 Minuten
async def _check_totp_lockout(user_id: str, redis) -> None:
"""Wirft HTTP 429 wenn TOTP-Login wegen zu vieler Fehlversuche gesperrt ist."""
key = f"totp_lockout:{user_id}"
if await redis.exists(key):
ttl = await redis.ttl(key)
wait_min = ttl // 60 + 1
raise HTTPException(429, detail=f"TOTP gesperrt. Bitte {wait_min} Minute(n) warten.")
async def _record_totp_failure(user_id: str, redis) -> None:
"""Zählt TOTP-Fehlversuch und setzt Lockout nach TOTP_MAX_ATTEMPTS Fehlversuchen."""
fail_key = f"totp_fails:{user_id}"
lock_key = f"totp_lockout:{user_id}"
fails = await redis.incr(fail_key)
await redis.expire(fail_key, TOTP_LOCKOUT_SECONDS)
if fails >= TOTP_MAX_ATTEMPTS:
await redis.set(lock_key, "1", ex=TOTP_LOCKOUT_SECONDS)
await redis.delete(fail_key)
async def _clear_totp_failures(user_id: str, redis) -> None:
"""Löscht TOTP-Fehlversuche nach erfolgreichem Login."""
await redis.delete(f"totp_fails:{user_id}")
await redis.delete(f"totp_lockout:{user_id}")
@router.post("/totp/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def totp_login(
request: Request,
response: Response,
data: TotpLoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens."""
import pyotp
import redis.asyncio as aioredis
from uuid import UUID
from app.core.config import settings
from app.core.security import decode_partial_token
from app.models.user import User
from jose import JWTError
try:
user_id = decode_partial_token(data.partial_token)
except JWTError:
raise HTTPException(401, "Ungültiger oder abgelaufener Token")
user = await db.get(User, UUID(user_id))
if not user or not user.is_active:
raise HTTPException(401, "Benutzer nicht gefunden")
if not user.totp_enabled or not user.totp_secret:
raise HTTPException(400, "2FA nicht aktiv")
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
try:
# M-5: Lockout-Check vor TOTP-Verifikation
await _check_totp_lockout(user_id, redis_client)
plain_secret = _totp_plain(user)
totp = pyotp.TOTP(plain_secret or "")
if not totp.verify(data.code, valid_window=1):
# M-5: Fehlversuch zählen
await _record_totp_failure(user_id, redis_client)
raise HTTPException(400, "Ungültiger Code")
# M-5: Erfolg → Fehlversuche zurücksetzen
await _clear_totp_failures(user_id, redis_client)
finally:
await redis_client.aclose()
from datetime import datetime, timezone
user.last_login = datetime.now(timezone.utc)
result = await auth_service._create_session(user, db, request=request)
if result.refresh_token:
_set_refresh_cookie(response, result.refresh_token)
result.refresh_token = None # Nicht im JSON-Body zurückgeben
return result