feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle

Made-with: Cursor
This commit is contained in:
Sepehr Ramezani
2026-03-07 11:42:58 +01:00
parent 3d37ce4582
commit 473b3e26c7
181 changed files with 30617 additions and 7170 deletions

View File

@@ -1,25 +1,48 @@
"""
Authentication and User API routes
Story 3.6: Documentation OpenAPI complète avec exemples et codes d'erreur
"""
import os
from datetime import timedelta
from fastapi import APIRouter, HTTPException, Depends, Header, Request
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, ValidationError as PydanticValidationError
from typing import Optional, Dict, Any
from models.subscription import UserCreate, UserLogin, UserResponse, PlanType, PLANS, CREDIT_PACKAGES
from models.subscription import (
UserCreate,
UserLogin,
UserResponse,
PlanType,
PLANS,
CREDIT_PACKAGES,
)
from services.auth_service import (
create_user, authenticate_user, get_user_by_id,
create_access_token, create_refresh_token, verify_token,
check_usage_limits, update_user
create_user,
authenticate_user,
get_user_by_id,
create_access_token,
create_refresh_token,
verify_token,
revoke_token_jti,
check_usage_limits,
update_user,
get_user_by_email,
verify_password,
PASSLIB_AVAILABLE,
)
from services.payment_service import (
create_checkout_session, create_credits_checkout,
cancel_subscription, get_billing_portal_url,
handle_webhook, is_stripe_configured
create_checkout_session,
create_credits_checkout,
cancel_subscription,
get_billing_portal_url,
handle_webhook,
is_stripe_configured,
)
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
security = HTTPBearer(auto_error=False)
@@ -44,31 +67,26 @@ class CreditsCheckoutRequest(BaseModel):
package_index: int
# Dependency to get current user
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
):
if not credentials:
return None
payload = verify_token(credentials.credentials)
if not payload:
return None
user = get_user_by_id(payload.get("sub"))
return user
return get_user_by_id(payload.get("sub"))
async def require_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
if not credentials:
raise HTTPException(status_code=401, detail="Not authenticated")
payload = verify_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=401, detail="Invalid or expired token")
user = get_user_by_id(payload.get("sub"))
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
@@ -81,6 +99,7 @@ def user_to_response(user) -> UserResponse:
name=user.name,
avatar_url=user.avatar_url,
plan=user.plan,
tier=user.plan,
subscription_status=user.subscription_status,
docs_translated_this_month=user.docs_translated_this_month,
pages_translated_this_month=user.pages_translated_this_month,
@@ -94,188 +113,610 @@ def user_to_response(user) -> UserResponse:
"providers": plan_limits["providers"],
"features": plan_limits["features"],
"api_access": plan_limits.get("api_access", False),
}
},
)
# Auth endpoints
@router.post("/register", response_model=TokenResponse)
async def register(user_create: UserCreate):
"""Register a new user"""
# ============== API v1 Router ==============
router_v1 = APIRouter(prefix="/api/v1/auth", tags=["Authentication v1"])
def _has_email_validation_error(exc: PydanticValidationError) -> bool:
"""Return True when pydantic validation errors include the email field."""
for err in exc.errors():
loc = err.get("loc", ())
if isinstance(loc, (list, tuple)) and "email" in loc:
return True
return False
@router_v1.post(
"/register",
status_code=201,
summary="Inscription d'un nouvel utilisateur",
description="""
Créer un nouveau compte utilisateur.
**Paramètres requis:**
- `email`: Adresse email valide (sera utilisée pour la connexion)
- `password`: Mot de passe (minimum 8 caractères)
- `name`: Nom complet (optionnel)
**Réponse:**
- HTTP 201 avec les données de l'utilisateur créé
- Le `tier` par défaut est "free"
**Codes d'erreur:**
- `INVALID_EMAIL`: Format d'email invalide
- `EMAIL_EXISTS`: Un compte existe déjà avec cet email
- `INVALID_REQUEST`: Données d'inscription invalides
""",
responses={
201: {
"description": "Utilisateur créé avec succès",
"content": {
"application/json": {
"example": {
"data": {
"id": "usr_abc123def456",
"email": "utilisateur@exemple.com",
"tier": "free",
},
"meta": {},
}
}
},
},
400: {
"description": "Erreur de validation",
"content": {
"application/json": {
"examples": {
"INVALID_EMAIL": {
"summary": "Format d'email invalide",
"value": {
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
},
},
"EMAIL_EXISTS": {
"summary": "Email déjà utilisé",
"value": {
"error": "EMAIL_EXISTS",
"message": "Un compte existe déjà avec cette adresse email",
},
},
}
}
},
},
},
)
async def register_v1(request: Request):
"""Inscription d'un nouvel utilisateur (API v1) — retourne 201 avec données utilisateur"""
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
)
try:
user_create = UserCreate.model_validate(body)
except PydanticValidationError as exc:
if _has_email_validation_error(exc):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
},
)
# Check if it's a password validation error
for error in exc.errors():
loc = error.get("loc", ())
if "password" in loc:
msg = error.get("msg", "")
# If password is missing entirely, return INVALID_REQUEST
if "Field required" in msg or "required" in msg.lower():
break
# Otherwise it's a weak password
# Translate common pydantic messages to French
if "at least 8 characters" in msg.lower() or "8 caractères" in msg:
msg = "Le mot de passe doit contenir au moins 8 caractères"
return JSONResponse(
status_code=400,
content={
"error": "WEAK_PASSWORD",
"message": msg,
},
)
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Données d'inscription invalides",
},
)
# In production, registration must rely on passlib[bcrypt] hashing.
if (
os.getenv("ENVIRONMENT", "development").lower() == "production"
and not PASSLIB_AVAILABLE
):
return JSONResponse(
status_code=503,
content={
"error": "AUTH_HASHING_UNAVAILABLE",
"message": "Service d'inscription temporairement indisponible",
},
)
try:
user = create_user(user_create)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user=user_to_response(user)
except ValueError as exc:
msg = str(exc).strip().lower()
if "email already registered" in msg or "email already" in msg:
return JSONResponse(
status_code=400,
content={
"error": "EMAIL_EXISTS",
"message": "Un compte existe déjà avec cette adresse email",
},
)
return JSONResponse(
status_code=400,
content={
"error": "REGISTRATION_FAILED",
"message": "Impossible de créer le compte avec les données fournies",
},
)
return JSONResponse(
status_code=201,
content={
"data": {
"id": user.id,
"email": user.email,
"tier": user.plan.value,
},
"meta": {},
},
)
@router.post("/login", response_model=TokenResponse)
async def login(credentials: UserLogin):
"""Login with email and password"""
user = authenticate_user(credentials.email, credentials.password)
@router_v1.post(
"/logout",
summary="Déconnexion utilisateur",
description="""
Déconnecte l'utilisateur en révoquant son token d'accès.
**Authentification requise:** Bearer token dans le header Authorization
**Paramètres optionnels:**
- `refresh_token`: Peut être fourni pour révoquer également le refresh token
**Réponse:**
- HTTP 200 avec message de confirmation
**Codes d'erreur:**
- `TOKEN_MISSING`: Token d'authentification manquant
- `TOKEN_INVALID`: Token invalide ou expiré
""",
responses={
200: {
"description": "Déconnexion réussie",
"content": {
"application/json": {
"example": {"data": {"message": "Déconnexion réussie"}, "meta": {}}
}
},
},
401: {
"description": "Erreur d'authentification",
"content": {
"application/json": {
"examples": {
"TOKEN_MISSING": {
"summary": "Token manquant",
"value": {
"error": "TOKEN_MISSING",
"message": "Token d'authentification requis",
},
},
"TOKEN_INVALID": {
"summary": "Token invalide",
"value": {
"error": "TOKEN_INVALID",
"message": "Token invalide ou expiré",
},
},
}
}
},
},
},
)
async def logout_v1(request: Request):
"""Logout utilisateur (API v1) — révoque l'access token et optionnellement le refresh token"""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_MISSING",
"message": "Token d'authentification requis",
},
)
access_token = auth_header[7:]
payload = verify_token(access_token)
if not payload:
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_INVALID",
"message": "Token invalide ou expiré",
},
)
jti = payload.get("jti")
if jti:
revoke_token_jti(jti, float(payload.get("exp", 0)))
try:
body = await request.json()
refresh_token = body.get("refresh_token")
if refresh_token:
refresh_payload = verify_token(refresh_token)
if refresh_payload and refresh_payload.get("jti"):
revoke_token_jti(
refresh_payload["jti"],
float(refresh_payload.get("exp", 0)),
)
except Exception:
pass
return JSONResponse(
status_code=200,
content={"data": {"message": "Déconnexion réussie"}, "meta": {}},
)
@router_v1.post(
"/login",
summary="Connexion utilisateur",
description="""
Authentifie un utilisateur et retourne les tokens JWT.
**Paramètres requis:**
- `email`: Adresse email de l'utilisateur
- `password`: Mot de passe
**Réponse:**
- HTTP 200 avec `access_token` (expire dans 15 min) et `refresh_token` (expire dans 7 jours)
**Codes d'erreur:**
- `INVALID_REQUEST`: Corps de requête JSON invalide
- `INVALID_EMAIL`: Format d'email invalide
- `INVALID_CREDENTIALS`: Email ou mot de passe incorrect
""",
responses={
200: {
"description": "Connexion réussie",
"content": {
"application/json": {
"example": {
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
},
"meta": {},
}
}
},
},
400: {
"description": "Erreur de validation",
"content": {
"application/json": {
"examples": {
"INVALID_REQUEST": {
"summary": "Corps invalide",
"value": {
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
},
"INVALID_EMAIL": {
"summary": "Email invalide",
"value": {
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
},
},
}
}
},
},
401: {
"description": "Identifiants invalides",
"content": {
"application/json": {
"example": {
"error": "INVALID_CREDENTIALS",
"message": "Email ou mot de passe incorrect",
}
}
},
},
},
)
async def login_v1(request: Request):
"""Login utilisateur (API v1) — retourne access_token (15min) et refresh_token (7j)"""
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
)
try:
user_login = UserLogin.model_validate(body)
except PydanticValidationError as exc:
if _has_email_validation_error(exc):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
},
)
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Données d'inscription invalides",
},
)
user = get_user_by_email(user_login.email)
if not user:
raise HTTPException(status_code=401, detail="Invalid email or password")
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user=user_to_response(user)
# Constant-time dummy verify to prevent user enumeration via response time
verify_password("__dummy__", "$2b$12$mBw4RxPJBaaS1FtEZcT/E.E35YUCk1Zx0ICzIzNUSdzHmQmko1.WW")
return JSONResponse(
status_code=401,
content={
"error": "INVALID_CREDENTIALS",
"message": "Email ou mot de passe incorrect",
},
)
if not verify_password(user_login.password, user.password_hash):
return JSONResponse(
status_code=401,
content={
"error": "INVALID_CREDENTIALS",
"message": "Email ou mot de passe incorrect",
},
)
access_token = create_access_token(
user.id, tier=user.plan.value, expires_delta=timedelta(minutes=15)
)
refresh_token = create_refresh_token(user.id, expires_delta=timedelta(days=7))
return JSONResponse(
status_code=200,
content={
"data": {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
},
"meta": {},
},
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_tokens(request: RefreshRequest):
"""Refresh access token"""
payload = verify_token(request.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
@router_v1.post("/refresh")
async def refresh_v1(request: Request):
"""Refresh tokens (API v1) — accepte refresh_token en corps, retourne nouvel access_token et refresh_token."""
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
)
if not isinstance(body, dict):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
)
refresh_token = body.get("refresh_token")
if (
not refresh_token
or not isinstance(refresh_token, str)
or not refresh_token.strip()
):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Refresh token requis",
},
)
payload = verify_token(refresh_token)
if not payload:
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_EXPIRED",
"message": "Token invalide ou expiré",
},
)
if payload.get("type") != "refresh":
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_EXPIRED",
"message": "Token invalide ou expiré",
},
)
user = get_user_by_id(payload.get("sub"))
if not user:
raise HTTPException(status_code=401, detail="User not found")
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user=user_to_response(user)
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_EXPIRED",
"message": "Token invalide ou expiré",
},
)
access_token = create_access_token(
user.id, tier=user.plan.value, expires_delta=timedelta(minutes=15)
)
new_refresh_token = create_refresh_token(user.id, expires_delta=timedelta(days=7))
return JSONResponse(
status_code=200,
content={
"data": {
"access_token": access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer",
},
"meta": {},
},
)
@router.get("/me", response_model=UserResponse)
async def get_me(user=Depends(require_user)):
"""Get current user info"""
return user_to_response(user)
@router.get("/usage")
async def get_usage(user=Depends(require_user)):
"""Get current usage and limits"""
return check_usage_limits(user)
@router.put("/settings")
async def update_settings(settings: Dict[str, Any], user=Depends(require_user)):
"""Update user settings"""
allowed_fields = [
"default_source_lang", "default_target_lang", "default_provider",
"ollama_endpoint", "ollama_model", "name"
]
updates = {k: v for k, v in settings.items() if k in allowed_fields}
updated_user = update_user(user.id, updates)
if not updated_user:
raise HTTPException(status_code=400, detail="Failed to update settings")
return user_to_response(updated_user)
# Plans endpoint (public)
@router.get("/plans")
async def get_plans():
"""Get all available plans"""
plans = []
for plan_type, config in PLANS.items():
plans.append({
"id": plan_type.value,
"name": config["name"],
"price_monthly": config["price_monthly"],
"price_yearly": config["price_yearly"],
"features": config["features"],
"docs_per_month": config["docs_per_month"],
"max_pages_per_doc": config["max_pages_per_doc"],
"max_file_size_mb": config["max_file_size_mb"],
"providers": config["providers"],
"api_access": config.get("api_access", False),
"popular": plan_type == PlanType.PRO,
})
return {"plans": plans, "credit_packages": CREDIT_PACKAGES}
# Payment endpoints
@router.post("/checkout/subscription")
async def checkout_subscription(request: CheckoutRequest, user=Depends(require_user)):
"""Create Stripe checkout session for subscription"""
if not is_stripe_configured():
# Demo mode - just upgrade the user
update_user(user.id, {"plan": request.plan.value})
return {"demo_mode": True, "message": "Upgraded in demo mode", "plan": request.plan.value}
result = await create_checkout_session(
user.id,
request.plan,
request.billing_period
@router_v1.get(
"/me",
summary="Informations utilisateur",
description="Retourne les informations de l'utilisateur authentifié.",
)
async def get_me_v1(user=Depends(require_user)):
return JSONResponse(
status_code=200,
content={"data": user_to_response(user).model_dump(mode="json"), "meta": {}},
)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.post("/checkout/credits")
async def checkout_credits(request: CreditsCheckoutRequest, user=Depends(require_user)):
"""Create Stripe checkout session for credits"""
if not is_stripe_configured():
# Demo mode - add credits directly
from services.auth_service import add_credits
credits = CREDIT_PACKAGES[request.package_index]["credits"]
add_credits(user.id, credits)
return {"demo_mode": True, "message": f"Added {credits} credits in demo mode"}
result = await create_credits_checkout(user.id, request.package_index)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router_v1.get(
"/plans",
summary="Liste des forfaits disponibles",
description="Retourne tous les forfaits et packs de crédits disponibles (endpoint public).",
)
async def get_plans_v1():
from models.subscription import PLANS, CREDIT_PACKAGES
plans_list = []
for plan_type, plan in PLANS.items():
plans_list.append(
{
"id": plan_type.value,
"name": plan["name"],
"price_monthly": plan["price_monthly"],
"price_yearly": plan["price_yearly"],
"docs_per_month": plan["docs_per_month"],
"max_pages_per_doc": plan["max_pages_per_doc"],
"max_file_size_mb": plan["max_file_size_mb"],
"max_chars_per_month": plan.get("max_chars_per_month", -1),
"providers": plan["providers"],
"features": plan["features"],
"ai_translation": plan.get("ai_translation", False),
"ai_tier": plan.get("ai_tier"),
"api_access": plan.get("api_access", False),
"priority_processing": plan.get("priority_processing", False),
"team_seats": plan.get("team_seats"),
"highlight": plan.get("highlight"),
"description": plan.get("description"),
"badge": plan.get("badge"),
"popular": plan.get("badge") == "POPULAIRE",
}
)
packages_list = []
for pkg in CREDIT_PACKAGES:
packages_list.append(
{
"credits": pkg["credits"],
"price": pkg["price"],
"price_per_credit": round(pkg["price"] / pkg["credits"], 4),
"popular": pkg.get("popular", False),
}
)
return JSONResponse(
status_code=200,
content={"data": {"plans": plans_list, "credit_packages": packages_list}, "meta": {}},
)
@router.post("/subscription/cancel")
async def cancel_user_subscription(user=Depends(require_user)):
"""Cancel subscription"""
result = await cancel_subscription(user.id)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router_v1.get(
"/usage",
summary="Utilisation et limites",
description="Retourne l'utilisation actuelle et les limites du plan de l'utilisateur.",
)
async def get_usage_v1(user=Depends(require_user)):
return JSONResponse(
status_code=200,
content={"data": check_usage_limits(user), "meta": {}},
)
@router.get("/billing-portal")
async def get_billing_portal(user=Depends(require_user)):
"""Get Stripe billing portal URL"""
@router_v1.get(
"/billing-portal",
summary="Portail de facturation",
description="Retourne l'URL du portail de facturation Stripe.",
)
async def get_billing_portal_v1(user=Depends(require_user)):
url = await get_billing_portal_url(user.id)
if not url:
raise HTTPException(status_code=400, detail="Billing portal not available")
return {"url": url}
return JSONResponse(
status_code=400,
content={
"error": "BILLING_UNAVAILABLE",
"message": "Portail de facturation non disponible",
},
)
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
# Stripe webhook
@router.post("/webhook/stripe")
# ============== Stripe webhook (versioned) ==============
@router_v1.post("/webhook/stripe")
async def stripe_webhook(request: Request, stripe_signature: str = Header(None)):
"""Handle Stripe webhooks"""
payload = await request.body()
result = await handle_webhook(payload, stripe_signature or "")
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result