""" Authentication and Authorization for Mitai Jinkendo Provides password hashing, session management, and auth dependencies for FastAPI endpoints. """ import hashlib import secrets from typing import Optional from datetime import datetime, timedelta from fastapi import Header, Query, HTTPException import bcrypt from db import get_db, get_cursor from media_grant import profile_id_from_media_grant # Fester bcrypt-Hash, damit ein unbekannter Login dieselbe Rechenzeit hat wie ein Treffer. _LOGIN_TIMING_HASH = "$2b$12$GYdnAmeiqaZg/.5J9E4f4.xhz70piViqXIpqKCY/FY609qj65sc9i" MAX_SESSION_DAYS = 30 def hash_pin(pin: str) -> str: """Hash password with bcrypt. Falls back gracefully from legacy SHA256.""" return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() def verify_pin(pin: str, stored_hash: str) -> bool: """Verify password - supports both bcrypt and legacy SHA256.""" if not stored_hash: return False # Detect bcrypt hash (starts with $2b$ or $2a$) if stored_hash.startswith('$2'): try: return bcrypt.checkpw(pin.encode(), stored_hash.encode()) except Exception: return False # Legacy SHA256 support (auto-upgrade to bcrypt on next login) return stored_hash == hashlib.sha256(pin.encode()).hexdigest() def verify_pin_for_login(pin: str, stored_hash: Optional[str]) -> bool: """Immer ein bcrypt-Vergleich, auch wenn das Konto fehlt (kein Zeit-Orakel).""" if stored_hash and str(stored_hash).startswith("$2"): return verify_pin(pin, stored_hash) verify_pin(pin or "x", _LOGIN_TIMING_HASH) if not stored_hash: return False return verify_pin(pin, stored_hash) def session_lifetime_days(profile: Optional[dict]) -> int: raw = profile.get("session_days") if profile else None try: days = int(raw) if raw is not None else MAX_SESSION_DAYS except (TypeError, ValueError): days = MAX_SESSION_DAYS if days < 1: days = 1 if days > MAX_SESSION_DAYS: days = MAX_SESSION_DAYS return days def revoke_profile_sessions(cur, profile_id: int) -> None: """Alle Sitzungen eines Profils entfernen, inklusive Reset- und 2FA-Zwischentokens.""" cur.execute("DELETE FROM sessions WHERE profile_id = %s", (profile_id,)) def make_token() -> str: """Generate a secure random token for sessions.""" return secrets.token_urlsafe(32) def get_session(token: str): """ Get session data for a given token. Returns session dict with profile info, or None if invalid/expired. """ if not token: return None with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT s.*, p.role, p.name " "FROM sessions s JOIN profiles p ON s.profile_id=p.id " "WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP " "AND COALESCE(s.purpose, 'full') = 'full'", (token,) ) return cur.fetchone() def get_limited_session(token: str, purpose: str): """Sitzung mit festem Zweck (totp_enroll, totp_challenge). Kein Fach-API-Zugriff.""" if not token or purpose not in ("totp_enroll", "totp_challenge"): return None with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT s.*, p.role, p.name, p.email " "FROM sessions s JOIN profiles p ON s.profile_id=p.id " "WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP " "AND s.purpose = %s", (token, purpose), ) return cur.fetchone() def _profile_session_from_id(profile_id: int) -> Optional[dict]: with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT id AS profile_id, role, name FROM profiles WHERE id = %s", (profile_id,), ) return cur.fetchone() def require_auth(x_auth_token: Optional[str] = Header(default=None)): """ FastAPI dependency - requires valid authentication. Usage: @app.get("/api/endpoint") def endpoint(session: dict = Depends(require_auth)): profile_id = session['profile_id'] ... Raises: HTTPException 401 if not authenticated """ session = get_session(x_auth_token) if not session: raise HTTPException(401, "Nicht eingeloggt") return session def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ssetoken: Optional[str] = Query(default=None)): """ FastAPI dependency - auth via header OR query parameter. Dateiabruf für /