654258f13e
M-2: Refresh-Token als HttpOnly SameSite=Strict Cookie - auth.py: _set_refresh_cookie/_delete_refresh_cookie Helpers - Alle Auth-Endpoints (login, totp/login, refresh, logout) nutzen Cookie - schemas/auth.py: refresh_token in Request/Response optional - AuthContext.tsx: kein refresh_token in localStorage - api/client.ts: credentials:include, kein Token-Body beim Refresh M-4: TrustedHostMiddleware Warning in Production - main.py: Startup-Warning wenn is_production + kein ALLOWED_HOSTS M-5: TOTP-Fehlversuche Redis-Lockout - auth.py: _check/_record/_clear_totp_lockout; 5 Versuche → 15 min Sperre M-7: Zentraler get_client_ip()-Helper - core/dependencies.py: get_client_ip() mit X-Real-IP → X-Forwarded-For → client.host - hours_payouts.py, absences.py, busylight.py: request.client.host ersetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
349 lines
13 KiB
Python
349 lines
13 KiB
Python
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)):
|
||
# Cookie bevorzugen, Body als Fallback (Rückwärtskompatibilität für API-Clients)
|
||
token = request.cookies.get(_COOKIE_NAME)
|
||
if not token and data:
|
||
token = data.refresh_token
|
||
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)):
|
||
# Cookie bevorzugen, Body als Fallback
|
||
token = request.cookies.get(_COOKIE_NAME)
|
||
if not token and data:
|
||
token = data.refresh_token
|
||
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
|