1"""JWT helpers, password-less auth utilities and FastAPI dependencies."""23from __future__ import annotations45import hashlib6import secrets7from datetime import UTC, datetime, timedelta8from typing import Any910from fastapi import Depends, HTTPException, Request, status11from jose import JWTError, jwt1213from app.core.config import Settings, get_settings1415ALGO = "HS256"16COOKIE_NAME = "uqo_session"171819def hash_user_id(user_id: str) -> str:20 return hashlib.sha256(user_id.encode()).hexdigest()212223def create_token(sub: str, role: str, settings: Settings, ttl: timedelta | None = None,24 kind: str = "access") -> str:25 now = datetime.now(UTC)26 exp = now + (ttl or timedelta(hours=settings.JWT_TTL_HOURS))27 payload: dict[str, Any] = {"sub": sub, "role": role, "kind": kind, "iat": now, "exp": exp}28 return jwt.encode(payload, settings.JWT_SECRET.get_secret_value(), algorithm=ALGO)293031def decode_token(token: str, settings: Settings) -> dict[str, Any] | None:32 try:33 return jwt.decode(token, settings.JWT_SECRET.get_secret_value(), algorithms=[ALGO])34 except JWTError:35 return None363738def new_magic_token() -> str:39 return secrets.token_urlsafe(32)404142def role_for_email(email: str, settings: Settings) -> str:43 e = email.lower()44 if e in settings.admin_emails:45 return "admin"46 if e in settings.professor_emails:47 return "professor"48 return "student"495051def email_allowed(email: str, settings: Settings) -> bool:52 e = email.lower().strip()53 if "@" not in e:54 return False55 if e in settings.invited_emails or e in settings.professor_emails or e in settings.admin_emails:56 return True57 domain = e.split("@", 1)[1]58 return domain in settings.allowed_domains596061class AuthUser:62 def __init__(self, user_id: str, role: str) -> None:63 self.id = user_id64 self.role = role6566 @property67 def is_professor(self) -> bool:68 return self.role in {"professor", "admin"}6970 @property71 def is_admin(self) -> bool:72 return self.role == "admin"737475def _extract_token(request: Request) -> str | None:76 auth = request.headers.get("authorization", "")77 if auth.lower().startswith("bearer "):78 return auth[7:].strip()79 return request.cookies.get(COOKIE_NAME)808182async def get_current_user(request: Request, settings: Settings = Depends(get_settings)) -> AuthUser:83 token = _extract_token(request)84 if not token:85 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Connexion requise.")86 payload = decode_token(token, settings)87 if not payload or payload.get("kind") != "access":88 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Session expirée. Reconnecte-toi.")89 return AuthUser(str(payload["sub"]), str(payload.get("role", "student")))909192async def get_optional_user(request: Request,93 settings: Settings = Depends(get_settings)) -> AuthUser | None:94 try:95 return await get_current_user(request, settings)96 except HTTPException:97 return None9899100async def require_professor(user: AuthUser = Depends(get_current_user)) -> AuthUser:101 if not user.is_professor:102 raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Réservé au professeur.")103 return user104105106async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser:107 if not user.is_admin:108 raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Réservé à l'administrateur.")109 return user110