feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
330
routes/api_key_routes.py
Normal file
330
routes/api_key_routes.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
API Key management routes for Pro users
|
||||
Story 3.1: Modèle API Key & Génération
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.auth_service import verify_token, get_user_by_id
|
||||
from database.connection import get_sync_session
|
||||
from database.models import ApiKey
|
||||
|
||||
router = APIRouter(prefix="/api/v1/api-keys", tags=["API Keys v1"])
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
MAX_API_KEYS_PER_USER = 10
|
||||
|
||||
|
||||
class ApiKeyCreateRequest(BaseModel):
|
||||
name: Optional[str] = Field(default="Default API Key", max_length=100)
|
||||
|
||||
|
||||
class ApiKeyResponse(BaseModel):
|
||||
id: str
|
||||
key: str
|
||||
name: str
|
||||
key_prefix: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class ProUser:
|
||||
"""Wrapper for authenticated Pro user with tier info"""
|
||||
|
||||
def __init__(self, user):
|
||||
self._user = user
|
||||
self.id = user.id
|
||||
self.email = getattr(user, "email", None)
|
||||
self._tier = None
|
||||
|
||||
@property
|
||||
def tier(self) -> str:
|
||||
if self._tier is None:
|
||||
user_tier = getattr(self._user, "tier", None)
|
||||
if user_tier:
|
||||
self._tier = user_tier
|
||||
else:
|
||||
plan_value = getattr(self._user, "plan", None)
|
||||
if plan_value and hasattr(plan_value, "value"):
|
||||
if plan_value.value in ("pro", "business", "enterprise"):
|
||||
self._tier = "pro"
|
||||
else:
|
||||
self._tier = "free"
|
||||
else:
|
||||
self._tier = "free"
|
||||
return self._tier
|
||||
|
||||
|
||||
def _require_auth(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
):
|
||||
"""Dependency that requires a valid JWT token (any authenticated user)"""
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "UNAUTHORIZED",
|
||||
"message": "Authentification requise",
|
||||
},
|
||||
)
|
||||
|
||||
payload = verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "UNAUTHORIZED",
|
||||
"message": "Token invalide ou expiré",
|
||||
},
|
||||
)
|
||||
|
||||
sub = payload.get("sub")
|
||||
if not sub or not isinstance(sub, str):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "UNAUTHORIZED",
|
||||
"message": "Token invalide",
|
||||
},
|
||||
)
|
||||
|
||||
user = get_user_by_id(sub)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "UNAUTHORIZED",
|
||||
"message": "Utilisateur non trouvé",
|
||||
},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def _require_pro_user(user=Depends(_require_auth)) -> ProUser:
|
||||
"""Dependency that requires a valid Pro user JWT token"""
|
||||
pro_user = ProUser(user)
|
||||
|
||||
if pro_user.tier != "pro":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "PRO_FEATURE_REQUIRED",
|
||||
"message": "Cette fonctionnalité nécessite un abonnement Pro",
|
||||
},
|
||||
)
|
||||
|
||||
return pro_user
|
||||
|
||||
|
||||
def _generate_api_key() -> tuple[str, str, str]:
|
||||
"""
|
||||
Generate a secure API key with sk_live_ prefix.
|
||||
|
||||
Returns:
|
||||
tuple: (raw_key, key_hash, key_prefix)
|
||||
"""
|
||||
raw_random = secrets.token_urlsafe(32)
|
||||
raw_key = f"sk_live_{raw_random}"
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
key_prefix = raw_key[:8]
|
||||
|
||||
return raw_key, key_hash, key_prefix
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_api_key(
|
||||
request: Request,
|
||||
body: Optional[ApiKeyCreateRequest] = None,
|
||||
user: ProUser = Depends(_require_pro_user),
|
||||
):
|
||||
"""
|
||||
Create a new API key for the authenticated Pro user.
|
||||
|
||||
Returns:
|
||||
201: API key created successfully (key shown ONCE)
|
||||
401: Authentication required
|
||||
403: Pro subscription required
|
||||
429: Maximum API keys reached
|
||||
"""
|
||||
key_name = body.name if body and body.name else "Default API Key"
|
||||
|
||||
raw_key, key_hash, key_prefix = _generate_api_key()
|
||||
|
||||
with get_sync_session() as session:
|
||||
existing_count = (
|
||||
session.query(ApiKey)
|
||||
.filter(
|
||||
ApiKey.user_id == user.id,
|
||||
ApiKey.is_active == True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
if existing_count >= MAX_API_KEYS_PER_USER:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"error": "API_KEY_LIMIT_REACHED",
|
||||
"message": f"Maximum de {MAX_API_KEYS_PER_USER} clés API atteint. Supprimez une clé existante.",
|
||||
},
|
||||
)
|
||||
|
||||
api_key = ApiKey(
|
||||
user_id=user.id,
|
||||
name=key_name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
is_active=True,
|
||||
scopes=["translate"],
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(api_key)
|
||||
session.commit()
|
||||
session.refresh(api_key)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"data": {
|
||||
"id": api_key.id,
|
||||
"key": raw_key,
|
||||
"name": api_key.name,
|
||||
"key_prefix": api_key.key_prefix,
|
||||
"created_at": api_key.created_at.isoformat()
|
||||
if api_key.created_at
|
||||
else None,
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_api_keys(
|
||||
request: Request,
|
||||
user: ProUser = Depends(_require_pro_user),
|
||||
):
|
||||
"""
|
||||
List all API keys for the authenticated Pro user.
|
||||
|
||||
Note: Keys are returned without the secret (only prefix visible).
|
||||
|
||||
Returns:
|
||||
200: List of API keys
|
||||
401: Authentication required
|
||||
403: Pro subscription required
|
||||
"""
|
||||
with get_sync_session() as session:
|
||||
keys = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == user.id)
|
||||
.order_by(ApiKey.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": [
|
||||
{
|
||||
"id": key.id,
|
||||
"name": key.name,
|
||||
"key_prefix": key.key_prefix,
|
||||
"is_active": key.is_active,
|
||||
"last_used_at": key.last_used_at.isoformat()
|
||||
if key.last_used_at
|
||||
else None,
|
||||
"usage_count": key.usage_count,
|
||||
"created_at": key.created_at.isoformat()
|
||||
if key.created_at
|
||||
else None,
|
||||
}
|
||||
for key in keys
|
||||
],
|
||||
"meta": {"total": len(keys)},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{key_id}")
|
||||
async def revoke_api_key(
|
||||
key_id: str,
|
||||
user: ProUser = Depends(_require_pro_user),
|
||||
):
|
||||
"""
|
||||
Revoke an API key for the authenticated Pro user.
|
||||
|
||||
This performs a soft delete by setting is_active=False.
|
||||
Only the owner of the key can revoke it.
|
||||
Only active keys can be revoked.
|
||||
|
||||
Returns:
|
||||
200: API key revoked successfully
|
||||
401: Authentication required
|
||||
403: Pro subscription required
|
||||
404: API key not found or already revoked
|
||||
"""
|
||||
# Validate key_id format (UUID)
|
||||
try:
|
||||
import uuid as uuid_module
|
||||
uuid_module.UUID(key_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_KEY_ID",
|
||||
"message": "Format d'identifiant de clé API invalide.",
|
||||
},
|
||||
)
|
||||
|
||||
with get_sync_session() as session:
|
||||
# Security: Filter by user_id AND is_active so only the owner can revoke active keys
|
||||
api_key = (
|
||||
session.query(ApiKey)
|
||||
.filter(
|
||||
ApiKey.id == key_id,
|
||||
ApiKey.user_id == user.id,
|
||||
ApiKey.is_active == True, # Only active keys can be revoked
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not api_key:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "API_KEY_NOT_FOUND",
|
||||
"message": "Clé API non trouvée, n'appartient pas à l'utilisateur ou déjà révoquée.",
|
||||
},
|
||||
)
|
||||
|
||||
# Soft delete - mark as inactive and record revocation timestamp
|
||||
revoked_at = datetime.now(timezone.utc)
|
||||
api_key.is_active = False
|
||||
api_key.revoked_at = revoked_at
|
||||
session.commit()
|
||||
|
||||
logger.info(f"API key {key_id} revoked by user {user.id}")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"id": api_key.id,
|
||||
"revoked": True,
|
||||
"revoked_at": revoked_at.isoformat(),
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user