"""Auth: e-mail + password, activation / reset links by e-mail (Resend); JWT cookie + bearer; /me. Flow: the professor registers the students' addresses → each receives a "choose your password" link → the password is then used on every sign-in. "Première connexion ou mot de passe oublié" re-sends such a link to any registered address. """ from __future__ import annotations from datetime import timedelta from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel, EmailStr from app.core.config import Settings, get_settings from app.core.ratelimit import limiter from app.core.security import ( COOKIE_NAME, AuthUser, create_token, decode_token, email_allowed, get_current_user, verify_password, ) from app.services import invites, users router = APIRouter(tags=["auth"]) MIN_PASSWORD = 8 class VerifyReq(BaseModel): token: str class PasswordLoginReq(BaseModel): email: EmailStr password: str class ForgotReq(BaseModel): email: EmailStr class SetPasswordReq(BaseModel): token: str password: str class ChangePasswordReq(BaseModel): current_password: str | None = None new_password: str class PrefsReq(BaseModel): tutoiement: bool | None = None course: str | None = None deep: bool | None = None locale: str | None = None font_scale: float | None = None display_name: str | None = None def _set_cookie(resp: Response, token: str, settings: Settings, refresh: str | None = None) -> None: secure = not settings.is_dev resp.set_cookie(COOKIE_NAME, token, httponly=True, secure=secure, samesite="lax", max_age=settings.JWT_TTL_HOURS * 3600, path="/") if refresh: resp.set_cookie(COOKIE_NAME + "_refresh", refresh, httponly=True, secure=secure, samesite="lax", max_age=settings.REFRESH_TTL_DAYS * 86400, path="/api/v1/auth") def _issue(user_id: str, role: str, settings: Settings, resp: Response) -> dict: token = create_token(user_id, role, settings) refresh = create_token(user_id, role, settings, timedelta(days=settings.REFRESH_TTL_DAYS), kind="refresh") _set_cookie(resp, token, settings, refresh) return {"token": token, "role": role} def _check_password_strength(password: str) -> None: if len(password) < MIN_PASSWORD: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=f"Mot de passe trop court ({MIN_PASSWORD} caractères minimum).") if len(password) > 128: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe trop long (128 maximum).") # ------------------------------------------------------------------ sign-in @router.post("/auth/login") async def password_login(req: PasswordLoginReq, request: Request, response: Response, settings: Settings = Depends(get_settings)) -> dict: ip = request.client.host if request.client else "?" limiter.check(f"pwd:{ip}", 30, 3600, "Trop de tentatives. Réessaie plus tard.") limiter.check(f"pwd:{req.email.lower()}", 10, 900, "Trop de tentatives pour ce compte. Patiente 15 minutes.") user = await users.authenticate(req.email, req.password) if not user: existing = await users.get_user_by_email(req.email) if existing and not existing.password_hash: raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ce compte n'a pas encore de mot de passe. Clique sur " "« Première connexion ou mot de passe oublié » pour en choisir un.") raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Courriel ou mot de passe invalide.") out = _issue(user.id, user.role, settings, response) return {"mode": "password", **out, "user": _user_dict(user)} @router.post("/auth/forgot") async def forgot_password(req: ForgotReq, request: Request, settings: Settings = Depends(get_settings)) -> dict: """First login or forgotten password: e-mail a single-use 'choose your password' link.""" ip = request.client.host if request.client else "?" limiter.check(f"forgot:{ip}", 20, 3600, "Trop de demandes. Réessaie plus tard.") email = req.email.lower().strip() limiter.check(f"forgot:{email}", 5, 3600, "Trop de demandes pour cette adresse. Réessaie dans une heure.") user = await users.get_user_by_email(email) if user is None: # Addresses listed in the environment (professor/admin/invited) may self-register. if email in settings.invited_emails | settings.professor_emails | settings.admin_emails: user = await users.get_or_create_user(email, settings) else: hint = ("Cette adresse n'est pas inscrite au cours. Vérifie l'orthographe ou demande au " "professeur de t'ajouter à la liste des étudiants.") if not email_allowed(email, settings): hint = "Adresse non admise. Utilise ton courriel @uqo.ca inscrit par le professeur." raise HTTPException(status.HTTP_404_NOT_FOUND, detail=hint) sent, link = await invites.send_password_link(user, settings) out: dict = {"sent": sent, "first_time": not user.password_hash, "ttl_minutes": settings.RESET_TTL_MINUTES} if not sent: if settings.is_dev: out["dev_link"] = link # local development only else: out["hint"] = "Envoi de courriel indisponible pour le moment. Préviens le professeur." return out @router.get("/auth/password-token") async def password_token_info(token: str) -> dict: """Validate an activation/reset token without consuming it (drives the /mot-de-passe page).""" link = await users.peek_magic_link(token) if not link or (link.purpose or "login") not in {"invite", "reset"}: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Lien invalide ou expiré.") user = await users.get_user_by_email(link.email) return {"email": link.email, "purpose": link.purpose, "first_time": not (user and user.password_hash), "display_name": user.display_name if user else None, "expires_at": link.expires_at.isoformat()} @router.post("/auth/set-password") async def set_password_with_token(req: SetPasswordReq, response: Response, settings: Settings = Depends(get_settings)) -> dict: """Consume an invite/reset token, store the password and open the session.""" _check_password_strength(req.password) email = await users.consume_magic_link(req.token, purposes={"invite", "reset"}) if not email: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Lien invalide ou expiré. Demande un nouveau lien depuis la page de connexion.") user = await users.get_user_by_email(email) or await users.get_or_create_user(email, settings) await users.set_password(user.id, req.password) await users.invalidate_links(email, purposes={"invite", "reset"}) user = await users.get_user(user.id) assert user is not None out = _issue(user.id, user.role, settings, response) return {"mode": "password_set", **out, "user": _user_dict(user)} @router.post("/auth/verify") async def verify(req: VerifyReq, response: Response, settings: Settings = Depends(get_settings)) -> dict: """Legacy direct sign-in link (purpose 'login'), still honoured for links already sent.""" email = await users.consume_magic_link(req.token, purposes={"login"}) if not email: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Lien invalide ou expiré.") user = await users.get_or_create_user(email, settings) out = _issue(user.id, user.role, settings, response) return {**out, "user": _user_dict(user)} @router.post("/auth/refresh") async def refresh(request: Request, response: Response, settings: Settings = Depends(get_settings)) -> dict: raw = request.cookies.get(COOKIE_NAME + "_refresh") payload = decode_token(raw, settings) if raw else None if not payload or payload.get("kind") != "refresh": raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Session expirée.") user = await users.get_user(str(payload["sub"])) if not user: raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.") return _issue(user.id, user.role, settings, response) @router.post("/auth/logout") async def logout(response: Response) -> dict: response.delete_cookie(COOKIE_NAME, path="/") response.delete_cookie(COOKIE_NAME + "_refresh", path="/api/v1/auth") return {"ok": True} @router.get("/auth/config") async def auth_config(settings: Settings = Depends(get_settings)) -> dict: return {"password": True, "mail": settings.mail_enabled, "min_password": MIN_PASSWORD, "domains": sorted(settings.allowed_domains), "courses": settings.courses, "term": settings.TERM_LABEL} def _user_dict(u) -> dict: # noqa: ANN001 return {"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name, "preferences": u.preferences or {}, "has_password": bool(u.password_hash), "consent_at": u.consent_at.isoformat() if u.consent_at else None} # ------------------------------------------------------------------ me @router.get("/me") async def me(auth: AuthUser = Depends(get_current_user)) -> dict: user = await users.get_user(auth.id) if not user: raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.") await users.touch(user.id) return _user_dict(user) @router.patch("/me/preferences") async def patch_prefs(req: PrefsReq, auth: AuthUser = Depends(get_current_user)) -> dict: data = req.model_dump(exclude_none=True) display_name = data.pop("display_name", None) user = await users.update_preferences(auth.id, data, display_name) return _user_dict(user) @router.post("/me/password") async def change_password(req: ChangePasswordReq, auth: AuthUser = Depends(get_current_user)) -> dict: user = await users.get_user(auth.id) if not user: raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.") _check_password_strength(req.new_password) if user.password_hash and not verify_password(req.current_password or "", user.password_hash): raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe actuel incorrect.") await users.set_password(auth.id, req.new_password) return {"ok": True} @router.post("/me/consent") async def consent(auth: AuthUser = Depends(get_current_user)) -> dict: await users.set_consent(auth.id) return {"ok": True} @router.delete("/me") async def delete_me(response: Response, auth: AuthUser = Depends(get_current_user)) -> dict: await users.delete_user_data(auth.id) response.delete_cookie(COOKIE_NAME, path="/") return {"ok": True, "message_fr": "Tes données ont été supprimées."}