723 lines
22 KiB
Python
723 lines
22 KiB
Python
"""
|
|
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, ValidationError as PydanticValidationError
|
|
from typing import Optional, Dict, Any
|
|
|
|
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,
|
|
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,
|
|
)
|
|
|
|
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
# Request/Response models
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str = "bearer"
|
|
user: UserResponse
|
|
|
|
|
|
class RefreshRequest(BaseModel):
|
|
refresh_token: str
|
|
|
|
|
|
class CheckoutRequest(BaseModel):
|
|
plan: PlanType
|
|
billing_period: str = "monthly"
|
|
|
|
|
|
class CreditsCheckoutRequest(BaseModel):
|
|
package_index: int
|
|
|
|
|
|
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
|
|
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
|
|
|
|
|
|
def user_to_response(user) -> UserResponse:
|
|
"""Convert User to UserResponse with plan limits"""
|
|
plan_limits = PLANS[user.plan]
|
|
return UserResponse(
|
|
id=user.id,
|
|
email=user.email,
|
|
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,
|
|
api_calls_this_month=user.api_calls_this_month,
|
|
extra_credits=user.extra_credits,
|
|
created_at=user.created_at,
|
|
plan_limits={
|
|
"docs_per_month": plan_limits["docs_per_month"],
|
|
"max_pages_per_doc": plan_limits["max_pages_per_doc"],
|
|
"max_file_size_mb": plan_limits["max_file_size_mb"],
|
|
"providers": plan_limits["providers"],
|
|
"features": plan_limits["features"],
|
|
"api_access": plan_limits.get("api_access", False),
|
|
},
|
|
)
|
|
|
|
|
|
# ============== 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 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_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:
|
|
# 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_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:
|
|
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_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": {}},
|
|
)
|
|
|
|
|
|
@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_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_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:
|
|
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 (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
|