Files
timemaster/backend/app/routers/auth.py
T
patrick 06bb1c1664 feat: FZA Einzelstunden + Security-Fixes (K-1–K-5, H-2–H-4, M-1/M-3/M-6)
FZA Einzelstunden:
- Absence.fza_hours (Numeric 5,2) — FZA in Stunden statt Tagen
- Migration 0032: fza_hours Spalte in absences
- AbsenceCreate/AbsenceOut Schema um fza_hours erweitert
- absence_service: _deduct/_refund_overtime nutzt fza_hours direkt wenn gesetzt
- Frontend: Tage/Stunden-Toggle im FZA-Antrag-Modal

Security K-1: Privilege Escalation via PATCH /users/{id}.role
- user_service: Whitelist für Rollenänderungen, SUPER_ADMIN nur durch SUPER_ADMIN
- Letzter COMPANY_ADMIN gegen Selbst-Demotion gesichert

Security K-2: Kiosk-IP-Whitelist hinter nginx
- kiosk_security: _get_client_ip() liest X-Real-IP statt request.client.host

Security K-3: Kiosk-PIN Brute-Force-Schutz
- kiosk_auth_service: Redis-Lockout nach 5 Fehlversuchen (15 min)

Security K-4: TOTP-Setup-Hijacking
- auth router: /totp/setup abgelehnt wenn TOTP bereits aktiv

Security K-5: Separater Fernet-Key
- config: SECRET_KEY_DATA Feld (optional, Fallback auf SECRET_KEY)
- crypto: get_fernet_key() mit Warning bei fehlendem SECRET_KEY_DATA

Security H-2: Vacation Balance nur HR/Admin
- absences router: PATCH /balance nur noch HR/COMPANY_ADMIN/SUPER_ADMIN + AuditLog

Security H-3: Rate-Limits auf /auth/refresh + /auth/logout
- auth router: 30/min auf refresh, 60/min auf logout

Security H-4: Login-Failure-Logging + Lockout
- auth_service: Redis-Counter, Lockout nach 10 Versuchen (15 min)
- AuditLog für login_success und login_failed

Security M-1: Nginx Security-Header
- nginx.conf: X-Frame-Options, X-Content-Type-Options, CSP, Referrer-Policy, X-XSS-Protection, Permissions-Policy

Security M-3: AuditLog bei Rollenänderungen
- user_service: action=role_changed mit old/new role

Security M-6: create_all nur in Development
- main.py: Base.metadata.create_all nur wenn not settings.is_production

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

257 lines
9.7 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
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"])
@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, data: LoginRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.login(data, db, request)
@router.post("/refresh", response_model=TokenResponse)
@limiter.limit("30/minute")
async def refresh(request: Request, data: RefreshRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.refresh(data.refresh_token, db)
@router.post("/logout", response_model=MessageResponse)
@limiter.limit("60/minute")
async def logout(request: Request, data: RefreshRequest, db: AsyncSession = Depends(get_db)):
await auth_service.logout(data.refresh_token, db)
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")
@router.post("/totp/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def totp_login(
request: Request,
data: TotpLoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens."""
import pyotp
from uuid import UUID
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")
plain_secret = _totp_plain(user)
totp = pyotp.TOTP(plain_secret or "")
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
from datetime import datetime, timezone
user.last_login = datetime.now(timezone.utc)
return await auth_service._create_session(user, db, request=request)