- Restructured docker-compose for Nginx Proxy Manager (no custom nginx) - Added domain wordly.art configuration - Added Prometheus + Grafana monitoring stack with pre-configured dashboards - Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation) - Added alert rules for backend, system, and Docker metrics - Updated deployment guide for NPM + IONOS DNS homelab setup - Added marketing plan document - PDF translator and watermark support - Enhanced middleware, routes, and translator modules Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
240 lines
6.7 KiB
Python
240 lines
6.7 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
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from routes.deps import ProUser, require_pro_user
|
|
from database.connection import get_sync_session
|
|
from database.models import ApiKey
|
|
|
|
router = APIRouter(prefix="/api/v1/api-keys", tags=["API Keys v1"])
|
|
|
|
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
|
|
|
|
|
|
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": {},
|
|
},
|
|
)
|