SPB Git

spb/uqo-chat

Public
6.3 KB · 164 lines python
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
Files · afb5ca448565f356efc1532400a8ac8b8eba5d6a
Menu

Theme