1"""Auth: magic link (SMTP) or access code fallback; JWT cookie + bearer; /me."""23from __future__ import annotations45import hmac6from datetime import timedelta78from fastapi import APIRouter, Depends, HTTPException, Request, Response, status9from pydantic import BaseModel, EmailStr1011from app.core.config import Settings, get_settings12from app.core.ratelimit import limiter13from app.core.security import (14 COOKIE_NAME,15 AuthUser,16 create_token,17 decode_token,18 email_allowed,19 get_current_user,20)21from app.services import mail, users2223router = APIRouter(tags=["auth"])242526class MagicLinkReq(BaseModel):27 email: EmailStr28 access_code: str | None = None293031class VerifyReq(BaseModel):32 token: str333435class PrefsReq(BaseModel):36 tutoiement: bool | None = None37 course: str | None = None38 deep: bool | None = None39 locale: str | None = None40 font_scale: float | None = None41 display_name: str | None = None424344def _set_cookie(resp: Response, token: str, settings: Settings, refresh: str | None = None) -> None:45 secure = not settings.is_dev46 resp.set_cookie(COOKIE_NAME, token, httponly=True, secure=secure, samesite="lax",47 max_age=settings.JWT_TTL_HOURS * 3600, path="/")48 if refresh:49 resp.set_cookie(COOKIE_NAME + "_refresh", refresh, httponly=True, secure=secure,50 samesite="lax", max_age=settings.REFRESH_TTL_DAYS * 86400,51 path="/api/v1/auth")525354def _issue(user_id: str, role: str, settings: Settings, resp: Response) -> dict:55 token = create_token(user_id, role, settings)56 refresh = create_token(user_id, role, settings, timedelta(days=settings.REFRESH_TTL_DAYS),57 kind="refresh")58 _set_cookie(resp, token, settings, refresh)59 return {"token": token, "role": role}606162@router.post("/auth/magic-link")63async def magic_link(req: MagicLinkReq, request: Request, response: Response,64 settings: Settings = Depends(get_settings)) -> dict:65 ip = request.client.host if request.client else "?"66 limiter.check(f"login:{ip}", 20, 3600, "Trop de tentatives. Réessaie plus tard.")67 email = req.email.lower()68 if not email_allowed(email, settings):69 raise HTTPException(status.HTTP_403_FORBIDDEN,70 detail="Seules les adresses @uqo.ca (ou invitées) sont admises.")71 # Access-code path: no SMTP needed (course code handed out by the professor).72 if req.access_code is not None:73 if not settings.ACCESS_CODE or not hmac.compare_digest(req.access_code.strip(),74 settings.ACCESS_CODE):75 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Code d'accès invalide.")76 user = await users.get_or_create_user(email, settings)77 out = _issue(user.id, user.role, settings, response)78 return {"mode": "access_code", **out, "user": _user_dict(user)}79 token = await users.create_magic_link(email)80 link = f"{settings.APP_URL}/connexion?token={token}"81 sent = await mail.send_magic_link(settings, email, link)82 out: dict = {"mode": "magic_link", "sent": sent}83 if not sent:84 if settings.is_dev:85 out["dev_link"] = link # local development only86 else:87 out["hint"] = ("Envoi de courriel non configuré : utilise le code d'accès du cours."88 if settings.ACCESS_CODE else "Envoi de courriel non configuré.")89 return out909192@router.post("/auth/verify")93async def verify(req: VerifyReq, response: Response,94 settings: Settings = Depends(get_settings)) -> dict:95 email = await users.consume_magic_link(req.token)96 if not email:97 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Lien invalide ou expiré.")98 user = await users.get_or_create_user(email, settings)99 out = _issue(user.id, user.role, settings, response)100 return {**out, "user": _user_dict(user)}101102103@router.post("/auth/refresh")104async def refresh(request: Request, response: Response,105 settings: Settings = Depends(get_settings)) -> dict:106 raw = request.cookies.get(COOKIE_NAME + "_refresh")107 payload = decode_token(raw, settings) if raw else None108 if not payload or payload.get("kind") != "refresh":109 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Session expirée.")110 user = await users.get_user(str(payload["sub"]))111 if not user:112 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.")113 return _issue(user.id, user.role, settings, response)114115116@router.post("/auth/logout")117async def logout(response: Response) -> dict:118 response.delete_cookie(COOKIE_NAME, path="/")119 response.delete_cookie(COOKIE_NAME + "_refresh", path="/api/v1/auth")120 return {"ok": True}121122123@router.get("/auth/config")124async def auth_config(settings: Settings = Depends(get_settings)) -> dict:125 return {"smtp": settings.smtp_enabled, "access_code": bool(settings.ACCESS_CODE),126 "domains": sorted(settings.allowed_domains), "courses": settings.courses,127 "term": settings.TERM_LABEL}128129130def _user_dict(u) -> dict: # noqa: ANN001131 return {"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name,132 "preferences": u.preferences or {}, "consent_at": u.consent_at.isoformat()133 if u.consent_at else None}134135136@router.get("/me")137async def me(auth: AuthUser = Depends(get_current_user)) -> dict:138 user = await users.get_user(auth.id)139 if not user:140 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.")141 await users.touch(user.id)142 return _user_dict(user)143144145@router.patch("/me/preferences")146async def patch_prefs(req: PrefsReq, auth: AuthUser = Depends(get_current_user)) -> dict:147 data = req.model_dump(exclude_none=True)148 display_name = data.pop("display_name", None)149 user = await users.update_preferences(auth.id, data, display_name)150 return _user_dict(user)151152153@router.post("/me/consent")154async def consent(auth: AuthUser = Depends(get_current_user)) -> dict:155 await users.set_consent(auth.id)156 return {"ok": True}157158159@router.delete("/me")160async def delete_me(response: Response, auth: AuthUser = Depends(get_current_user)) -> dict:161 await users.delete_user_data(auth.id)162 response.delete_cookie(COOKIE_NAME, path="/")163 return {"ok": True, "message_fr": "Tes données ont été supprimées."}164