from fastapi import APIRouter, Depends, HTTPException, Response, Body, Request, status from sqlalchemy.orm import Session as DBSession from sqlalchemy import func from passlib.context import CryptContext from datetime import datetime, timedelta from collections import defaultdict import os, hmac, hashlib from app.core.database import get_db from app.core.auth import ( set_session_cookie, issue_csrf_cookie, clear_session_cookie, clear_csrf_cookie, get_current_user, SESSION_COOKIE_NAME, ) from app.services.sessions import create_session, delete_session from app.models.user import User router = APIRouter(prefix="/auth", tags=["auth"]) pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def verify_pin(plain_pin: str, hashed_pin: str) -> bool: if not hashed_pin: return False return pwd_context.verify(plain_pin, hashed_pin) def verify_password(plain_password: str, hashed_password: str) -> bool: if not hashed_password: return False return pwd_context.verify(plain_password, hashed_password) # -------- Rolle robust normalisieren -------- def _normalize_role(role_raw) -> str: # Enum? -> value if hasattr(role_raw, "value"): role_raw = role_raw.value # Stringifizieren role = str(role_raw or "user").strip() # ggf. Präfixe/Namespaces entfernen (z. B. "userrole.admin") if "." in role: role = role.split(".")[-1] return role.lower() # ----------------------------- # Throttling für Management-Login # ----------------------------- _FAILED_LIMIT = 5 _LOCK_MINUTES = 2 _login_state = defaultdict(lambda: {"fails": 0, "lock_until": None}) def _client_ip(request: Request) -> str: xff = request.headers.get("X-Forwarded-For") if xff: first = xff.split(",")[0].strip() if first: return first return request.client.host if request.client else "0.0.0.0" def _throttle_check(email_lower: str, ip: str): st = _login_state[(email_lower, ip)] now = datetime.utcnow() if st["lock_until"] and now < st["lock_until"]: seconds = int((st["lock_until"] - now).total_seconds()) raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=f"Login gesperrt. Bitte {seconds} Sekunden warten.", ) def _throttle_fail(email_lower: str, ip: str): st = _login_state[(email_lower, ip)] st["fails"] += 1 if st["fails"] >= _FAILED_LIMIT: st["lock_until"] = datetime.utcnow() + timedelta(minutes=_LOCK_MINUTES) def _throttle_reset(email_lower: str, ip: str): _login_state[(email_lower, ip)] = {"fails": 0, "lock_until": None} # ----------------------------- # PIN-Login: O(1) via HMAC-Lookup + IP-Rate-Limit # ----------------------------- # Servergeheimer Pepper für HMAC über die **volle** PIN PIN_PEPPER = os.getenv("PIN_PEPPER") if not PIN_PEPPER or len(PIN_PEPPER) < 32: where = os.environ.get("DOTENV_PATH") or ".env (nicht gefunden)" raise RuntimeError(f"PIN_PEPPER env var fehlt/zu kurz – .env: {where}") def _pin_hmac(pin: str) -> str: """hex(HMAC_SHA256(PEPPER, pin))""" return hmac.new(PIN_PEPPER.encode("utf-8"), pin.encode("utf-8"), hashlib.sha256).hexdigest() # sehr einfaches IP-Rate-Limit (pro Prozess) _pin_attempts = defaultdict(list) # ip -> [timestamps] def _pin_rate_limit_ok(ip: str, limit: int = 10, window_sec: int = 60) -> bool: now = datetime.utcnow().timestamp() arr = _pin_attempts[ip] # altes Fenster abschneiden arr[:] = [t for t in arr if now - t < window_sec] if len(arr) >= limit: return False arr.append(now) return True # ----------------------------- # Management-Login (E-Mail/Passwort) # ----------------------------- @router.post("/management-login") def management_login( request: Request, response: Response, email: str = Body(..., embed=True), password: str = Body(..., embed=True), db: DBSession = Depends(get_db), ): email_lower = (email or "").strip().lower() ip = _client_ip(request) _throttle_check(email_lower, ip) user = db.query(User).filter(func.lower(User.email) == email_lower).one_or_none() if not user or not getattr(user, "is_active", True): _throttle_fail(email_lower, ip) raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten") hashed_pw = getattr(user, "hashed_password", None) or getattr(user, "password_hash", None) if not hashed_pw: _throttle_fail(email_lower, ip) raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten") if not verify_password(password, hashed_pw): _throttle_fail(email_lower, ip) raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten") role = _normalize_role(getattr(user, "role", "user")) _throttle_reset(email_lower, ip) token = create_session(db, user.id) set_session_cookie(response, token) issue_csrf_cookie(response) return { "message": "Login erfolgreich", "user": {"id": user.id, "name": user.name, "role": role}, } # ----------------------------- # PIN-Login (O(1) Kandidaten, dann 1× Hash-Verify) # ----------------------------- @router.post("/pin-login") def pin_login( request: Request, response: Response, pin: str = Body(..., embed=True), db: DBSession = Depends(get_db), ): # IP-Rate-Limit (z. B. 10 Versuche/60s) ip = _client_ip(request) if not _pin_rate_limit_ok(ip): raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Zu viele Versuche. Bitte kurz warten.") if not pin or not pin.isdigit() or len(pin) != 6: # gleiche Antwortzeit für Fehler raise HTTPException(status_code=401, detail="Ungültiger PIN") token = _pin_hmac(pin) # Schnellpfad: direkt per HMAC-Token Kandidaten suchen (O(1) via Index) candidates = db.query(User).filter( User.is_active == True, getattr(User, "pin_lookup") == token # erwartet Spalte users.pin_lookup (VARCHAR(64)) ).all() # Normalfall: genau 1 Kandidat; bei Kollisionen (gleiche PIN) sind es wenige matched = None for u in candidates: # sichere Verifikation mit dem langsamen Hash hashed_pin = getattr(u, "hashed_pin", None) or getattr(u, "pin_hash", None) if hashed_pin and verify_pin(pin, hashed_pin): matched = u break if not matched: # Altbestand (pin_lookup leer) – langsamer Fallback: aktive Nutzer iterieren # Hinweis: Bei vielen Nutzern besser deaktivieren und stattdessen Nutzer zur PIN-Neusetzung zwingen. slow = db.query(User).filter(User.is_active == True).with_entities(User.id, User.hashed_pin).all() for uid, h in slow: if h and verify_pin(pin, h): matched = db.query(User).get(uid) # type: ignore if matched: # Nachmigration: pin_lookup setzen, damit künftige Logins schnell sind setattr(matched, "pin_lookup", token) db.add(matched) db.commit() db.refresh(matched) break if not matched: raise HTTPException(status_code=401, detail="Ungültiger PIN") # Erfolg → Session-Cookie + CSRF token_val = create_session(db, matched.id) set_session_cookie(response, token_val) issue_csrf_cookie(response) return { "message": "Login erfolgreich", "user": { "id": matched.id, "name": matched.name, "role": _normalize_role(getattr(matched, "role", "user")), }, } # ----------------------------- # Logout # ----------------------------- @router.post("/logout") def logout( request: Request, response: Response, db: DBSession = Depends(get_db), current_user: User = Depends(get_current_user), ): token = request.cookies.get(SESSION_COOKIE_NAME) if token: delete_session(db, token) clear_session_cookie(response) clear_csrf_cookie(response) return Response(status_code=204) # ----------------------------- # CSRF-Token beziehen # ----------------------------- @router.get("/csrf") def get_csrf(response: Response): issue_csrf_cookie(response) return {"csrf": "ok"} # ----------------------------- # Aktueller Nutzer # ----------------------------- @router.get("/me") def me(current_user: User = Depends(get_current_user)): return current_user # ----------------------------- # Capabilities (UI-Gating) # ----------------------------- @router.get("/capabilities") def get_capabilities(current_user: User = Depends(get_current_user)): role = _normalize_role(getattr(current_user, "role", "user")) caps_by_role = { "user": [ "dashboard", "profile", "my-bookings", "my-transactions", "system-config", "stats-advanced", ], "manager": [ "dashboard", "profile", "my-bookings", "my-transactions", "products", "deliveries", "system-config", "stats-advanced", ], "admin": [ "dashboard", "profile", "my-bookings", "my-transactions", "users", "products", "deliveries", "stats-advanced", "audit-logs", "system-config", "transactions", ], } return {"role": role, "capabilities": caps_by_role.get(role, caps_by_role["user"])}