Files
office_translator/routes/api_key_routes.py
2026-03-07 11:42:58 +01:00

331 lines
9.3 KiB
Python

"""
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": {},
},
)