From 09eae50952efea3ad62134eb83b8a211188e3dda Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 19 Jan 2026 20:32:40 +0300 Subject: [PATCH 01/30] refactor: clean up code by removing unnecessary comments --- api.py | 3 -- config/config.py | 8 ----- create_admin.py | 3 -- database/models/api_key.py | 64 +++++++++++++++++++++++++++++++++ routes/enterprise.py | 1 - routes/universign.py | 1 - sage_client.py | 2 -- schemas/articles/articles.py | 1 - schemas/documents/reglements.py | 6 ---- schemas/sage/sage_gateway.py | 2 -- schemas/tiers/commercial.py | 5 --- schemas/tiers/tiers.py | 13 ------- services/universign_document.py | 37 ++++++------------- services/universign_sync.py | 43 +++------------------- utils/generic_functions.py | 6 +--- 15 files changed, 80 insertions(+), 115 deletions(-) create mode 100644 database/models/api_key.py diff --git a/api.py b/api.py index 1b2e078..27f5de3 100644 --- a/api.py +++ b/api.py @@ -132,7 +132,6 @@ async def lifespan(app: FastAPI): api_url=settings.universign_api_url, api_key=settings.universign_api_key ) - # Configuration du service avec les dépendances sync_service.configure( sage_client=sage_client, email_queue=email_queue, settings=settings ) @@ -180,7 +179,6 @@ app.include_router(entreprises_router) @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def obtenir_clients( query: Optional[str] = Query(None), - # sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: clients = sage_client.lister_clients(filtre=query or "") @@ -2768,7 +2766,6 @@ async def get_current_sage_config( } -# Routes Collaborateurs @app.get( "/collaborateurs", response_model=List[CollaborateurDetails], diff --git a/config/config.py b/config/config.py index 63bf99b..d60c4d8 100644 --- a/config/config.py +++ b/config/config.py @@ -7,7 +7,6 @@ class Settings(BaseSettings): env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) - # === JWT & Auth === jwt_secret: str jwt_algorithm: str access_token_expire_minutes: int @@ -21,15 +20,12 @@ class Settings(BaseSettings): SAGE_TYPE_BON_AVOIR: int = 50 SAGE_TYPE_FACTURE: int = 60 - # === Sage Gateway (Windows) === sage_gateway_url: str sage_gateway_token: str frontend_url: str - # === Base de données === database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db" - # === SMTP === smtp_host: str smtp_port: int = 587 smtp_user: str @@ -37,21 +33,17 @@ class Settings(BaseSettings): smtp_from: str smtp_use_tls: bool = True - # === Universign === universign_api_key: str universign_api_url: str - # === API === api_host: str api_port: int api_reload: bool = False - # === Email Queue === max_email_workers: int = 3 max_retry_attempts: int = 3 retry_delay_seconds: int = 3 - # === CORS === cors_origins: List[str] = ["*"] diff --git a/create_admin.py b/create_admin.py index d3cb786..96197ec 100644 --- a/create_admin.py +++ b/create_admin.py @@ -19,7 +19,6 @@ async def create_admin(): print(" Création d'un compte administrateur") print("=" * 60 + "\n") - # Saisie des informations email = input("Email de l'admin: ").strip().lower() if not email or "@" not in email: print(" Email invalide") @@ -32,7 +31,6 @@ async def create_admin(): print(" Prénom et nom requis") return False - # Mot de passe avec validation while True: password = input( "Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): " @@ -58,7 +56,6 @@ async def create_admin(): print(f"\n Un utilisateur avec l'email {email} existe déjà") return False - # Créer l'admin admin = User( id=str(uuid.uuid4()), email=email, diff --git a/database/models/api_key.py b/database/models/api_key.py new file mode 100644 index 0000000..1e54342 --- /dev/null +++ b/database/models/api_key.py @@ -0,0 +1,64 @@ +from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text +from datetime import datetime +import uuid + +from database.models.generic_model import Base + + +class ApiKey(Base): + """Modèle pour les clés API publiques""" + + __tablename__ = "api_keys" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + key_hash = Column(String(64), unique=True, nullable=False, index=True) + key_prefix = Column( + String(10), nullable=False + ) # Premiers caractères pour identification + + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + + # Métadonnées + user_id = Column(String(36), nullable=True) # Optionnel si associé à un utilisateur + created_by = Column(String(255), nullable=False) + + # Contrôle d'accès + is_active = Column(Boolean, default=True, nullable=False) + rate_limit_per_minute = Column(Integer, default=60, nullable=False) + allowed_endpoints = Column( + Text, nullable=True + ) # JSON array des endpoints autorisés + + # Statistiques + total_requests = Column(Integer, default=0, nullable=False) + last_used_at = Column(DateTime, nullable=True) + + # Dates + created_at = Column(DateTime, default=datetime.now, nullable=False) + expires_at = Column(DateTime, nullable=True) + revoked_at = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" + + +class SwaggerUser(Base): + """Modèle pour les utilisateurs autorisés à accéder au Swagger""" + + __tablename__ = "swagger_users" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + username = Column(String(100), unique=True, nullable=False, index=True) + hashed_password = Column(String(255), nullable=False) + + full_name = Column(String(255), nullable=True) + email = Column(String(255), nullable=True) + + is_active = Column(Boolean, default=True, nullable=False) + + created_at = Column(DateTime, default=datetime.now, nullable=False) + last_login = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" diff --git a/routes/enterprise.py b/routes/enterprise.py index 2ed18d1..2de1e5f 100644 --- a/routes/enterprise.py +++ b/routes/enterprise.py @@ -22,7 +22,6 @@ async def rechercher_entreprise( try: logger.info(f" Recherche entreprise: '{q}'") - # Appel API api_response = await rechercher_entreprise_api(q, per_page) resultats_api = api_response.get("results", []) diff --git a/routes/universign.py b/routes/universign.py index 20d7960..f67d042 100644 --- a/routes/universign.py +++ b/routes/universign.py @@ -511,7 +511,6 @@ async def webhook_universign( transaction_id = None if payload.get("type", "").startswith("transaction.") and "payload" in payload: - # Le transaction_id est dans payload.object.id nested_object = payload.get("payload", {}).get("object", {}) if nested_object.get("object") == "transaction": transaction_id = nested_object.get("id") diff --git a/sage_client.py b/sage_client.py index 0137512..9ad7b50 100644 --- a/sage_client.py +++ b/sage_client.py @@ -1,4 +1,3 @@ -# sage_client.py import requests from typing import Dict, List, Optional from config.config import settings @@ -468,7 +467,6 @@ class SageGatewayClient: "tva_encaissement": tva_encaissement, } - # Champs optionnels if date_reglement: payload["date_reglement"] = date_reglement if code_journal: diff --git a/schemas/articles/articles.py b/schemas/articles/articles.py index 79b2d62..26996a7 100644 --- a/schemas/articles/articles.py +++ b/schemas/articles/articles.py @@ -76,7 +76,6 @@ class Article(BaseModel): ) nb_emplacements: int = Field(0, description="Nombre d'emplacements") - # Champs énumérés normalisés suivi_stock: Optional[int] = Field( None, description="Type de suivi de stock (AR_SuiviStock): 0=Aucun, 1=CMUP, 2=FIFO/LIFO, 3=Sérialisé", diff --git a/schemas/documents/reglements.py b/schemas/documents/reglements.py index bf6d178..5cc5e2c 100644 --- a/schemas/documents/reglements.py +++ b/schemas/documents/reglements.py @@ -10,12 +10,10 @@ logger = logging.getLogger(__name__) class ReglementFactureCreate(BaseModel): """Requête de règlement d'une facture côté VPS""" - # Montant et devise montant: Decimal = Field(..., gt=0, description="Montant à régler") devise_code: Optional[int] = Field(0, description="Code devise (0=EUR par défaut)") cours_devise: Optional[Decimal] = Field(1.0, description="Cours de la devise") - # Mode et journal mode_reglement: int = Field( ..., ge=0, description="Code mode règlement depuis /reglements/modes" ) @@ -23,13 +21,11 @@ class ReglementFactureCreate(BaseModel): ..., min_length=1, description="Code journal depuis /journaux/tresorerie" ) - # Dates date_reglement: Optional[date] = Field( None, description="Date du règlement (défaut: aujourd'hui)" ) date_echeance: Optional[date] = Field(None, description="Date d'échéance") - # Références reference: Optional[str] = Field( "", max_length=17, description="Référence pièce règlement" ) @@ -37,7 +33,6 @@ class ReglementFactureCreate(BaseModel): "", max_length=35, description="Libellé du règlement" ) - # TVA sur encaissement tva_encaissement: Optional[bool] = Field( False, description="Appliquer TVA sur encaissement" ) @@ -81,7 +76,6 @@ class ReglementMultipleCreate(BaseModel): libelle: Optional[str] = Field("") tva_encaissement: Optional[bool] = Field(False) - # Factures spécifiques (optionnel) numeros_factures: Optional[List[str]] = Field( None, description="Si vide, règle les plus anciennes en premier" ) diff --git a/schemas/sage/sage_gateway.py b/schemas/sage/sage_gateway.py index e503641..9501129 100644 --- a/schemas/sage/sage_gateway.py +++ b/schemas/sage/sage_gateway.py @@ -10,7 +10,6 @@ class GatewayHealthStatus(str, Enum): UNKNOWN = "unknown" -# === CREATE === class SageGatewayCreate(BaseModel): name: str = Field( @@ -71,7 +70,6 @@ class SageGatewayUpdate(BaseModel): return v.rstrip("/") if v else v -# === RESPONSE === class SageGatewayResponse(BaseModel): id: str diff --git a/schemas/tiers/commercial.py b/schemas/tiers/commercial.py index 5a4685b..de74165 100644 --- a/schemas/tiers/commercial.py +++ b/schemas/tiers/commercial.py @@ -9,7 +9,6 @@ class CollaborateurBase(BaseModel): prenom: Optional[str] = Field(None, max_length=50) fonction: Optional[str] = Field(None, max_length=50) - # Adresse adresse: Optional[str] = Field(None, max_length=100) complement: Optional[str] = Field(None, max_length=100) code_postal: Optional[str] = Field(None, max_length=10) @@ -17,7 +16,6 @@ class CollaborateurBase(BaseModel): code_region: Optional[str] = Field(None, max_length=50) pays: Optional[str] = Field(None, max_length=50) - # Services service: Optional[str] = Field(None, max_length=50) vendeur: bool = Field(default=False) caissier: bool = Field(default=False) @@ -25,18 +23,15 @@ class CollaborateurBase(BaseModel): chef_ventes: bool = Field(default=False) numero_chef_ventes: Optional[int] = None - # Contact telephone: Optional[str] = Field(None, max_length=20) telecopie: Optional[str] = Field(None, max_length=20) email: Optional[EmailStr] = None tel_portable: Optional[str] = Field(None, max_length=20) - # Réseaux sociaux facebook: Optional[str] = Field(None, max_length=100) linkedin: Optional[str] = Field(None, max_length=100) skype: Optional[str] = Field(None, max_length=100) - # Autres matricule: Optional[str] = Field(None, max_length=20) sommeil: bool = Field(default=False) diff --git a/schemas/tiers/tiers.py b/schemas/tiers/tiers.py index 58166a1..7a46ef5 100644 --- a/schemas/tiers/tiers.py +++ b/schemas/tiers/tiers.py @@ -14,7 +14,6 @@ class TypeTiersInt(IntEnum): class TiersDetails(BaseModel): - # IDENTIFICATION numero: Optional[str] = Field(None, description="Code tiers (CT_Num)") intitule: Optional[str] = Field( None, description="Raison sociale ou Nom complet (CT_Intitule)" @@ -37,7 +36,6 @@ class TiersDetails(BaseModel): ) code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") - # ADRESSE contact: Optional[str] = Field( None, description="Nom du contact principal (CT_Contact)" ) @@ -50,7 +48,6 @@ class TiersDetails(BaseModel): region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)") pays: Optional[str] = Field(None, description="Pays (CT_Pays)") - # TELECOM telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") email: Optional[str] = Field(None, description="Email principal (CT_EMail)") @@ -58,13 +55,11 @@ class TiersDetails(BaseModel): facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") - # TAUX taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") - # STATISTIQUES statistique01: Optional[str] = Field( None, description="Statistique 1 (CT_Statistique01)" ) @@ -96,7 +91,6 @@ class TiersDetails(BaseModel): None, description="Statistique 10 (CT_Statistique10)" ) - # COMMERCIAL encours_autorise: Optional[float] = Field( None, description="Encours maximum autorisé (CT_Encours)" ) @@ -113,7 +107,6 @@ class TiersDetails(BaseModel): None, description="Détails du commercial/collaborateur" ) - # FACTURATION lettrage_auto: Optional[bool] = Field( None, description="Lettrage automatique (CT_Lettrage)" ) @@ -146,7 +139,6 @@ class TiersDetails(BaseModel): None, description="Bon à payer obligatoire (CT_BonAPayer)" ) - # LOGISTIQUE priorite_livraison: Optional[int] = Field( None, description="Priorité livraison (CT_PrioriteLivr)" ) @@ -160,17 +152,14 @@ class TiersDetails(BaseModel): None, description="Délai appro jours (CT_DelaiAppro)" ) - # COMMENTAIRE commentaire: Optional[str] = Field( None, description="Commentaire libre (CT_Commentaire)" ) - # ANALYTIQUE section_analytique: Optional[str] = Field( None, description="Section analytique (CA_Num)" ) - # ORGANISATION / SURVEILLANCE mode_reglement_code: Optional[int] = Field( None, description="Code mode règlement (MR_No)" ) @@ -200,7 +189,6 @@ class TiersDetails(BaseModel): None, description="Résultat financier (CT_SvResultat)" ) - # COMPTE GENERAL ET CATEGORIES compte_general: Optional[str] = Field( None, description="Compte général principal (CG_NumPrinc)" ) @@ -211,7 +199,6 @@ class TiersDetails(BaseModel): None, description="Catégorie comptable (N_CatCompta)" ) - # CONTACTS contacts: Optional[List[Contact]] = Field( default_factory=list, description="Liste des contacts du tiers" ) diff --git a/services/universign_document.py b/services/universign_document.py index fa899a9..9cb714e 100644 --- a/services/universign_document.py +++ b/services/universign_document.py @@ -38,7 +38,6 @@ class UniversignDocumentService: logger.info(f"{len(documents)} document(s) trouvé(s)") - # Log détaillé de chaque document for idx, doc in enumerate(documents): logger.debug( f" Document {idx}: id={doc.get('id')}, " @@ -64,7 +63,7 @@ class UniversignDocumentService: logger.error(f"⏱️ Timeout récupération transaction {transaction_id}") return None except Exception as e: - logger.error(f"❌ Erreur fetch documents: {e}", exc_info=True) + logger.error(f" Erreur fetch documents: {e}", exc_info=True) return None def download_signed_document( @@ -94,7 +93,6 @@ class UniversignDocumentService: f"Content-Type={content_type}, Size={content_length}" ) - # Vérification du type de contenu if ( "pdf" not in content_type.lower() and "octet-stream" not in content_type.lower() @@ -104,31 +102,30 @@ class UniversignDocumentService: f"Tentative de lecture quand même..." ) - # Lecture du contenu content = response.content if len(content) < 1024: - logger.error(f"❌ Document trop petit: {len(content)} octets") + logger.error(f" Document trop petit: {len(content)} octets") return None return content elif response.status_code == 404: logger.error( - f"❌ Document {document_id} introuvable pour transaction {transaction_id}" + f" Document {document_id} introuvable pour transaction {transaction_id}" ) return None elif response.status_code == 403: logger.error( - f"❌ Accès refusé au document {document_id}. " + f" Accès refusé au document {document_id}. " f"Vérifiez que la transaction est bien signée." ) return None else: logger.error( - f"❌ Erreur HTTP {response.status_code}: {response.text[:500]}" + f" Erreur HTTP {response.status_code}: {response.text[:500]}" ) return None @@ -136,13 +133,12 @@ class UniversignDocumentService: logger.error(f"⏱️ Timeout téléchargement document {document_id}") return None except Exception as e: - logger.error(f"❌ Erreur téléchargement: {e}", exc_info=True) + logger.error(f" Erreur téléchargement: {e}", exc_info=True) return None async def download_and_store_signed_document( self, session: AsyncSession, transaction, force: bool = False ) -> Tuple[bool, Optional[str]]: - # Vérification si déjà téléchargé if not force and transaction.signed_document_path: if os.path.exists(transaction.signed_document_path): logger.debug( @@ -153,7 +149,6 @@ class UniversignDocumentService: transaction.download_attempts += 1 try: - # ÉTAPE 1: Récupérer les documents de la transaction logger.info( f"Récupération document signé pour: {transaction.transaction_id}" ) @@ -167,13 +162,11 @@ class UniversignDocumentService: await session.commit() return False, error - # ÉTAPE 2: Récupérer le premier document (ou chercher celui qui est signé) document_id = None for doc in documents: doc_id = doc.get("id") doc_status = doc.get("status", "").lower() - # Priorité aux documents marqués comme signés/complétés if doc_status in ["signed", "completed", "closed"]: document_id = doc_id logger.info( @@ -181,34 +174,30 @@ class UniversignDocumentService: ) break - # Fallback sur le premier document si aucun n'est explicitement signé if document_id is None: document_id = doc_id if not document_id: error = "Impossible de déterminer l'ID du document à télécharger" - logger.error(f"❌ {error}") + logger.error(f" {error}") transaction.download_error = error await session.commit() return False, error - # Stocker le document_id pour référence future if hasattr(transaction, "universign_document_id"): transaction.universign_document_id = document_id - # ÉTAPE 3: Télécharger le document signé pdf_content = self.download_signed_document( transaction_id=transaction.transaction_id, document_id=document_id ) if not pdf_content: error = f"Échec téléchargement document {document_id}" - logger.error(f"❌ {error}") + logger.error(f" {error}") transaction.download_error = error await session.commit() return False, error - # ÉTAPE 4: Stocker le fichier localement filename = self._generate_filename(transaction) file_path = SIGNED_DOCS_DIR / filename @@ -217,13 +206,11 @@ class UniversignDocumentService: file_size = os.path.getsize(file_path) - # Mise à jour de la transaction transaction.signed_document_path = str(file_path) transaction.signed_document_downloaded_at = datetime.now() transaction.signed_document_size_bytes = file_size transaction.download_error = None - # Stocker aussi l'URL de téléchargement pour référence transaction.document_url = ( f"{self.api_url}/transactions/{transaction.transaction_id}" f"/documents/{document_id}/download" @@ -239,14 +226,14 @@ class UniversignDocumentService: except OSError as e: error = f"Erreur filesystem: {str(e)}" - logger.error(f"❌ {error}") + logger.error(f" {error}") transaction.download_error = error await session.commit() return False, error except Exception as e: error = f"Erreur inattendue: {str(e)}" - logger.error(f"❌ {error}", exc_info=True) + logger.error(f" {error}", exc_info=True) transaction.download_error = error await session.commit() return False, error @@ -294,7 +281,6 @@ class UniversignDocumentService: return deleted, int(size_freed_mb) - # === MÉTHODES DE DIAGNOSTIC === def diagnose_transaction(self, transaction_id: str) -> Dict: """ @@ -308,7 +294,6 @@ class UniversignDocumentService: } try: - # Test 1: Récupération de la transaction logger.info(f"Diagnostic transaction: {transaction_id}") response = requests.get( @@ -334,7 +319,6 @@ class UniversignDocumentService: "participants_count": len(data.get("participants", [])), } - # Test 2: Documents disponibles documents = data.get("documents", []) result["checks"]["documents"] = [] @@ -345,7 +329,6 @@ class UniversignDocumentService: "status": doc.get("status"), } - # Test téléchargement if doc.get("id"): download_url = ( f"{self.api_url}/transactions/{transaction_id}" diff --git a/services/universign_sync.py b/services/universign_sync.py index 28e633c..807802d 100644 --- a/services/universign_sync.py +++ b/services/universign_sync.py @@ -159,7 +159,6 @@ class UniversignSyncService: return stats - # CORRECTION 1 : process_webhook dans universign_sync.py async def process_webhook( self, session: AsyncSession, payload: Dict, transaction_id: str = None ) -> Tuple[bool, Optional[str]]: @@ -167,9 +166,7 @@ class UniversignSyncService: Traite un webhook Universign - CORRECTION : meilleure gestion des payloads """ try: - # Si transaction_id n'est pas fourni, essayer de l'extraire if not transaction_id: - # Même logique que dans universign.py if ( payload.get("type", "").startswith("transaction.") and "payload" in payload @@ -195,7 +192,6 @@ class UniversignSyncService: f"📨 Traitement webhook: transaction={transaction_id}, event={event_type}" ) - # Récupérer la transaction locale query = ( select(UniversignTransaction) .options(selectinload(UniversignTransaction.signers)) @@ -208,25 +204,20 @@ class UniversignSyncService: logger.warning(f"Transaction {transaction_id} inconnue localement") return False, "Transaction inconnue" - # Marquer comme webhook reçu transaction.webhook_received = True - # Stocker l'ancien statut pour comparaison old_status = transaction.local_status.value - # Force la synchronisation complète success, error = await self.sync_transaction( session, transaction, force=True ) - # Log du changement de statut if success and transaction.local_status.value != old_status: logger.info( f"Webhook traité: {transaction_id} | " f"{old_status} → {transaction.local_status.value}" ) - # Enregistrer le log du webhook await self._log_sync_attempt( session=session, transaction=transaction, @@ -248,7 +239,6 @@ class UniversignSyncService: logger.error(f"💥 Erreur traitement webhook: {e}", exc_info=True) return False, str(e) - # CORRECTION 2 : _sync_signers - Ne pas écraser les signers existants async def _sync_signers( self, session: AsyncSession, @@ -271,7 +261,6 @@ class UniversignSyncService: logger.warning(f"Signataire sans email à l'index {idx}, ignoré") continue - # PROTECTION : gérer les statuts inconnus raw_status = signer_data.get("status") or signer_data.get( "state", "waiting" ) @@ -302,7 +291,6 @@ class UniversignSyncService: if signer_data.get("name") and not signer.name: signer.name = signer_data.get("name") else: - # Nouveau signer avec gestion d'erreur intégrée try: signer = UniversignSigner( id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}", @@ -330,7 +318,6 @@ class UniversignSyncService: ): import json - # Si statut final et pas de force, skip if is_final_status(transaction.local_status.value) and not force: logger.debug( f"⏭️ Skip {transaction.transaction_id}: statut final " @@ -340,14 +327,13 @@ class UniversignSyncService: await session.commit() return True, None - # Récupération du statut distant logger.info(f"Synchronisation: {transaction.transaction_id}") result = self.fetch_transaction_status(transaction.transaction_id) if not result: error = "Échec récupération données Universign" - logger.error(f"❌ {error}: {transaction.transaction_id}") + logger.error(f" {error}: {transaction.transaction_id}") transaction.sync_attempts += 1 transaction.sync_error = error await self._log_sync_attempt(session, transaction, "polling", False, error) @@ -360,7 +346,6 @@ class UniversignSyncService: logger.info(f"📊 Statut Universign brut: {universign_status_raw}") - # Convertir le statut new_local_status = map_universign_to_local(universign_status_raw) previous_local_status = transaction.local_status.value @@ -369,7 +354,6 @@ class UniversignSyncService: f"{new_local_status} (Local) | Actuel: {previous_local_status}" ) - # Vérifier la transition if not is_transition_allowed(previous_local_status, new_local_status): logger.warning( f"Transition refusée: {previous_local_status} → {new_local_status}" @@ -386,7 +370,6 @@ class UniversignSyncService: f"🔔 CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" ) - # Mise à jour du statut Universign brut try: transaction.universign_status = UniversignTransactionStatus( universign_status_raw @@ -404,11 +387,9 @@ class UniversignSyncService: else: transaction.universign_status = UniversignTransactionStatus.STARTED - # Mise à jour du statut local transaction.local_status = LocalDocumentStatus(new_local_status) transaction.universign_status_updated_at = datetime.now() - # Mise à jour des dates if new_local_status == "EN_COURS" and not transaction.sent_at: transaction.sent_at = datetime.now() logger.info("📅 Date d'envoi mise à jour") @@ -419,15 +400,12 @@ class UniversignSyncService: if new_local_status == "REFUSE" and not transaction.refused_at: transaction.refused_at = datetime.now() - logger.info("❌ Date de refus mise à jour") + logger.info(" Date de refus mise à jour") if new_local_status == "EXPIRE" and not transaction.expired_at: transaction.expired_at = datetime.now() logger.info("⏰ Date d'expiration mise à jour") - # === SECTION CORRIGÉE: Gestion des documents === - # Ne plus chercher document_url dans la réponse (elle n'existe pas!) - # Le téléchargement se fait via le service document qui utilise le bon endpoint documents = universign_data.get("documents", []) if documents: @@ -437,7 +415,6 @@ class UniversignSyncService: f"status={first_doc.get('status')}" ) - # Téléchargement automatique du document signé if new_local_status == "SIGNE" and not transaction.signed_document_path: logger.info("Déclenchement téléchargement document signé...") @@ -456,20 +433,16 @@ class UniversignSyncService: except Exception as e: logger.error( - f"❌ Erreur téléchargement document: {e}", exc_info=True + f" Erreur téléchargement document: {e}", exc_info=True ) - # === FIN SECTION CORRIGÉE === - # Synchroniser les signataires await self._sync_signers(session, transaction, universign_data) - # Mise à jour des métadonnées de sync transaction.last_synced_at = datetime.now() transaction.sync_attempts += 1 transaction.needs_sync = not is_final_status(new_local_status) transaction.sync_error = None - # Log de la tentative await self._log_sync_attempt( session=session, transaction=transaction, @@ -491,7 +464,6 @@ class UniversignSyncService: await session.commit() - # Exécuter les actions post-changement if status_changed: logger.info(f"🎬 Exécution actions pour statut: {new_local_status}") await self._execute_status_actions( @@ -507,7 +479,7 @@ class UniversignSyncService: except Exception as e: error_msg = f"Erreur lors de la synchronisation: {str(e)}" - logger.error(f"❌ {error_msg}", exc_info=True) + logger.error(f" {error_msg}", exc_info=True) transaction.sync_error = error_msg[:1000] transaction.sync_attempts += 1 @@ -519,20 +491,16 @@ class UniversignSyncService: return False, error_msg - # CORRECTION 3 : Amélioration du logging dans sync_transaction async def _sync_transaction_documents_corrected( self, session, transaction, universign_data: dict, new_local_status: str ): - # Récupérer et stocker les infos documents documents = universign_data.get("documents", []) if documents: - # Stocker le premier document_id pour référence first_doc = documents[0] first_doc_id = first_doc.get("id") if first_doc_id: - # Stocker l'ID du document (si le champ existe dans le modèle) if hasattr(transaction, "universign_document_id"): transaction.universign_document_id = first_doc_id @@ -543,7 +511,6 @@ class UniversignSyncService: else: logger.debug("Aucun document dans la réponse Universign") - # Téléchargement automatique si signé if new_local_status == "SIGNE": if not transaction.signed_document_path: logger.info("Déclenchement téléchargement document signé...") @@ -563,7 +530,7 @@ class UniversignSyncService: except Exception as e: logger.error( - f"❌ Erreur téléchargement document: {e}", exc_info=True + f" Erreur téléchargement document: {e}", exc_info=True ) else: logger.debug( diff --git a/utils/generic_functions.py b/utils/generic_functions.py index 41b734b..4cce52f 100644 --- a/utils/generic_functions.py +++ b/utils/generic_functions.py @@ -290,15 +290,11 @@ def _preparer_lignes_document(lignes: List) -> List[Dict]: UNIVERSIGN_TO_LOCAL: Dict[str, str] = { - # États initiaux "draft": "EN_ATTENTE", "ready": "EN_ATTENTE", - # En cours "started": "EN_COURS", - # États finaux (succès) "completed": "SIGNE", "closed": "SIGNE", - # États finaux (échec) "refused": "REFUSE", "expired": "EXPIRE", "canceled": "REFUSE", @@ -441,7 +437,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = { "ERREUR": { "fr": "Erreur technique", "en": "Technical error", - "icon": "", + "icon": "⚠️", "color": "red", }, } From 9f6c1de8efcb808397f4d149293ea8bdd97d86df Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 19 Jan 2026 20:35:03 +0300 Subject: [PATCH 02/30] feat(api-keys): implement api key management system --- middleware/security.py | 296 +++++++++++++++++++++++++++++++++++++++++ routes/api_keys.py | 180 +++++++++++++++++++++++++ schemas/api_key.py | 77 +++++++++++ 3 files changed, 553 insertions(+) create mode 100644 middleware/security.py create mode 100644 routes/api_keys.py create mode 100644 schemas/api_key.py diff --git a/middleware/security.py b/middleware/security.py new file mode 100644 index 0000000..17186a0 --- /dev/null +++ b/middleware/security.py @@ -0,0 +1,296 @@ +import secrets +from fastapi import Request, HTTPException, status +from fastapi.responses import JSONResponse +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from sqlalchemy import select +from typing import Optional +from datetime import datetime +import logging + +from database import get_session +from database.models.api_key import SwaggerUser +from security.auth import verify_password + +logger = logging.getLogger(__name__) + +# === Configuration Swagger === +security = HTTPBasic() + + +async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: + """ + VERSION 2: Vérification des identifiants Swagger via base de données + + ✅ Plus sécurisé + ✅ Gestion centralisée + ✅ Tracking des connexions + """ + username = credentials.username + password = credentials.password + + try: + # Utiliser get_session de manière asynchrone + async for session in get_session(): + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + swagger_user = result.scalar_one_or_none() + + if swagger_user and swagger_user.is_active: + if verify_password(password, swagger_user.hashed_password): + # Mise à jour de la dernière connexion + swagger_user.last_login = datetime.now() + await session.commit() + + logger.info(f"✅ Accès Swagger autorisé (DB): {username}") + return True + + logger.warning(f"⚠️ Tentative d'accès Swagger refusée: {username}") + return False + + except Exception as e: + logger.error(f"❌ Erreur vérification Swagger credentials: {e}") + return False + + +class SwaggerAuthMiddleware: + """ + Middleware pour protéger les endpoints de documentation + (/docs, /redoc, /openapi.json) + + VERSION 2: Avec vérification en base de données + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive=receive) + path = request.url.path + + # Endpoints à protéger + protected_paths = ["/docs", "/redoc", "/openapi.json"] + + if any(path.startswith(protected_path) for protected_path in protected_paths): + # Vérification de l'authentification Basic + auth_header = request.headers.get("Authorization") + + if not auth_header or not auth_header.startswith("Basic "): + # Demande d'authentification + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise pour accéder à la documentation" + }, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + # Extraction des credentials + try: + import base64 + + encoded_credentials = auth_header.split(" ")[1] + decoded_credentials = base64.b64decode(encoded_credentials).decode( + "utf-8" + ) + username, password = decoded_credentials.split(":", 1) + + credentials = HTTPBasicCredentials(username=username, password=password) + + # Vérification via DB + if not await verify_swagger_credentials(credentials): + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Identifiants invalides"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + except Exception as e: + logger.error(f"❌ Erreur parsing auth header: {e}") + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Format d'authentification invalide"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + # Si tout est OK, continuer + await self.app(scope, receive, send) + + +class ApiKeyMiddleware: + """ + Middleware HYBRIDE pour vérifier les clés API sur les endpoints publics + + ✅ Accepte X-API-Key OU Authorization: Bearer (JWT) + ✅ Les deux méthodes sont équivalentes + ✅ Les endpoints /auth/* restent accessibles sans auth + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive=receive) + path = request.url.path + + # ============================================ + # ENDPOINTS EXCLUS (accessibles sans auth) + # ============================================ + excluded_paths = [ + "/docs", + "/redoc", + "/openapi.json", + "/health", + "/", + # === ROUTES AUTH (toujours publiques) === + "/api/v1/auth/login", + "/api/v1/auth/register", + "/api/v1/auth/verify-email", + "/api/v1/auth/reset-password", + "/api/v1/auth/request-reset", + "/api/v1/auth/refresh", + ] + + if any(path.startswith(excluded_path) for excluded_path in excluded_paths): + await self.app(scope, receive, send) + return + + # ============================================ + # VÉRIFICATION HYBRIDE: API Key OU JWT + # ============================================ + + # Option 1: Vérifier si JWT présent + auth_header = request.headers.get("Authorization") + has_jwt = auth_header and auth_header.startswith("Bearer ") + + # Option 2: Vérifier si API Key présente + api_key = request.headers.get("X-API-Key") + has_api_key = api_key is not None + + # ============================================ + # LOGIQUE HYBRIDE + # ============================================ + + if has_jwt: + # JWT présent → laisser passer, sera validé par les dependencies FastAPI + logger.debug(f"🔑 JWT détecté pour {path}") + await self.app(scope, receive, send) + return + + elif has_api_key: + # API Key présente → valider la clé + logger.debug(f"🔑 API Key détectée pour {path}") + + from services.api_key import ApiKeyService + + try: + async for session in get_session(): + api_key_service = ApiKeyService(session) + api_key_obj = await api_key_service.verify_api_key(api_key) + + if not api_key_obj: + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Clé API invalide ou expirée", + "hint": "Utilisez X-API-Key: sdk_live_xxx ou Authorization: Bearer ", + }, + ) + await response(scope, receive, send) + return + + # Vérification du rate limit + is_allowed, rate_info = await api_key_service.check_rate_limit( + api_key_obj + ) + if not is_allowed: + response = JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content={"detail": "Rate limit dépassé"}, + headers={ + "X-RateLimit-Limit": str(rate_info["limit"]), + "X-RateLimit-Remaining": "0", + }, + ) + await response(scope, receive, send) + return + + # Vérification de l'accès à l'endpoint + has_access = await api_key_service.check_endpoint_access( + api_key_obj, path + ) + if not has_access: + response = JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={ + "detail": "Accès non autorisé à cet endpoint", + "endpoint": path, + "api_key": api_key_obj.key_prefix + "...", + }, + ) + await response(scope, receive, send) + return + + # ✅ Clé valide → ajouter les infos à la requête + request.state.api_key = api_key_obj + request.state.authenticated_via = "api_key" + + logger.info(f"✅ API Key valide: {api_key_obj.name} → {path}") + + # Continuer la requête + await self.app(scope, receive, send) + return + + except Exception as e: + logger.error(f"❌ Erreur validation API Key: {e}", exc_info=True) + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "detail": "Erreur interne lors de la validation de la clé" + }, + ) + await response(scope, receive, send) + return + + else: + # ❌ Ni JWT ni API Key + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise", + "hint": "Utilisez soit 'X-API-Key: sdk_live_xxx' soit 'Authorization: Bearer '", + }, + headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, + ) + await response(scope, receive, send) + return + + +# === Fonction helper pour récupérer l'API Key depuis la requête === +def get_api_key_from_request(request: Request) -> Optional: + """Récupère l'objet ApiKey depuis la requête si présent""" + return getattr(request.state, "api_key", None) + + +def get_auth_method(request: Request) -> str: + """ + Retourne la méthode d'authentification utilisée + + Returns: + "jwt" | "api_key" | "none" + """ + return getattr(request.state, "authenticated_via", "none") diff --git a/routes/api_keys.py b/routes/api_keys.py new file mode 100644 index 0000000..8c8f6db --- /dev/null +++ b/routes/api_keys.py @@ -0,0 +1,180 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +import logging + +from database import get_session, User +from core.dependencies import get_current_user, require_role +from services.api_key import ApiKeyService, api_key_to_response +from schemas.api_key import ( + ApiKeyCreate, + ApiKeyCreatedResponse, + ApiKeyResponse, + ApiKeyList, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api-keys", tags=["API Keys Management"]) + + +@router.post( + "", + response_model=ApiKeyCreatedResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_role("admin", "super_admin"))], +) +async def create_api_key( + data: ApiKeyCreate, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """ + 🔑 Créer une nouvelle clé API + + **Réservé aux admins** + + ⚠️ La clé en clair ne sera affichée qu'une seule fois ! + """ + service = ApiKeyService(session) + + api_key_obj, api_key_plain = await service.create_api_key( + name=data.name, + description=data.description, + created_by=user.email, + user_id=user.id, + expires_in_days=data.expires_in_days, + rate_limit_per_minute=data.rate_limit_per_minute, + allowed_endpoints=data.allowed_endpoints, + ) + + logger.info(f"✅ Clé API créée par {user.email}: {data.name}") + + response_data = api_key_to_response(api_key_obj) + response_data["api_key"] = api_key_plain + + return ApiKeyCreatedResponse(**response_data) + + +@router.get("", response_model=ApiKeyList) +async def list_api_keys( + include_revoked: bool = Query(False, description="Inclure les clés révoquées"), + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """ + 📋 Lister toutes les clés API + + **Réservé aux admins** - liste toutes les clés + **Utilisateurs standards** - liste uniquement leurs clés + """ + service = ApiKeyService(session) + + # Si admin, voir toutes les clés, sinon seulement les siennes + user_id = None if user.role in ["admin", "super_admin"] else user.id + + keys = await service.list_api_keys(include_revoked=include_revoked, user_id=user_id) + + items = [ApiKeyResponse(**api_key_to_response(k)) for k in keys] + + return ApiKeyList(total=len(items), items=items) + + +@router.get("/{key_id}", response_model=ApiKeyResponse) +async def get_api_key( + key_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """🔍 Récupérer une clé API par son ID""" + service = ApiKeyService(session) + + api_key_obj = await service.get_by_id(key_id) + + if not api_key_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Clé API {key_id} introuvable", + ) + + # Vérification des permissions + if user.role not in ["admin", "super_admin"]: + if api_key_obj.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Accès refusé à cette clé", + ) + + return ApiKeyResponse(**api_key_to_response(api_key_obj)) + + +@router.delete("/{key_id}", status_code=status.HTTP_200_OK) +async def revoke_api_key( + key_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """ + 🚫 Révoquer une clé API + + **Action irréversible** - la clé sera désactivée définitivement + """ + service = ApiKeyService(session) + + api_key_obj = await service.get_by_id(key_id) + + if not api_key_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Clé API {key_id} introuvable", + ) + + # Vérification des permissions + if user.role not in ["admin", "super_admin"]: + if api_key_obj.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Accès refusé à cette clé", + ) + + success = await service.revoke_api_key(key_id) + + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erreur lors de la révocation", + ) + + logger.info(f"🚫 Clé API révoquée par {user.email}: {api_key_obj.name}") + + return { + "success": True, + "message": f"Clé API '{api_key_obj.name}' révoquée avec succès", + } + + +@router.post("/verify", status_code=status.HTTP_200_OK) +async def verify_api_key_endpoint( + api_key: str = Query(..., description="Clé API à vérifier"), + session: AsyncSession = Depends(get_session), +): + """ + ✅ Vérifier la validité d'une clé API + + **Endpoint public** - permet de tester une clé + """ + service = ApiKeyService(session) + + api_key_obj = await service.verify_api_key(api_key) + + if not api_key_obj: + return { + "valid": False, + "message": "Clé API invalide, expirée ou révoquée", + } + + return { + "valid": True, + "message": "Clé API valide", + "key_name": api_key_obj.name, + "rate_limit": api_key_obj.rate_limit_per_minute, + "expires_at": api_key_obj.expires_at, + } diff --git a/schemas/api_key.py b/schemas/api_key.py new file mode 100644 index 0000000..6a2d659 --- /dev/null +++ b/schemas/api_key.py @@ -0,0 +1,77 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + + +class ApiKeyCreate(BaseModel): + """Schema pour créer une clé API""" + + name: str = Field(..., min_length=3, max_length=255, description="Nom de la clé") + description: Optional[str] = Field(None, description="Description de l'usage") + expires_in_days: Optional[int] = Field( + None, ge=1, le=3650, description="Expiration en jours (max 10 ans)" + ) + rate_limit_per_minute: int = Field( + 60, ge=1, le=1000, description="Limite de requêtes par minute" + ) + allowed_endpoints: Optional[List[str]] = Field( + None, description="Endpoints autorisés ([] = tous, ['/clients*'] = wildcard)" + ) + + +class ApiKeyResponse(BaseModel): + """Schema de réponse pour une clé API""" + + id: str + name: str + description: Optional[str] + key_prefix: str + is_active: bool + is_expired: bool + rate_limit_per_minute: int + allowed_endpoints: Optional[List[str]] + total_requests: int + last_used_at: Optional[datetime] + created_at: datetime + expires_at: Optional[datetime] + revoked_at: Optional[datetime] + created_by: str + + +class ApiKeyCreatedResponse(ApiKeyResponse): + """Schema de réponse après création (inclut la clé en clair)""" + + api_key: str = Field( + ..., description="⚠️ Clé API en clair - à sauvegarder immédiatement" + ) + + +class ApiKeyList(BaseModel): + """Liste de clés API""" + + total: int + items: List[ApiKeyResponse] + + +class SwaggerUserCreate(BaseModel): + """Schema pour créer un utilisateur Swagger""" + + username: str = Field(..., min_length=3, max_length=100) + password: str = Field(..., min_length=8) + full_name: Optional[str] = None + email: Optional[str] = None + + +class SwaggerUserResponse(BaseModel): + """Schema de réponse pour un utilisateur Swagger""" + + id: str + username: str + full_name: Optional[str] + email: Optional[str] + is_active: bool + created_at: datetime + last_login: Optional[datetime] + + class Config: + from_attributes = True From a10fda072cf489405d9d0d8f9870947ff75d1335 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 19 Jan 2026 20:38:01 +0300 Subject: [PATCH 03/30] feat(security): implement API key authentication system --- .gitignore | 4 +- config/cors_config.py | 321 ++++++++++++++++++++++++++++++++ core/dependencies.py | 239 ++++++++++++++++++------ scripts/manage_security.py | 290 +++++++++++++++++++++++++++++ scripts/test_security.py | 369 +++++++++++++++++++++++++++++++++++++ services/api_key.py | 233 +++++++++++++++++++++++ 6 files changed, 1395 insertions(+), 61 deletions(-) create mode 100644 config/cors_config.py create mode 100644 scripts/manage_security.py create mode 100644 scripts/test_security.py create mode 100644 services/api_key.py diff --git a/.gitignore b/.gitignore index 7d75fa8..ed8f8ab 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,6 @@ tools/ .env.staging .env.production -.trunk \ No newline at end of file +.trunk + +*clean*.py \ No newline at end of file diff --git a/config/cors_config.py b/config/cors_config.py new file mode 100644 index 0000000..6ee880e --- /dev/null +++ b/config/cors_config.py @@ -0,0 +1,321 @@ +""" +CONFIGURATION CORS POUR API AVEC CLÉS API MULTIPLES + +Problématique: +- Les clés API seront utilisées depuis de nombreux domaines/IPs différents +- Impossible de lister tous les origins autorisés à l'avance +- Solution: CORS permissif mais sécurisé par les clés API + +Stratégies: +1. CORS ouvert avec validation par clé API (RECOMMANDÉ) +2. CORS dynamique basé sur whitelist +3. CORS avec wildcard et credentials=False +""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from typing import List +import os +import logging + +logger = logging.getLogger(__name__) + + +# ============================================ +# STRATÉGIE 1: CORS OUVERT + VALIDATION API KEY +# ============================================ +# ✅ RECOMMANDÉ pour les API publiques avec clés +# +# Principe: +# - Accepter toutes les origines (allow_origins=["*"]) +# - La sécurité est assurée par la validation des clés API +# - Les clés API protègent l'accès, pas le CORS + + +def configure_cors_open(app: FastAPI): + """ + Configuration CORS ouverte (RECOMMANDÉE) + + ✅ Accepte toutes les origines + ✅ Sécurité assurée par les clés API + ✅ Simplifie l'utilisation pour les clients + + ⚠️ Attention: credentials=False obligatoire avec allow_origins=["*"] + """ + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Accepte toutes les origines + allow_credentials=False, # ⚠️ Obligatoire avec "*" + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["*"], # Accepte tous les headers (dont X-API-Key) + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, # Cache preflight requests pendant 1h + ) + + logger.info("🌐 CORS configuré: Mode OUVERT (sécurisé par API Keys)") + logger.info(" - Origins: * (toutes)") + logger.info(" - Headers: * (dont X-API-Key)") + logger.info(" - Credentials: False") + + +# ============================================ +# STRATÉGIE 2: CORS AVEC WHITELIST DYNAMIQUE +# ============================================ +# 🔶 Pour environnements contrôlés +# +# Principe: +# - Lister explicitement les domaines autorisés +# - Peut inclure des patterns wildcards +# - Credentials possible (cookies, etc.) + + +def configure_cors_whitelist(app: FastAPI): + """ + Configuration CORS avec whitelist (MODE CONTRÔLÉ) + + ✅ Meilleur contrôle des origines + ✅ Credentials possible + ❌ Nécessite maintenance de la liste + + À utiliser si vous connaissez tous les domaines clients + """ + + # Charger depuis .env ou config + allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "") + + if allowed_origins_str: + allowed_origins = [ + origin.strip() + for origin in allowed_origins_str.split(",") + if origin.strip() + ] + else: + # Valeurs par défaut + allowed_origins = [ + "http://localhost:3000", # Frontend dev React/Vue + "http://localhost:5173", # Vite dev + "http://localhost:8080", # Frontend dev alternatif + "https://app.votre-domaine.com", + "https://admin.votre-domaine.com", + # Ajouter vos domaines de production + ] + + app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, + allow_credentials=True, # ✅ Possible avec liste explicite + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info("🌐 CORS configuré: Mode WHITELIST") + logger.info(f" - Origins autorisées: {len(allowed_origins)}") + for origin in allowed_origins: + logger.info(f" • {origin}") + + +# ============================================ +# STRATÉGIE 3: CORS AVEC REGEX (AVANCÉ) +# ============================================ +# 🔶 Pour patterns complexes +# +# Exemple: Autoriser tous les sous-domaines *.votre-domaine.com + + +def configure_cors_regex(app: FastAPI): + """ + Configuration CORS avec patterns regex (AVANCÉ) + + ✅ Flexible pour sous-domaines + ✅ Supporte patterns complexes + ❌ Plus complexe à configurer + + Utilise allow_origin_regex au lieu de allow_origins + """ + + # Pattern regex pour autoriser tous les sous-domaines + origin_regex = r"https://.*\.votre-domaine\.com" + + app.add_middleware( + CORSMiddleware, + allow_origin_regex=origin_regex, # ⚠️ Utilise regex au lieu de liste + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info("🌐 CORS configuré: Mode REGEX") + logger.info(f" - Pattern: {origin_regex}") + + +# ============================================ +# STRATÉGIE 4: CORS HYBRIDE (PRODUCTION) +# ============================================ +# ✅ RECOMMANDÉ pour production +# +# Principe: +# - Whitelist pour les domaines connus (credentials=True) +# - Fallback sur "*" pour le reste (credentials=False) + + +def configure_cors_hybrid(app: FastAPI): + """ + Configuration CORS hybride (PRODUCTION) + + ✅ Meilleur des deux mondes + ✅ Whitelist pour domaines connus + ✅ Fallback ouvert pour API Keys externes + + Note: Nécessite un middleware custom pour gérer les deux modes + """ + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.responses import Response + + class HybridCORSMiddleware(BaseHTTPMiddleware): + def __init__(self, app, known_origins: List[str]): + super().__init__(app) + self.known_origins = set(known_origins) + + async def dispatch(self, request, call_next): + origin = request.headers.get("origin") + + # Si origin connue → CORS strict avec credentials + if origin in self.known_origins: + response = await call_next(request) + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, PATCH, OPTIONS" + ) + response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization, X-API-Key" + ) + return response + + # Sinon → CORS ouvert sans credentials + response = await call_next(request) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, PATCH, OPTIONS" + ) + response.headers["Access-Control-Allow-Headers"] = "*" + return response + + # Domaines connus (whitelist) + known_origins = [ + "https://app.votre-domaine.com", + "https://admin.votre-domaine.com", + "http://localhost:3000", + "http://localhost:5173", + ] + + app.add_middleware(HybridCORSMiddleware, known_origins=known_origins) + + logger.info("🌐 CORS configuré: Mode HYBRIDE") + logger.info(f" - Whitelist: {len(known_origins)} domaines") + logger.info(" - Fallback: * (ouvert)") + + +# ============================================ +# FONCTION PRINCIPALE +# ============================================ + + +def setup_cors(app: FastAPI, mode: str = "open"): + """ + Configure CORS selon le mode choisi + + Args: + app: Instance FastAPI + mode: "open" | "whitelist" | "regex" | "hybrid" + + Recommandations: + - Development: "open" + - Production (API publique): "open" ou "hybrid" + - Production (API interne): "whitelist" + """ + + if mode == "open": + configure_cors_open(app) + elif mode == "whitelist": + configure_cors_whitelist(app) + elif mode == "regex": + configure_cors_regex(app) + elif mode == "hybrid": + configure_cors_hybrid(app) + else: + logger.warning( + f"⚠️ Mode CORS inconnu: {mode}. Utilisation de 'open' par défaut." + ) + configure_cors_open(app) + + +# ============================================ +# EXEMPLE D'UTILISATION DANS api.py +# ============================================ + +""" +# Dans api.py + +from config.cors_config import setup_cors + +app = FastAPI(...) + +# DÉVELOPPEMENT +setup_cors(app, mode="open") + +# PRODUCTION (API publique avec clés) +setup_cors(app, mode="hybrid") + +# PRODUCTION (API interne uniquement) +setup_cors(app, mode="whitelist") +""" + + +# ============================================ +# VARIABLES D'ENVIRONNEMENT (.env) +# ============================================ + +""" +# Pour mode "whitelist" +CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com,http://localhost:3000 + +# Pour mode "regex" +CORS_ORIGIN_REGEX=https://.*\.example\.com + +# Choisir le mode +CORS_MODE=open +""" + + +# ============================================ +# FAQ CORS + API KEYS +# ============================================ + +""" +Q: Pourquoi allow_origins=["*"] est sécurisé avec des API Keys ? +R: Les clés API protègent l'accès aux données. CORS empêche seulement les + navigateurs web de faire des requêtes cross-origin. Un attaquant peut + contourner CORS facilement (curl, postman), donc la vraie sécurité vient + de la validation des clés API, pas du CORS. + +Q: Pourquoi credentials=False avec allow_origins=["*"] ? +R: C'est une restriction du navigateur (spec CORS). Vous ne pouvez pas avoir + credentials=True ET origins=["*"] en même temps. + +Q: Mes clients utilisent des IPs dynamiques, que faire ? +R: Utilisez mode "open". Les clés API sont justement faites pour ça - + permettre l'accès depuis n'importe quelle origine, de manière sécurisée. + +Q: Je veux quand même utiliser des cookies/sessions ? +R: Utilisez mode "whitelist" ou "hybrid" pour les domaines qui ont besoin + de credentials, et laissez les autres utiliser X-API-Key sans credentials. + +Q: Comment tester CORS localement ? +R: Créez un simple fichier HTML avec fetch() et ouvrez-le en file:// + Ou utilisez un serveur local (python -m http.server) +""" diff --git a/core/dependencies.py b/core/dependencies.py index 039081c..6782e98 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -1,4 +1,4 @@ -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -6,89 +6,208 @@ from database import get_session, User from security.auth import decode_token from typing import Optional from datetime import datetime +import logging -security = HTTPBearer() +logger = logging.getLogger(__name__) + +security = HTTPBearer(auto_error=False) # ⚠️ auto_error=False pour gérer manuellement -async def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security), +async def get_current_user_hybrid( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), session: AsyncSession = Depends(get_session), ) -> User: - token = credentials.credentials + """ + VERSION HYBRIDE: Accepte JWT OU API Key - payload = decode_token(token) - if not payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token invalide ou expiré", - headers={"WWW-Authenticate": "Bearer"}, + Priorité: + 1. JWT (Authorization: Bearer) + 2. API Key (déjà validée par middleware) + + Si API Key utilisée, retourne un "user virtuel" basé sur la clé + """ + + # ============================================ + # OPTION 1: JWT (comportement standard) + # ============================================ + if credentials and credentials.credentials: + token = credentials.credentials + + payload = decode_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalide ou expiré", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Type de token incorrect", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id: str = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token malformé", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Utilisateur introuvable", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" + ) + + if not user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email non vérifié. Consultez votre boîte de réception.", + ) + + if user.locked_until and user.locked_until > datetime.now(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Compte temporairement verrouillé suite à trop de tentatives échouées", + ) + + logger.debug(f"🔐 Authentifié via JWT: {user.email}") + return user + + # ============================================ + # OPTION 2: API Key (validée par middleware) + # ============================================ + api_key_obj = getattr(request.state, "api_key", None) + + if api_key_obj: + # Créer un "user virtuel" basé sur la clé API + # Cela permet aux routes existantes de fonctionner sans modification + + # Si la clé est associée à un vrai user, le récupérer + if api_key_obj.user_id: + result = await session.execute( + select(User).where(User.id == api_key_obj.user_id) + ) + user = result.scalar_one_or_none() + + if user: + logger.debug( + f"🔑 Authentifié via API Key ({api_key_obj.name}) → User: {user.email}" + ) + return user + + # Sinon, créer un user virtuel (pour les clés API sans user associé) + from database import User as UserModel + + virtual_user = UserModel( + id=f"api_key_{api_key_obj.id}", + email=f"{api_key_obj.name.lower().replace(' ', '_')}@api-key.local", + nom="API Key", + prenom=api_key_obj.name, + role="api_client", # Rôle spécial pour les API keys + is_verified=True, + is_active=True, ) - if payload.get("type") != "access": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Type de token incorrect", - headers={"WWW-Authenticate": "Bearer"}, - ) + # Marquer que c'est un user virtuel + virtual_user._is_api_key_user = True + virtual_user._api_key_obj = api_key_obj - user_id: str = payload.get("sub") - if not user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token malformé", - headers={"WWW-Authenticate": "Bearer"}, - ) + logger.debug(f"🔑 Authentifié via API Key: {api_key_obj.name} (user virtuel)") + return virtual_user - result = await session.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Utilisateur introuvable", - headers={"WWW-Authenticate": "Bearer"}, - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" - ) - - if not user.is_verified: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception.", - ) - - if user.locked_until and user.locked_until > datetime.now(): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé suite à trop de tentatives échouées", - ) - - return user + # ============================================ + # AUCUNE AUTHENTIFICATION + # ============================================ + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentification requise (JWT ou API Key)", + headers={"WWW-Authenticate": "Bearer"}, + ) -async def get_current_user_optional( +async def get_current_user_optional_hybrid( + request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), session: AsyncSession = Depends(get_session), ) -> Optional[User]: - if not credentials: - return None - + """Version optionnelle de get_current_user_hybrid (ne lève pas d'erreur)""" try: - return await get_current_user(credentials, session) + return await get_current_user_hybrid(request, credentials, session) except HTTPException: return None -def require_role(*allowed_roles: str): - async def role_checker(user: User = Depends(get_current_user)) -> User: - if user.role not in allowed_roles: +def require_role_hybrid(*allowed_roles: str): + """ + VERSION HYBRIDE: Vérification de rôle compatible avec API Keys + + Notes: + - Les users via JWT ont leur vrai rôle + - Les users via API Key ont le rôle "api_client" par défaut + - Vous pouvez autoriser "api_client" dans allowed_roles pour permettre l'accès aux API Keys + """ + + async def role_checker( + request: Request, user: User = Depends(get_current_user_hybrid) + ) -> User: + # Vérifier si c'est un user API Key + is_api_key_user = getattr(user, "_is_api_key_user", False) + + if is_api_key_user: + # Pour les API Keys, vérifier si "api_client" est autorisé + if "api_client" not in allowed_roles and "*" not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}. API Keys non autorisées.", + ) + logger.debug(f"✅ API Key autorisée pour cette route") + return user + + # Pour les vrais users, vérification standard + if user.role not in allowed_roles and "*" not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}", ) + return user return role_checker + + +# ============================================ +# HELPERS +# ============================================ + + +def is_api_key_user(user: User) -> bool: + """Vérifie si l'utilisateur est authentifié via API Key""" + return getattr(user, "_is_api_key_user", False) + + +def get_api_key_from_user(user: User): + """Récupère l'objet API Key depuis un user virtuel""" + return getattr(user, "_api_key_obj", None) + + +# ============================================ +# RÉTROCOMPATIBILITÉ +# ============================================ + +# Alias pour garder la compatibilité avec le code existant +get_current_user = get_current_user_hybrid +get_current_user_optional = get_current_user_optional_hybrid diff --git a/scripts/manage_security.py b/scripts/manage_security.py new file mode 100644 index 0000000..35c0cff --- /dev/null +++ b/scripts/manage_security.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +Script CLI pour gérer les clés API et utilisateurs Swagger + +Usage: + python manage_security.py swagger add + python manage_security.py swagger list + python manage_security.py swagger delete + + python manage_security.py apikey create [--days 365] [--rate-limit 60] + python manage_security.py apikey list + python manage_security.py apikey revoke + python manage_security.py apikey verify +""" + +import asyncio +import sys +from pathlib import Path +import argparse + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from database import get_session +from database.models.api_key import SwaggerUser +from services.api_key import ApiKeyService +from security.auth import hash_password, verify_password +from sqlalchemy import select +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================ +# GESTION DES UTILISATEURS SWAGGER +# ============================================ + +async def add_swagger_user(username: str, password: str, full_name: str = None): + """Ajouter un utilisateur Swagger""" + async with get_session() as session: + # Vérifier si existe + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + existing = result.scalar_one_or_none() + + if existing: + logger.error(f"❌ L'utilisateur {username} existe déjà") + return + + # Créer + user = SwaggerUser( + username=username, + hashed_password=hash_password(password), + full_name=full_name or username, + is_active=True, + ) + + session.add(user) + await session.commit() + + logger.info(f"✅ Utilisateur Swagger créé: {username}") + print(f"\n✅ Utilisateur créé avec succès") + print(f" Username: {username}") + print(f" Accès: https://votre-serveur/docs") + + +async def list_swagger_users(): + """Lister les utilisateurs Swagger""" + async with get_session() as session: + result = await session.execute(select(SwaggerUser)) + users = result.scalars().all() + + if not users: + print("Aucun utilisateur Swagger trouvé") + return + + print(f"\n📋 {len(users)} utilisateur(s) Swagger:\n") + for user in users: + status = "✅ Actif" if user.is_active else "❌ Inactif" + print(f" • {user.username:<20} {status}") + if user.full_name: + print(f" Nom: {user.full_name}") + if user.last_login: + print(f" Dernière connexion: {user.last_login}") + print() + + +async def delete_swagger_user(username: str): + """Supprimer un utilisateur Swagger""" + async with get_session() as session: + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + user = result.scalar_one_or_none() + + if not user: + logger.error(f"❌ Utilisateur {username} introuvable") + return + + await session.delete(user) + await session.commit() + + logger.info(f"🗑️ Utilisateur supprimé: {username}") + + +# ============================================ +# GESTION DES CLÉS API +# ============================================ + +async def create_api_key( + name: str, + description: str = None, + expires_in_days: int = 365, + rate_limit: int = 60, + endpoints: list = None, +): + """Créer une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + + api_key_obj, api_key_plain = await service.create_api_key( + name=name, + description=description, + created_by="CLI", + expires_in_days=expires_in_days, + rate_limit_per_minute=rate_limit, + allowed_endpoints=endpoints, + ) + + print(f"\n✅ Clé API créée avec succès\n") + print(f" ID: {api_key_obj.id}") + print(f" Nom: {name}") + print(f" Clé: {api_key_plain}") + print(f" Préfixe: {api_key_obj.key_prefix}") + print(f" Rate limit: {rate_limit} req/min") + print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}") + print(f"\n⚠️ IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !\n") + + +async def list_api_keys(): + """Lister les clés API""" + async with get_session() as session: + service = ApiKeyService(session) + keys = await service.list_api_keys() + + if not keys: + print("Aucune clé API trouvée") + return + + print(f"\n📋 {len(keys)} clé(s) API:\n") + for key in keys: + status = "✅" if key.is_active else "❌" + expired = "⏰ Expirée" if key.expires_at and key.expires_at < datetime.now() else "" + + print(f" {status} {key.name:<30} ({key.key_prefix}...)") + print(f" ID: {key.id}") + print(f" Requêtes: {key.total_requests}") + print(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + if expired: + print(f" {expired}") + print() + + +async def revoke_api_key(key_id: str): + """Révoquer une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + + api_key = await service.get_by_id(key_id) + if not api_key: + logger.error(f"❌ Clé {key_id} introuvable") + return + + success = await service.revoke_api_key(key_id) + + if success: + logger.info(f"🚫 Clé révoquée: {api_key.name}") + print(f"\n✅ Clé '{api_key.name}' révoquée avec succès") + else: + logger.error("❌ Erreur lors de la révocation") + + +async def verify_api_key_cmd(api_key: str): + """Vérifier une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + api_key_obj = await service.verify_api_key(api_key) + + if api_key_obj: + print(f"\n✅ Clé API valide\n") + print(f" Nom: {api_key_obj.name}") + print(f" ID: {api_key_obj.id}") + print(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") + print(f" Requêtes: {api_key_obj.total_requests}") + print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}\n") + else: + print(f"\n❌ Clé API invalide, expirée ou révoquée\n") + + +# ============================================ +# CLI PRINCIPAL +# ============================================ + +async def main(): + parser = argparse.ArgumentParser( + description="Gestion de la sécurité Sage Dataven API" + ) + + subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles") + + # === SWAGGER === + swagger_parser = subparsers.add_parser("swagger", help="Gestion utilisateurs Swagger") + swagger_subparsers = swagger_parser.add_subparsers(dest="action") + + # swagger add + swagger_add = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") + swagger_add.add_argument("username", help="Nom d'utilisateur") + swagger_add.add_argument("password", help="Mot de passe") + swagger_add.add_argument("--full-name", help="Nom complet") + + # swagger list + swagger_subparsers.add_parser("list", help="Lister les utilisateurs") + + # swagger delete + swagger_delete = swagger_subparsers.add_parser("delete", help="Supprimer un utilisateur") + swagger_delete.add_argument("username", help="Nom d'utilisateur") + + # === API KEYS === + apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API") + apikey_subparsers = apikey_parser.add_subparsers(dest="action") + + # apikey create + apikey_create = apikey_subparsers.add_parser("create", help="Créer une clé API") + apikey_create.add_argument("name", help="Nom de la clé") + apikey_create.add_argument("--description", help="Description") + apikey_create.add_argument("--days", type=int, default=365, help="Expiration en jours") + apikey_create.add_argument("--rate-limit", type=int, default=60, help="Limite req/min") + apikey_create.add_argument("--endpoints", nargs="+", help="Endpoints autorisés") + + # apikey list + apikey_subparsers.add_parser("list", help="Lister les clés") + + # apikey revoke + apikey_revoke = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") + apikey_revoke.add_argument("key_id", help="ID de la clé") + + # apikey verify + apikey_verify = apikey_subparsers.add_parser("verify", help="Vérifier une clé") + apikey_verify.add_argument("api_key", help="Clé API à vérifier") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + # Exécution des commandes + if args.command == "swagger": + if args.action == "add": + await add_swagger_user(args.username, args.password, args.full_name) + elif args.action == "list": + await list_swagger_users() + elif args.action == "delete": + await delete_swagger_user(args.username) + else: + swagger_parser.print_help() + + elif args.command == "apikey": + if args.action == "create": + await create_api_key( + args.name, + args.description, + args.days, + args.rate_limit, + args.endpoints, + ) + elif args.action == "list": + await list_api_keys() + elif args.action == "revoke": + await revoke_api_key(args.key_id) + elif args.action == "verify": + await verify_api_key_cmd(args.api_key) + else: + apikey_parser.print_help() + + +if __name__ == "__main__": + from datetime import datetime + asyncio.run(main()) \ No newline at end of file diff --git a/scripts/test_security.py b/scripts/test_security.py new file mode 100644 index 0000000..79f0299 --- /dev/null +++ b/scripts/test_security.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +Script de test automatisé pour vérifier la sécurité de l'API + +Usage: + python test_security.py --url http://votre-vps:8000 --swagger-user admin --swagger-pass password +""" + +import requests +import argparse +import sys +from typing import Dict, Tuple +import json + + +class SecurityTester: + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + self.results = {"passed": 0, "failed": 0, "tests": []} + + def log_test(self, name: str, passed: bool, details: str = ""): + """Enregistrer le résultat d'un test""" + status = "✅ PASS" if passed else "❌ FAIL" + print(f"{status} - {name}") + if details: + print(f" {details}") + + self.results["tests"].append( + {"name": name, "passed": passed, "details": details} + ) + + if passed: + self.results["passed"] += 1 + else: + self.results["failed"] += 1 + + def test_swagger_without_auth(self) -> bool: + """Test 1: Swagger UI devrait demander une authentification""" + print("\n🔍 Test 1: Protection Swagger UI") + + try: + response = requests.get(f"{self.base_url}/docs", timeout=5) + + if response.status_code == 401: + self.log_test( + "Swagger protégé", + True, + "Code 401 retourné sans authentification", + ) + return True + else: + self.log_test( + "Swagger protégé", + False, + f"Code {response.status_code} au lieu de 401", + ) + return False + + except Exception as e: + self.log_test("Swagger protégé", False, f"Erreur: {str(e)}") + return False + + def test_swagger_with_auth(self, username: str, password: str) -> bool: + """Test 2: Swagger UI accessible avec credentials valides""" + print("\n🔍 Test 2: Accès Swagger avec authentification") + + try: + response = requests.get( + f"{self.base_url}/docs", auth=(username, password), timeout=5 + ) + + if response.status_code == 200: + self.log_test( + "Accès Swagger avec auth", + True, + f"Authentifié comme {username}", + ) + return True + else: + self.log_test( + "Accès Swagger avec auth", + False, + f"Code {response.status_code}, credentials invalides?", + ) + return False + + except Exception as e: + self.log_test("Accès Swagger avec auth", False, f"Erreur: {str(e)}") + return False + + def test_api_without_auth(self) -> bool: + """Test 3: Endpoints API devraient demander une authentification""" + print("\n🔍 Test 3: Protection des endpoints API") + + test_endpoints = ["/api/v1/clients", "/api/v1/documents"] + + all_protected = True + for endpoint in test_endpoints: + try: + response = requests.get(f"{self.base_url}{endpoint}", timeout=5) + + if response.status_code == 401: + print(f" ✅ {endpoint} protégé (401)") + else: + print( + f" ❌ {endpoint} accessible sans auth (code {response.status_code})" + ) + all_protected = False + + except Exception as e: + print(f" ⚠️ {endpoint} erreur: {str(e)}") + all_protected = False + + self.log_test("Endpoints API protégés", all_protected) + return all_protected + + def test_health_endpoint_public(self) -> bool: + """Test 4: Endpoint /health devrait être accessible sans auth""" + print("\n🔍 Test 4: Endpoint /health public") + + try: + response = requests.get(f"{self.base_url}/health", timeout=5) + + if response.status_code == 200: + self.log_test("/health accessible", True, "Endpoint public fonctionne") + return True + else: + self.log_test( + "/health accessible", + False, + f"Code {response.status_code} inattendu", + ) + return False + + except Exception as e: + self.log_test("/health accessible", False, f"Erreur: {str(e)}") + return False + + def test_api_key_creation(self, username: str, password: str) -> Tuple[bool, str]: + """Test 5: Créer une clé API via l'endpoint""" + print("\n🔍 Test 5: Création d'une clé API") + + try: + # 1. Login pour obtenir un JWT + login_response = requests.post( + f"{self.base_url}/api/v1/auth/login", + json={"email": username, "password": password}, + timeout=5, + ) + + if login_response.status_code != 200: + self.log_test( + "Création clé API", + False, + "Impossible de se connecter pour obtenir un JWT", + ) + return False, "" + + jwt_token = login_response.json().get("access_token") + + # 2. Créer une clé API + create_response = requests.post( + f"{self.base_url}/api/v1/api-keys", + headers={"Authorization": f"Bearer {jwt_token}"}, + json={ + "name": "Test API Key", + "description": "Clé de test automatisé", + "rate_limit_per_minute": 60, + "expires_in_days": 30, + }, + timeout=5, + ) + + if create_response.status_code == 201: + api_key = create_response.json().get("api_key") + self.log_test("Création clé API", True, f"Clé créée: {api_key[:20]}...") + return True, api_key + else: + self.log_test( + "Création clé API", + False, + f"Code {create_response.status_code}", + ) + return False, "" + + except Exception as e: + self.log_test("Création clé API", False, f"Erreur: {str(e)}") + return False, "" + + def test_api_key_usage(self, api_key: str) -> bool: + """Test 6: Utiliser une clé API pour accéder à un endpoint""" + print("\n🔍 Test 6: Utilisation d'une clé API") + + if not api_key: + self.log_test("Utilisation clé API", False, "Pas de clé disponible") + return False + + try: + response = requests.get( + f"{self.base_url}/api/v1/clients", + headers={"X-API-Key": api_key}, + timeout=5, + ) + + if response.status_code == 200: + self.log_test("Utilisation clé API", True, "Clé acceptée") + return True + else: + self.log_test( + "Utilisation clé API", + False, + f"Code {response.status_code}, clé refusée?", + ) + return False + + except Exception as e: + self.log_test("Utilisation clé API", False, f"Erreur: {str(e)}") + return False + + def test_invalid_api_key(self) -> bool: + """Test 7: Une clé invalide devrait être refusée""" + print("\n🔍 Test 7: Rejet de clé API invalide") + + invalid_key = "sdk_live_invalid_key_12345" + + try: + response = requests.get( + f"{self.base_url}/api/v1/clients", + headers={"X-API-Key": invalid_key}, + timeout=5, + ) + + if response.status_code == 401: + self.log_test("Clé invalide rejetée", True, "Code 401 comme attendu") + return True + else: + self.log_test( + "Clé invalide rejetée", + False, + f"Code {response.status_code} au lieu de 401", + ) + return False + + except Exception as e: + self.log_test("Clé invalide rejetée", False, f"Erreur: {str(e)}") + return False + + def test_rate_limiting(self, api_key: str) -> bool: + """Test 8: Rate limiting (optionnel, peut prendre du temps)""" + print("\n🔍 Test 8: Rate limiting (test simple)") + + if not api_key: + self.log_test("Rate limiting", False, "Pas de clé disponible") + return False + + # Envoyer 70 requêtes rapidement (limite = 60/min) + print(" Envoi de 70 requêtes rapides...") + + rate_limited = False + for i in range(70): + try: + response = requests.get( + f"{self.base_url}/health", + headers={"X-API-Key": api_key}, + timeout=1, + ) + + if response.status_code == 429: + rate_limited = True + print(f" ⚠️ Rate limit atteint à la requête {i + 1}") + break + + except Exception: + pass + + if rate_limited: + self.log_test("Rate limiting", True, "Rate limit détecté") + return True + else: + self.log_test( + "Rate limiting", + True, + "Aucun rate limit détecté (peut être normal si pas implémenté)", + ) + return True + + def print_summary(self): + """Afficher le résumé des tests""" + print("\n" + "=" * 60) + print("📊 RÉSUMÉ DES TESTS") + print("=" * 60) + + total = self.results["passed"] + self.results["failed"] + success_rate = (self.results["passed"] / total * 100) if total > 0 else 0 + + print(f"\nTotal: {total} tests") + print(f"✅ Réussis: {self.results['passed']}") + print(f"❌ Échoués: {self.results['failed']}") + print(f"📈 Taux de réussite: {success_rate:.1f}%\n") + + if self.results["failed"] == 0: + print("🎉 Tous les tests sont passés ! Sécurité OK.") + return 0 + else: + print("⚠️ Certains tests ont échoué. Vérifiez la configuration.") + return 1 + + +def main(): + parser = argparse.ArgumentParser( + description="Test automatisé de la sécurité de l'API" + ) + + parser.add_argument( + "--url", + required=True, + help="URL de base de l'API (ex: http://localhost:8000)", + ) + + parser.add_argument( + "--swagger-user", required=True, help="Utilisateur Swagger pour les tests" + ) + + parser.add_argument( + "--swagger-pass", required=True, help="Mot de passe Swagger pour les tests" + ) + + parser.add_argument( + "--skip-rate-limit", + action="store_true", + help="Sauter le test de rate limiting (long)", + ) + + args = parser.parse_args() + + print("🚀 Démarrage des tests de sécurité") + print(f"🎯 URL cible: {args.url}\n") + + tester = SecurityTester(args.url) + + # Exécuter les tests + tester.test_swagger_without_auth() + tester.test_swagger_with_auth(args.swagger_user, args.swagger_pass) + tester.test_api_without_auth() + tester.test_health_endpoint_public() + + # Tests nécessitant une clé API + success, api_key = tester.test_api_key_creation( + args.swagger_user, args.swagger_pass + ) + + if success and api_key: + tester.test_api_key_usage(api_key) + tester.test_invalid_api_key() + + if not args.skip_rate_limit: + tester.test_rate_limiting(api_key) + else: + print("\n⏭️ Test de rate limiting sauté") + else: + print("\n⚠️ Tests avec clé API sautés (création échouée)") + + # Résumé + exit_code = tester.print_summary() + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/services/api_key.py b/services/api_key.py new file mode 100644 index 0000000..d54943a --- /dev/null +++ b/services/api_key.py @@ -0,0 +1,233 @@ +import secrets +import hashlib +import json +from datetime import datetime, timedelta +from typing import Optional, List, Dict +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_ +import logging + +from database.models.api_key import ApiKey + +logger = logging.getLogger(__name__) + + +class ApiKeyService: + """Service de gestion des clés API""" + + def __init__(self, session: AsyncSession): + self.session = session + + @staticmethod + def generate_api_key() -> str: + """Génère une clé API unique et sécurisée""" + # Format: sdk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + random_part = secrets.token_urlsafe(32) + return f"sdk_live_{random_part}" + + @staticmethod + def hash_api_key(api_key: str) -> str: + """Hash la clé API pour stockage sécurisé""" + return hashlib.sha256(api_key.encode()).hexdigest() + + @staticmethod + def get_key_prefix(api_key: str) -> str: + """Extrait le préfixe de la clé pour identification""" + # Retourne les 12 premiers caractères + return api_key[:12] if len(api_key) >= 12 else api_key + + async def create_api_key( + self, + name: str, + description: Optional[str] = None, + created_by: str = "system", + user_id: Optional[str] = None, + expires_in_days: Optional[int] = None, + rate_limit_per_minute: int = 60, + allowed_endpoints: Optional[List[str]] = None, + ) -> tuple[ApiKey, str]: + """ + Crée une nouvelle clé API + + Returns: + tuple[ApiKey, str]: (objet ApiKey, clé en clair - à ne montrer qu'une fois) + """ + # Génération de la clé + api_key_plain = self.generate_api_key() + key_hash = self.hash_api_key(api_key_plain) + key_prefix = self.get_key_prefix(api_key_plain) + + # Calcul de la date d'expiration + expires_at = None + if expires_in_days: + expires_at = datetime.now() + timedelta(days=expires_in_days) + + # Création de l'objet + api_key_obj = ApiKey( + key_hash=key_hash, + key_prefix=key_prefix, + name=name, + description=description, + created_by=created_by, + user_id=user_id, + expires_at=expires_at, + rate_limit_per_minute=rate_limit_per_minute, + allowed_endpoints=json.dumps(allowed_endpoints) + if allowed_endpoints + else None, + ) + + self.session.add(api_key_obj) + await self.session.commit() + await self.session.refresh(api_key_obj) + + logger.info(f"✅ Clé API créée: {name} (prefix: {key_prefix})") + + return api_key_obj, api_key_plain + + async def verify_api_key(self, api_key_plain: str) -> Optional[ApiKey]: + """ + Vérifie une clé API et retourne l'objet si valide + + Returns: + Optional[ApiKey]: L'objet ApiKey si valide, None sinon + """ + key_hash = self.hash_api_key(api_key_plain) + + result = await self.session.execute( + select(ApiKey).where( + and_( + ApiKey.key_hash == key_hash, + ApiKey.is_active == True, + ApiKey.revoked_at.is_(None), + or_( + ApiKey.expires_at.is_(None), ApiKey.expires_at > datetime.now() + ), + ) + ) + ) + + api_key_obj = result.scalar_one_or_none() + + if api_key_obj: + # Mise à jour des statistiques + api_key_obj.total_requests += 1 + api_key_obj.last_used_at = datetime.now() + await self.session.commit() + + logger.debug(f"🔑 Clé API validée: {api_key_obj.name}") + else: + logger.warning(f"⚠️ Clé API invalide ou expirée") + + return api_key_obj + + async def list_api_keys( + self, + include_revoked: bool = False, + user_id: Optional[str] = None, + ) -> List[ApiKey]: + """Liste les clés API""" + query = select(ApiKey) + + if not include_revoked: + query = query.where(ApiKey.revoked_at.is_(None)) + + if user_id: + query = query.where(ApiKey.user_id == user_id) + + query = query.order_by(ApiKey.created_at.desc()) + + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def revoke_api_key(self, key_id: str) -> bool: + """Révoque une clé API""" + result = await self.session.execute(select(ApiKey).where(ApiKey.id == key_id)) + api_key_obj = result.scalar_one_or_none() + + if not api_key_obj: + return False + + api_key_obj.is_active = False + api_key_obj.revoked_at = datetime.now() + await self.session.commit() + + logger.info(f"🚫 Clé API révoquée: {api_key_obj.name}") + return True + + async def get_by_id(self, key_id: str) -> Optional[ApiKey]: + """Récupère une clé API par son ID""" + result = await self.session.execute(select(ApiKey).where(ApiKey.id == key_id)) + return result.scalar_one_or_none() + + async def check_rate_limit(self, api_key_obj: ApiKey) -> tuple[bool, Dict]: + """ + Vérifie le rate limit d'une clé API (à implémenter avec Redis/cache) + + Returns: + tuple[bool, Dict]: (is_allowed, info_dict) + """ + # TODO: Implémenter avec Redis pour un vrai rate limiting + # Pour l'instant, retourne toujours True + return True, { + "allowed": True, + "limit": api_key_obj.rate_limit_per_minute, + "remaining": api_key_obj.rate_limit_per_minute, + } + + async def check_endpoint_access(self, api_key_obj: ApiKey, endpoint: str) -> bool: + """Vérifie si la clé a accès à un endpoint spécifique""" + if not api_key_obj.allowed_endpoints: + # Si aucune restriction, accès total + return True + + try: + allowed = json.loads(api_key_obj.allowed_endpoints) + + # Support des wildcards + for pattern in allowed: + if pattern == "*": + return True + if pattern.endswith("*"): + prefix = pattern[:-1] + if endpoint.startswith(prefix): + return True + if pattern == endpoint: + return True + + return False + except json.JSONDecodeError: + logger.error(f"❌ Erreur parsing allowed_endpoints pour {api_key_obj.id}") + return False + + +def api_key_to_response(api_key_obj: ApiKey, show_key: bool = False) -> Dict: + """Convertit un objet ApiKey en réponse API""" + + allowed_endpoints = None + if api_key_obj.allowed_endpoints: + try: + allowed_endpoints = json.loads(api_key_obj.allowed_endpoints) + except json.JSONDecodeError: + pass + + is_expired = False + if api_key_obj.expires_at: + is_expired = api_key_obj.expires_at < datetime.now() + + return { + "id": api_key_obj.id, + "name": api_key_obj.name, + "description": api_key_obj.description, + "key_prefix": api_key_obj.key_prefix, + "is_active": api_key_obj.is_active, + "is_expired": is_expired, + "rate_limit_per_minute": api_key_obj.rate_limit_per_minute, + "allowed_endpoints": allowed_endpoints, + "total_requests": api_key_obj.total_requests, + "last_used_at": api_key_obj.last_used_at, + "created_at": api_key_obj.created_at, + "expires_at": api_key_obj.expires_at, + "revoked_at": api_key_obj.revoked_at, + "created_by": api_key_obj.created_by, + } From 1164c7975ae2742c6248cd443a0b3175d26c0a97 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 06:31:17 +0300 Subject: [PATCH 04/30] refactor: remove emojis and clean up code comments --- config/cors_config.py | 254 ++++------------------------- core/dependencies.py | 59 +------ database/models/api_key.py | 14 +- middleware/security.py | 90 ++-------- routes/api_keys.py | 32 +--- schemas/api_key.py | 2 +- scripts/manage_security.py | 108 +++++------- scripts/test_security.py | 61 +++---- services/api_key.py | 40 +---- services/universign_document.py | 2 +- services/universign_sync.py | 17 +- utils/generic_functions.py | 4 +- utils/universign_status_mapping.py | 2 +- 13 files changed, 137 insertions(+), 548 deletions(-) diff --git a/config/cors_config.py b/config/cors_config.py index 6ee880e..0f3a4d2 100644 --- a/config/cors_config.py +++ b/config/cors_config.py @@ -1,17 +1,3 @@ -""" -CONFIGURATION CORS POUR API AVEC CLÉS API MULTIPLES - -Problématique: -- Les clés API seront utilisées depuis de nombreux domaines/IPs différents -- Impossible de lister tous les origins autorisés à l'avance -- Solution: CORS permissif mais sécurisé par les clés API - -Stratégies: -1. CORS ouvert avec validation par clé API (RECOMMANDÉ) -2. CORS dynamique basé sur whitelist -3. CORS avec wildcard et credentials=False -""" - from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from typing import List @@ -21,66 +7,24 @@ import logging logger = logging.getLogger(__name__) -# ============================================ -# STRATÉGIE 1: CORS OUVERT + VALIDATION API KEY -# ============================================ -# ✅ RECOMMANDÉ pour les API publiques avec clés -# -# Principe: -# - Accepter toutes les origines (allow_origins=["*"]) -# - La sécurité est assurée par la validation des clés API -# - Les clés API protègent l'accès, pas le CORS - - def configure_cors_open(app: FastAPI): - """ - Configuration CORS ouverte (RECOMMANDÉE) - - ✅ Accepte toutes les origines - ✅ Sécurité assurée par les clés API - ✅ Simplifie l'utilisation pour les clients - - ⚠️ Attention: credentials=False obligatoire avec allow_origins=["*"] - """ app.add_middleware( CORSMiddleware, - allow_origins=["*"], # Accepte toutes les origines - allow_credentials=False, # ⚠️ Obligatoire avec "*" + allow_origins=["*"], + allow_credentials=False, allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], - allow_headers=["*"], # Accepte tous les headers (dont X-API-Key) + allow_headers=["*"], expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], - max_age=3600, # Cache preflight requests pendant 1h + max_age=3600, ) - logger.info("🌐 CORS configuré: Mode OUVERT (sécurisé par API Keys)") + logger.info(" CORS configuré: Mode OUVERT (sécurisé par API Keys)") logger.info(" - Origins: * (toutes)") logger.info(" - Headers: * (dont X-API-Key)") logger.info(" - Credentials: False") -# ============================================ -# STRATÉGIE 2: CORS AVEC WHITELIST DYNAMIQUE -# ============================================ -# 🔶 Pour environnements contrôlés -# -# Principe: -# - Lister explicitement les domaines autorisés -# - Peut inclure des patterns wildcards -# - Credentials possible (cookies, etc.) - - def configure_cors_whitelist(app: FastAPI): - """ - Configuration CORS avec whitelist (MODE CONTRÔLÉ) - - ✅ Meilleur contrôle des origines - ✅ Credentials possible - ❌ Nécessite maintenance de la liste - - À utiliser si vous connaissez tous les domaines clients - """ - - # Charger depuis .env ou config allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "") if allowed_origins_str: @@ -90,57 +34,11 @@ def configure_cors_whitelist(app: FastAPI): if origin.strip() ] else: - # Valeurs par défaut - allowed_origins = [ - "http://localhost:3000", # Frontend dev React/Vue - "http://localhost:5173", # Vite dev - "http://localhost:8080", # Frontend dev alternatif - "https://app.votre-domaine.com", - "https://admin.votre-domaine.com", - # Ajouter vos domaines de production - ] + allowed_origins = ["*"] app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, - allow_credentials=True, # ✅ Possible avec liste explicite - allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], - allow_headers=["Content-Type", "Authorization", "X-API-Key"], - expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], - max_age=3600, - ) - - logger.info("🌐 CORS configuré: Mode WHITELIST") - logger.info(f" - Origins autorisées: {len(allowed_origins)}") - for origin in allowed_origins: - logger.info(f" • {origin}") - - -# ============================================ -# STRATÉGIE 3: CORS AVEC REGEX (AVANCÉ) -# ============================================ -# 🔶 Pour patterns complexes -# -# Exemple: Autoriser tous les sous-domaines *.votre-domaine.com - - -def configure_cors_regex(app: FastAPI): - """ - Configuration CORS avec patterns regex (AVANCÉ) - - ✅ Flexible pour sous-domaines - ✅ Supporte patterns complexes - ❌ Plus complexe à configurer - - Utilise allow_origin_regex au lieu de allow_origins - """ - - # Pattern regex pour autoriser tous les sous-domaines - origin_regex = r"https://.*\.votre-domaine\.com" - - app.add_middleware( - CORSMiddleware, - allow_origin_regex=origin_regex, # ⚠️ Utilise regex au lieu de liste allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], allow_headers=["Content-Type", "Authorization", "X-API-Key"], @@ -148,32 +46,31 @@ def configure_cors_regex(app: FastAPI): max_age=3600, ) - logger.info("🌐 CORS configuré: Mode REGEX") + logger.info(" CORS configuré: Mode WHITELIST") + logger.info(f" - Origins autorisées: {len(allowed_origins)}") + for origin in allowed_origins: + logger.info(f" • {origin}") + + +def configure_cors_regex(app: FastAPI): + origin_regex = r"*" + + app.add_middleware( + CORSMiddleware, + allow_origin_regex=origin_regex, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info(" CORS configuré: Mode REGEX") logger.info(f" - Pattern: {origin_regex}") -# ============================================ -# STRATÉGIE 4: CORS HYBRIDE (PRODUCTION) -# ============================================ -# ✅ RECOMMANDÉ pour production -# -# Principe: -# - Whitelist pour les domaines connus (credentials=True) -# - Fallback sur "*" pour le reste (credentials=False) - - def configure_cors_hybrid(app: FastAPI): - """ - Configuration CORS hybride (PRODUCTION) - - ✅ Meilleur des deux mondes - ✅ Whitelist pour domaines connus - ✅ Fallback ouvert pour API Keys externes - - Note: Nécessite un middleware custom pour gérer les deux modes - """ from starlette.middleware.base import BaseHTTPMiddleware - from starlette.responses import Response class HybridCORSMiddleware(BaseHTTPMiddleware): def __init__(self, app, known_origins: List[str]): @@ -183,7 +80,6 @@ def configure_cors_hybrid(app: FastAPI): async def dispatch(self, request, call_next): origin = request.headers.get("origin") - # Si origin connue → CORS strict avec credentials if origin in self.known_origins: response = await call_next(request) response.headers["Access-Control-Allow-Origin"] = origin @@ -196,7 +92,6 @@ def configure_cors_hybrid(app: FastAPI): ) return response - # Sinon → CORS ouvert sans credentials response = await call_next(request) response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = ( @@ -205,40 +100,16 @@ def configure_cors_hybrid(app: FastAPI): response.headers["Access-Control-Allow-Headers"] = "*" return response - # Domaines connus (whitelist) - known_origins = [ - "https://app.votre-domaine.com", - "https://admin.votre-domaine.com", - "http://localhost:3000", - "http://localhost:5173", - ] + known_origins = ["*"] app.add_middleware(HybridCORSMiddleware, known_origins=known_origins) - logger.info("🌐 CORS configuré: Mode HYBRIDE") + logger.info(" CORS configuré: Mode HYBRIDE") logger.info(f" - Whitelist: {len(known_origins)} domaines") logger.info(" - Fallback: * (ouvert)") -# ============================================ -# FONCTION PRINCIPALE -# ============================================ - - def setup_cors(app: FastAPI, mode: str = "open"): - """ - Configure CORS selon le mode choisi - - Args: - app: Instance FastAPI - mode: "open" | "whitelist" | "regex" | "hybrid" - - Recommandations: - - Development: "open" - - Production (API publique): "open" ou "hybrid" - - Production (API interne): "whitelist" - """ - if mode == "open": configure_cors_open(app) elif mode == "whitelist": @@ -249,73 +120,6 @@ def setup_cors(app: FastAPI, mode: str = "open"): configure_cors_hybrid(app) else: logger.warning( - f"⚠️ Mode CORS inconnu: {mode}. Utilisation de 'open' par défaut." + f" Mode CORS inconnu: {mode}. Utilisation de 'open' par défaut." ) configure_cors_open(app) - - -# ============================================ -# EXEMPLE D'UTILISATION DANS api.py -# ============================================ - -""" -# Dans api.py - -from config.cors_config import setup_cors - -app = FastAPI(...) - -# DÉVELOPPEMENT -setup_cors(app, mode="open") - -# PRODUCTION (API publique avec clés) -setup_cors(app, mode="hybrid") - -# PRODUCTION (API interne uniquement) -setup_cors(app, mode="whitelist") -""" - - -# ============================================ -# VARIABLES D'ENVIRONNEMENT (.env) -# ============================================ - -""" -# Pour mode "whitelist" -CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com,http://localhost:3000 - -# Pour mode "regex" -CORS_ORIGIN_REGEX=https://.*\.example\.com - -# Choisir le mode -CORS_MODE=open -""" - - -# ============================================ -# FAQ CORS + API KEYS -# ============================================ - -""" -Q: Pourquoi allow_origins=["*"] est sécurisé avec des API Keys ? -R: Les clés API protègent l'accès aux données. CORS empêche seulement les - navigateurs web de faire des requêtes cross-origin. Un attaquant peut - contourner CORS facilement (curl, postman), donc la vraie sécurité vient - de la validation des clés API, pas du CORS. - -Q: Pourquoi credentials=False avec allow_origins=["*"] ? -R: C'est une restriction du navigateur (spec CORS). Vous ne pouvez pas avoir - credentials=True ET origins=["*"] en même temps. - -Q: Mes clients utilisent des IPs dynamiques, que faire ? -R: Utilisez mode "open". Les clés API sont justement faites pour ça - - permettre l'accès depuis n'importe quelle origine, de manière sécurisée. - -Q: Je veux quand même utiliser des cookies/sessions ? -R: Utilisez mode "whitelist" ou "hybrid" pour les domaines qui ont besoin - de credentials, et laissez les autres utiliser X-API-Key sans credentials. - -Q: Comment tester CORS localement ? -R: Créez un simple fichier HTML avec fetch() et ouvrez-le en file:// - Ou utilisez un serveur local (python -m http.server) -""" diff --git a/core/dependencies.py b/core/dependencies.py index 6782e98..ff443a6 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -10,7 +10,7 @@ import logging logger = logging.getLogger(__name__) -security = HTTPBearer(auto_error=False) # ⚠️ auto_error=False pour gérer manuellement +security = HTTPBearer(auto_error=False) async def get_current_user_hybrid( @@ -18,19 +18,6 @@ async def get_current_user_hybrid( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), session: AsyncSession = Depends(get_session), ) -> User: - """ - VERSION HYBRIDE: Accepte JWT OU API Key - - Priorité: - 1. JWT (Authorization: Bearer) - 2. API Key (déjà validée par middleware) - - Si API Key utilisée, retourne un "user virtuel" basé sur la clé - """ - - # ============================================ - # OPTION 1: JWT (comportement standard) - # ============================================ if credentials and credentials.credentials: token = credentials.credentials @@ -84,19 +71,12 @@ async def get_current_user_hybrid( detail="Compte temporairement verrouillé suite à trop de tentatives échouées", ) - logger.debug(f"🔐 Authentifié via JWT: {user.email}") + logger.debug(f" Authentifié via JWT: {user.email}") return user - # ============================================ - # OPTION 2: API Key (validée par middleware) - # ============================================ api_key_obj = getattr(request.state, "api_key", None) if api_key_obj: - # Créer un "user virtuel" basé sur la clé API - # Cela permet aux routes existantes de fonctionner sans modification - - # Si la clé est associée à un vrai user, le récupérer if api_key_obj.user_id: result = await session.execute( select(User).where(User.id == api_key_obj.user_id) @@ -105,11 +85,10 @@ async def get_current_user_hybrid( if user: logger.debug( - f"🔑 Authentifié via API Key ({api_key_obj.name}) → User: {user.email}" + f" Authentifié via API Key ({api_key_obj.name}) → User: {user.email}" ) return user - # Sinon, créer un user virtuel (pour les clés API sans user associé) from database import User as UserModel virtual_user = UserModel( @@ -117,21 +96,17 @@ async def get_current_user_hybrid( email=f"{api_key_obj.name.lower().replace(' ', '_')}@api-key.local", nom="API Key", prenom=api_key_obj.name, - role="api_client", # Rôle spécial pour les API keys + role="api_client", is_verified=True, is_active=True, ) - # Marquer que c'est un user virtuel virtual_user._is_api_key_user = True virtual_user._api_key_obj = api_key_obj - logger.debug(f"🔑 Authentifié via API Key: {api_key_obj.name} (user virtuel)") + logger.debug(f" Authentifié via API Key: {api_key_obj.name} (user virtuel)") return virtual_user - # ============================================ - # AUCUNE AUTHENTIFICATION - # ============================================ raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentification requise (JWT ou API Key)", @@ -152,32 +127,20 @@ async def get_current_user_optional_hybrid( def require_role_hybrid(*allowed_roles: str): - """ - VERSION HYBRIDE: Vérification de rôle compatible avec API Keys - - Notes: - - Les users via JWT ont leur vrai rôle - - Les users via API Key ont le rôle "api_client" par défaut - - Vous pouvez autoriser "api_client" dans allowed_roles pour permettre l'accès aux API Keys - """ - async def role_checker( request: Request, user: User = Depends(get_current_user_hybrid) ) -> User: - # Vérifier si c'est un user API Key is_api_key_user = getattr(user, "_is_api_key_user", False) if is_api_key_user: - # Pour les API Keys, vérifier si "api_client" est autorisé if "api_client" not in allowed_roles and "*" not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}. API Keys non autorisées.", ) - logger.debug(f"✅ API Key autorisée pour cette route") + logger.debug(" API Key autorisée pour cette route") return user - # Pour les vrais users, vérification standard if user.role not in allowed_roles and "*" not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -189,11 +152,6 @@ def require_role_hybrid(*allowed_roles: str): return role_checker -# ============================================ -# HELPERS -# ============================================ - - def is_api_key_user(user: User) -> bool: """Vérifie si l'utilisateur est authentifié via API Key""" return getattr(user, "_is_api_key_user", False) @@ -204,10 +162,5 @@ def get_api_key_from_user(user: User): return getattr(user, "_api_key_obj", None) -# ============================================ -# RÉTROCOMPATIBILITÉ -# ============================================ - -# Alias pour garder la compatibilité avec le code existant get_current_user = get_current_user_hybrid get_current_user_optional = get_current_user_optional_hybrid diff --git a/database/models/api_key.py b/database/models/api_key.py index 1e54342..0d246ab 100644 --- a/database/models/api_key.py +++ b/database/models/api_key.py @@ -12,29 +12,21 @@ class ApiKey(Base): id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) key_hash = Column(String(64), unique=True, nullable=False, index=True) - key_prefix = Column( - String(10), nullable=False - ) # Premiers caractères pour identification + key_prefix = Column(String(10), nullable=False) name = Column(String(255), nullable=False) description = Column(Text, nullable=True) - # Métadonnées - user_id = Column(String(36), nullable=True) # Optionnel si associé à un utilisateur + user_id = Column(String(36), nullable=True) created_by = Column(String(255), nullable=False) - # Contrôle d'accès is_active = Column(Boolean, default=True, nullable=False) rate_limit_per_minute = Column(Integer, default=60, nullable=False) - allowed_endpoints = Column( - Text, nullable=True - ) # JSON array des endpoints autorisés + allowed_endpoints = Column(Text, nullable=True) - # Statistiques total_requests = Column(Integer, default=0, nullable=False) last_used_at = Column(DateTime, nullable=True) - # Dates created_at = Column(DateTime, default=datetime.now, nullable=False) expires_at = Column(DateTime, nullable=True) revoked_at = Column(DateTime, nullable=True) diff --git a/middleware/security.py b/middleware/security.py index 17186a0..137e7dd 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -1,5 +1,4 @@ -import secrets -from fastapi import Request, HTTPException, status +from fastapi import Request, status from fastapi.responses import JSONResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials from sqlalchemy import select @@ -13,23 +12,14 @@ from security.auth import verify_password logger = logging.getLogger(__name__) -# === Configuration Swagger === security = HTTPBasic() async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: - """ - VERSION 2: Vérification des identifiants Swagger via base de données - - ✅ Plus sécurisé - ✅ Gestion centralisée - ✅ Tracking des connexions - """ username = credentials.username password = credentials.password try: - # Utiliser get_session de manière asynchrone async for session in get_session(): result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) @@ -38,29 +28,21 @@ async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: if swagger_user and swagger_user.is_active: if verify_password(password, swagger_user.hashed_password): - # Mise à jour de la dernière connexion swagger_user.last_login = datetime.now() await session.commit() - logger.info(f"✅ Accès Swagger autorisé (DB): {username}") + logger.info(f" Accès Swagger autorisé (DB): {username}") return True - logger.warning(f"⚠️ Tentative d'accès Swagger refusée: {username}") + logger.warning(f" Tentative d'accès Swagger refusée: {username}") return False except Exception as e: - logger.error(f"❌ Erreur vérification Swagger credentials: {e}") + logger.error(f" Erreur vérification Swagger credentials: {e}") return False class SwaggerAuthMiddleware: - """ - Middleware pour protéger les endpoints de documentation - (/docs, /redoc, /openapi.json) - - VERSION 2: Avec vérification en base de données - """ - def __init__(self, app): self.app = app @@ -72,15 +54,12 @@ class SwaggerAuthMiddleware: request = Request(scope, receive=receive) path = request.url.path - # Endpoints à protéger protected_paths = ["/docs", "/redoc", "/openapi.json"] if any(path.startswith(protected_path) for protected_path in protected_paths): - # Vérification de l'authentification Basic auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Basic "): - # Demande d'authentification response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ @@ -91,7 +70,6 @@ class SwaggerAuthMiddleware: await response(scope, receive, send) return - # Extraction des credentials try: import base64 @@ -103,7 +81,6 @@ class SwaggerAuthMiddleware: credentials = HTTPBasicCredentials(username=username, password=password) - # Vérification via DB if not await verify_swagger_credentials(credentials): response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, @@ -114,7 +91,7 @@ class SwaggerAuthMiddleware: return except Exception as e: - logger.error(f"❌ Erreur parsing auth header: {e}") + logger.error(f" Erreur parsing auth header: {e}") response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={"detail": "Format d'authentification invalide"}, @@ -123,19 +100,10 @@ class SwaggerAuthMiddleware: await response(scope, receive, send) return - # Si tout est OK, continuer await self.app(scope, receive, send) class ApiKeyMiddleware: - """ - Middleware HYBRIDE pour vérifier les clés API sur les endpoints publics - - ✅ Accepte X-API-Key OU Authorization: Bearer (JWT) - ✅ Les deux méthodes sont équivalentes - ✅ Les endpoints /auth/* restent accessibles sans auth - """ - def __init__(self, app): self.app = app @@ -147,53 +115,37 @@ class ApiKeyMiddleware: request = Request(scope, receive=receive) path = request.url.path - # ============================================ - # ENDPOINTS EXCLUS (accessibles sans auth) - # ============================================ excluded_paths = [ "/docs", "/redoc", "/openapi.json", "/health", "/", - # === ROUTES AUTH (toujours publiques) === - "/api/v1/auth/login", - "/api/v1/auth/register", - "/api/v1/auth/verify-email", - "/api/v1/auth/reset-password", - "/api/v1/auth/request-reset", - "/api/v1/auth/refresh", + "/auth/login", + "/auth/register", + "/auth/verify-email", + "/auth/reset-password", + "/auth/request-reset", + "/auth/refresh", ] if any(path.startswith(excluded_path) for excluded_path in excluded_paths): await self.app(scope, receive, send) return - # ============================================ - # VÉRIFICATION HYBRIDE: API Key OU JWT - # ============================================ - - # Option 1: Vérifier si JWT présent auth_header = request.headers.get("Authorization") has_jwt = auth_header and auth_header.startswith("Bearer ") - # Option 2: Vérifier si API Key présente api_key = request.headers.get("X-API-Key") has_api_key = api_key is not None - # ============================================ - # LOGIQUE HYBRIDE - # ============================================ - if has_jwt: - # JWT présent → laisser passer, sera validé par les dependencies FastAPI - logger.debug(f"🔑 JWT détecté pour {path}") + logger.debug(f" JWT détecté pour {path}") await self.app(scope, receive, send) return elif has_api_key: - # API Key présente → valider la clé - logger.debug(f"🔑 API Key détectée pour {path}") + logger.debug(f" API Key détectée pour {path}") from services.api_key import ApiKeyService @@ -213,7 +165,6 @@ class ApiKeyMiddleware: await response(scope, receive, send) return - # Vérification du rate limit is_allowed, rate_info = await api_key_service.check_rate_limit( api_key_obj ) @@ -229,7 +180,6 @@ class ApiKeyMiddleware: await response(scope, receive, send) return - # Vérification de l'accès à l'endpoint has_access = await api_key_service.check_endpoint_access( api_key_obj, path ) @@ -245,18 +195,16 @@ class ApiKeyMiddleware: await response(scope, receive, send) return - # ✅ Clé valide → ajouter les infos à la requête request.state.api_key = api_key_obj request.state.authenticated_via = "api_key" - logger.info(f"✅ API Key valide: {api_key_obj.name} → {path}") + logger.info(f" API Key valide: {api_key_obj.name} → {path}") - # Continuer la requête await self.app(scope, receive, send) return except Exception as e: - logger.error(f"❌ Erreur validation API Key: {e}", exc_info=True) + logger.error(f" Erreur validation API Key: {e}", exc_info=True) response = JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={ @@ -267,7 +215,6 @@ class ApiKeyMiddleware: return else: - # ❌ Ni JWT ni API Key response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ @@ -280,17 +227,10 @@ class ApiKeyMiddleware: return -# === Fonction helper pour récupérer l'API Key depuis la requête === def get_api_key_from_request(request: Request) -> Optional: """Récupère l'objet ApiKey depuis la requête si présent""" return getattr(request.state, "api_key", None) def get_auth_method(request: Request) -> str: - """ - Retourne la méthode d'authentification utilisée - - Returns: - "jwt" | "api_key" | "none" - """ return getattr(request.state, "authenticated_via", "none") diff --git a/routes/api_keys.py b/routes/api_keys.py index 8c8f6db..27f0efc 100644 --- a/routes/api_keys.py +++ b/routes/api_keys.py @@ -27,13 +27,6 @@ async def create_api_key( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): - """ - 🔑 Créer une nouvelle clé API - - **Réservé aux admins** - - ⚠️ La clé en clair ne sera affichée qu'une seule fois ! - """ service = ApiKeyService(session) api_key_obj, api_key_plain = await service.create_api_key( @@ -46,7 +39,7 @@ async def create_api_key( allowed_endpoints=data.allowed_endpoints, ) - logger.info(f"✅ Clé API créée par {user.email}: {data.name}") + logger.info(f" Clé API créée par {user.email}: {data.name}") response_data = api_key_to_response(api_key_obj) response_data["api_key"] = api_key_plain @@ -60,15 +53,8 @@ async def list_api_keys( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): - """ - 📋 Lister toutes les clés API - - **Réservé aux admins** - liste toutes les clés - **Utilisateurs standards** - liste uniquement leurs clés - """ service = ApiKeyService(session) - # Si admin, voir toutes les clés, sinon seulement les siennes user_id = None if user.role in ["admin", "super_admin"] else user.id keys = await service.list_api_keys(include_revoked=include_revoked, user_id=user_id) @@ -84,7 +70,7 @@ async def get_api_key( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): - """🔍 Récupérer une clé API par son ID""" + """ Récupérer une clé API par son ID""" service = ApiKeyService(session) api_key_obj = await service.get_by_id(key_id) @@ -95,7 +81,6 @@ async def get_api_key( detail=f"Clé API {key_id} introuvable", ) - # Vérification des permissions if user.role not in ["admin", "super_admin"]: if api_key_obj.user_id != user.id: raise HTTPException( @@ -112,11 +97,6 @@ async def revoke_api_key( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): - """ - 🚫 Révoquer une clé API - - **Action irréversible** - la clé sera désactivée définitivement - """ service = ApiKeyService(session) api_key_obj = await service.get_by_id(key_id) @@ -127,7 +107,6 @@ async def revoke_api_key( detail=f"Clé API {key_id} introuvable", ) - # Vérification des permissions if user.role not in ["admin", "super_admin"]: if api_key_obj.user_id != user.id: raise HTTPException( @@ -143,7 +122,7 @@ async def revoke_api_key( detail="Erreur lors de la révocation", ) - logger.info(f"🚫 Clé API révoquée par {user.email}: {api_key_obj.name}") + logger.info(f" Clé API révoquée par {user.email}: {api_key_obj.name}") return { "success": True, @@ -156,11 +135,6 @@ async def verify_api_key_endpoint( api_key: str = Query(..., description="Clé API à vérifier"), session: AsyncSession = Depends(get_session), ): - """ - ✅ Vérifier la validité d'une clé API - - **Endpoint public** - permet de tester une clé - """ service = ApiKeyService(session) api_key_obj = await service.verify_api_key(api_key) diff --git a/schemas/api_key.py b/schemas/api_key.py index 6a2d659..4ec49b6 100644 --- a/schemas/api_key.py +++ b/schemas/api_key.py @@ -42,7 +42,7 @@ class ApiKeyCreatedResponse(ApiKeyResponse): """Schema de réponse après création (inclut la clé en clair)""" api_key: str = Field( - ..., description="⚠️ Clé API en clair - à sauvegarder immédiatement" + ..., description=" Clé API en clair - à sauvegarder immédiatement" ) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 35c0cff..1f234b9 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,18 +1,3 @@ -#!/usr/bin/env python3 -""" -Script CLI pour gérer les clés API et utilisateurs Swagger - -Usage: - python manage_security.py swagger add - python manage_security.py swagger list - python manage_security.py swagger delete - - python manage_security.py apikey create [--days 365] [--rate-limit 60] - python manage_security.py apikey list - python manage_security.py apikey revoke - python manage_security.py apikey verify -""" - import asyncio import sys from pathlib import Path @@ -23,7 +8,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from database import get_session from database.models.api_key import SwaggerUser from services.api_key import ApiKeyService -from security.auth import hash_password, verify_password +from security.auth import hash_password from sqlalchemy import select import logging @@ -31,24 +16,18 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# ============================================ -# GESTION DES UTILISATEURS SWAGGER -# ============================================ - async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" async with get_session() as session: - # Vérifier si existe result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) ) existing = result.scalar_one_or_none() if existing: - logger.error(f"❌ L'utilisateur {username} existe déjà") + logger.error(f" L'utilisateur {username} existe déjà") return - # Créer user = SwaggerUser( username=username, hashed_password=hash_password(password), @@ -59,10 +38,10 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): session.add(user) await session.commit() - logger.info(f"✅ Utilisateur Swagger créé: {username}") - print(f"\n✅ Utilisateur créé avec succès") + logger.info(f" Utilisateur Swagger créé: {username}") + print("\n Utilisateur créé avec succès") print(f" Username: {username}") - print(f" Accès: https://votre-serveur/docs") + print(" Accès: https://votre-serveur/docs") async def list_swagger_users(): @@ -75,9 +54,9 @@ async def list_swagger_users(): print("Aucun utilisateur Swagger trouvé") return - print(f"\n📋 {len(users)} utilisateur(s) Swagger:\n") + print(f"\n {len(users)} utilisateur(s) Swagger:\n") for user in users: - status = "✅ Actif" if user.is_active else "❌ Inactif" + status = " Actif" if user.is_active else " Inactif" print(f" • {user.username:<20} {status}") if user.full_name: print(f" Nom: {user.full_name}") @@ -95,7 +74,7 @@ async def delete_swagger_user(username: str): user = result.scalar_one_or_none() if not user: - logger.error(f"❌ Utilisateur {username} introuvable") + logger.error(f" Utilisateur {username} introuvable") return await session.delete(user) @@ -104,10 +83,6 @@ async def delete_swagger_user(username: str): logger.info(f"🗑️ Utilisateur supprimé: {username}") -# ============================================ -# GESTION DES CLÉS API -# ============================================ - async def create_api_key( name: str, description: str = None, @@ -128,14 +103,14 @@ async def create_api_key( allowed_endpoints=endpoints, ) - print(f"\n✅ Clé API créée avec succès\n") + print("\n Clé API créée avec succès\n") print(f" ID: {api_key_obj.id}") print(f" Nom: {name}") print(f" Clé: {api_key_plain}") print(f" Préfixe: {api_key_obj.key_prefix}") print(f" Rate limit: {rate_limit} req/min") print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}") - print(f"\n⚠️ IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !\n") + print("\n IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !\n") async def list_api_keys(): @@ -148,11 +123,15 @@ async def list_api_keys(): print("Aucune clé API trouvée") return - print(f"\n📋 {len(keys)} clé(s) API:\n") + print(f"\n {len(keys)} clé(s) API:\n") for key in keys: - status = "✅" if key.is_active else "❌" - expired = "⏰ Expirée" if key.expires_at and key.expires_at < datetime.now() else "" - + status = "" if key.is_active else "" + expired = ( + "⏰ Expirée" + if key.expires_at and key.expires_at < datetime.now() + else "" + ) + print(f" {status} {key.name:<30} ({key.key_prefix}...)") print(f" ID: {key.id}") print(f" Requêtes: {key.total_requests}") @@ -166,19 +145,19 @@ async def revoke_api_key(key_id: str): """Révoquer une clé API""" async with get_session() as session: service = ApiKeyService(session) - + api_key = await service.get_by_id(key_id) if not api_key: - logger.error(f"❌ Clé {key_id} introuvable") + logger.error(f" Clé {key_id} introuvable") return success = await service.revoke_api_key(key_id) - + if success: - logger.info(f"🚫 Clé révoquée: {api_key.name}") - print(f"\n✅ Clé '{api_key.name}' révoquée avec succès") + logger.info(f" Clé révoquée: {api_key.name}") + print(f"\n Clé '{api_key.name}' révoquée avec succès") else: - logger.error("❌ Erreur lors de la révocation") + logger.error(" Erreur lors de la révocation") async def verify_api_key_cmd(api_key: str): @@ -188,64 +167,59 @@ async def verify_api_key_cmd(api_key: str): api_key_obj = await service.verify_api_key(api_key) if api_key_obj: - print(f"\n✅ Clé API valide\n") + print("\n Clé API valide\n") print(f" Nom: {api_key_obj.name}") print(f" ID: {api_key_obj.id}") print(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") print(f" Requêtes: {api_key_obj.total_requests}") print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}\n") else: - print(f"\n❌ Clé API invalide, expirée ou révoquée\n") + print("\n Clé API invalide, expirée ou révoquée\n") -# ============================================ -# CLI PRINCIPAL -# ============================================ - async def main(): parser = argparse.ArgumentParser( description="Gestion de la sécurité Sage Dataven API" ) - + subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles") - # === SWAGGER === - swagger_parser = subparsers.add_parser("swagger", help="Gestion utilisateurs Swagger") + swagger_parser = subparsers.add_parser( + "swagger", help="Gestion utilisateurs Swagger" + ) swagger_subparsers = swagger_parser.add_subparsers(dest="action") - # swagger add swagger_add = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") swagger_add.add_argument("username", help="Nom d'utilisateur") swagger_add.add_argument("password", help="Mot de passe") swagger_add.add_argument("--full-name", help="Nom complet") - # swagger list swagger_subparsers.add_parser("list", help="Lister les utilisateurs") - # swagger delete - swagger_delete = swagger_subparsers.add_parser("delete", help="Supprimer un utilisateur") + swagger_delete = swagger_subparsers.add_parser( + "delete", help="Supprimer un utilisateur" + ) swagger_delete.add_argument("username", help="Nom d'utilisateur") - # === API KEYS === apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API") apikey_subparsers = apikey_parser.add_subparsers(dest="action") - # apikey create apikey_create = apikey_subparsers.add_parser("create", help="Créer une clé API") apikey_create.add_argument("name", help="Nom de la clé") apikey_create.add_argument("--description", help="Description") - apikey_create.add_argument("--days", type=int, default=365, help="Expiration en jours") - apikey_create.add_argument("--rate-limit", type=int, default=60, help="Limite req/min") + apikey_create.add_argument( + "--days", type=int, default=365, help="Expiration en jours" + ) + apikey_create.add_argument( + "--rate-limit", type=int, default=60, help="Limite req/min" + ) apikey_create.add_argument("--endpoints", nargs="+", help="Endpoints autorisés") - # apikey list apikey_subparsers.add_parser("list", help="Lister les clés") - # apikey revoke apikey_revoke = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") apikey_revoke.add_argument("key_id", help="ID de la clé") - # apikey verify apikey_verify = apikey_subparsers.add_parser("verify", help="Vérifier une clé") apikey_verify.add_argument("api_key", help="Clé API à vérifier") @@ -255,7 +229,6 @@ async def main(): parser.print_help() return - # Exécution des commandes if args.command == "swagger": if args.action == "add": await add_swagger_user(args.username, args.password, args.full_name) @@ -287,4 +260,5 @@ async def main(): if __name__ == "__main__": from datetime import datetime - asyncio.run(main()) \ No newline at end of file + + asyncio.run(main()) diff --git a/scripts/test_security.py b/scripts/test_security.py index 79f0299..497870e 100644 --- a/scripts/test_security.py +++ b/scripts/test_security.py @@ -1,16 +1,7 @@ -#!/usr/bin/env python3 -""" -Script de test automatisé pour vérifier la sécurité de l'API - -Usage: - python test_security.py --url http://votre-vps:8000 --swagger-user admin --swagger-pass password -""" - import requests import argparse import sys -from typing import Dict, Tuple -import json +from typing import Tuple class SecurityTester: @@ -20,7 +11,7 @@ class SecurityTester: def log_test(self, name: str, passed: bool, details: str = ""): """Enregistrer le résultat d'un test""" - status = "✅ PASS" if passed else "❌ FAIL" + status = " PASS" if passed else " FAIL" print(f"{status} - {name}") if details: print(f" {details}") @@ -36,7 +27,7 @@ class SecurityTester: def test_swagger_without_auth(self) -> bool: """Test 1: Swagger UI devrait demander une authentification""" - print("\n🔍 Test 1: Protection Swagger UI") + print("\n Test 1: Protection Swagger UI") try: response = requests.get(f"{self.base_url}/docs", timeout=5) @@ -62,7 +53,7 @@ class SecurityTester: def test_swagger_with_auth(self, username: str, password: str) -> bool: """Test 2: Swagger UI accessible avec credentials valides""" - print("\n🔍 Test 2: Accès Swagger avec authentification") + print("\n Test 2: Accès Swagger avec authentification") try: response = requests.get( @@ -90,7 +81,7 @@ class SecurityTester: def test_api_without_auth(self) -> bool: """Test 3: Endpoints API devraient demander une authentification""" - print("\n🔍 Test 3: Protection des endpoints API") + print("\n Test 3: Protection des endpoints API") test_endpoints = ["/api/v1/clients", "/api/v1/documents"] @@ -100,15 +91,15 @@ class SecurityTester: response = requests.get(f"{self.base_url}{endpoint}", timeout=5) if response.status_code == 401: - print(f" ✅ {endpoint} protégé (401)") + print(f" {endpoint} protégé (401)") else: print( - f" ❌ {endpoint} accessible sans auth (code {response.status_code})" + f" {endpoint} accessible sans auth (code {response.status_code})" ) all_protected = False except Exception as e: - print(f" ⚠️ {endpoint} erreur: {str(e)}") + print(f" {endpoint} erreur: {str(e)}") all_protected = False self.log_test("Endpoints API protégés", all_protected) @@ -116,7 +107,7 @@ class SecurityTester: def test_health_endpoint_public(self) -> bool: """Test 4: Endpoint /health devrait être accessible sans auth""" - print("\n🔍 Test 4: Endpoint /health public") + print("\n Test 4: Endpoint /health public") try: response = requests.get(f"{self.base_url}/health", timeout=5) @@ -138,10 +129,9 @@ class SecurityTester: def test_api_key_creation(self, username: str, password: str) -> Tuple[bool, str]: """Test 5: Créer une clé API via l'endpoint""" - print("\n🔍 Test 5: Création d'une clé API") + print("\n Test 5: Création d'une clé API") try: - # 1. Login pour obtenir un JWT login_response = requests.post( f"{self.base_url}/api/v1/auth/login", json={"email": username, "password": password}, @@ -158,7 +148,6 @@ class SecurityTester: jwt_token = login_response.json().get("access_token") - # 2. Créer une clé API create_response = requests.post( f"{self.base_url}/api/v1/api-keys", headers={"Authorization": f"Bearer {jwt_token}"}, @@ -189,7 +178,7 @@ class SecurityTester: def test_api_key_usage(self, api_key: str) -> bool: """Test 6: Utiliser une clé API pour accéder à un endpoint""" - print("\n🔍 Test 6: Utilisation d'une clé API") + print("\n Test 6: Utilisation d'une clé API") if not api_key: self.log_test("Utilisation clé API", False, "Pas de clé disponible") @@ -219,7 +208,7 @@ class SecurityTester: def test_invalid_api_key(self) -> bool: """Test 7: Une clé invalide devrait être refusée""" - print("\n🔍 Test 7: Rejet de clé API invalide") + print("\n Test 7: Rejet de clé API invalide") invalid_key = "sdk_live_invalid_key_12345" @@ -247,13 +236,12 @@ class SecurityTester: def test_rate_limiting(self, api_key: str) -> bool: """Test 8: Rate limiting (optionnel, peut prendre du temps)""" - print("\n🔍 Test 8: Rate limiting (test simple)") + print("\n Test 8: Rate limiting (test simple)") if not api_key: self.log_test("Rate limiting", False, "Pas de clé disponible") return False - # Envoyer 70 requêtes rapidement (limite = 60/min) print(" Envoi de 70 requêtes rapides...") rate_limited = False @@ -267,7 +255,7 @@ class SecurityTester: if response.status_code == 429: rate_limited = True - print(f" ⚠️ Rate limit atteint à la requête {i + 1}") + print(f" Rate limit atteint à la requête {i + 1}") break except Exception: @@ -287,22 +275,22 @@ class SecurityTester: def print_summary(self): """Afficher le résumé des tests""" print("\n" + "=" * 60) - print("📊 RÉSUMÉ DES TESTS") + print(" RÉSUMÉ DES TESTS") print("=" * 60) total = self.results["passed"] + self.results["failed"] success_rate = (self.results["passed"] / total * 100) if total > 0 else 0 print(f"\nTotal: {total} tests") - print(f"✅ Réussis: {self.results['passed']}") - print(f"❌ Échoués: {self.results['failed']}") - print(f"📈 Taux de réussite: {success_rate:.1f}%\n") + print(f" Réussis: {self.results['passed']}") + print(f" Échoués: {self.results['failed']}") + print(f"Taux de réussite: {success_rate:.1f}%\n") if self.results["failed"] == 0: print("🎉 Tous les tests sont passés ! Sécurité OK.") return 0 else: - print("⚠️ Certains tests ont échoué. Vérifiez la configuration.") + print(" Certains tests ont échoué. Vérifiez la configuration.") return 1 @@ -333,18 +321,16 @@ def main(): args = parser.parse_args() - print("🚀 Démarrage des tests de sécurité") - print(f"🎯 URL cible: {args.url}\n") + print(" Démarrage des tests de sécurité") + print(f" URL cible: {args.url}\n") tester = SecurityTester(args.url) - # Exécuter les tests tester.test_swagger_without_auth() tester.test_swagger_with_auth(args.swagger_user, args.swagger_pass) tester.test_api_without_auth() tester.test_health_endpoint_public() - # Tests nécessitant une clé API success, api_key = tester.test_api_key_creation( args.swagger_user, args.swagger_pass ) @@ -356,11 +342,10 @@ def main(): if not args.skip_rate_limit: tester.test_rate_limiting(api_key) else: - print("\n⏭️ Test de rate limiting sauté") + print("\n Test de rate limiting sauté") else: - print("\n⚠️ Tests avec clé API sautés (création échouée)") + print("\n Tests avec clé API sautés (création échouée)") - # Résumé exit_code = tester.print_summary() sys.exit(exit_code) diff --git a/services/api_key.py b/services/api_key.py index d54943a..ad3cf6f 100644 --- a/services/api_key.py +++ b/services/api_key.py @@ -21,7 +21,6 @@ class ApiKeyService: @staticmethod def generate_api_key() -> str: """Génère une clé API unique et sécurisée""" - # Format: sdk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx random_part = secrets.token_urlsafe(32) return f"sdk_live_{random_part}" @@ -33,7 +32,6 @@ class ApiKeyService: @staticmethod def get_key_prefix(api_key: str) -> str: """Extrait le préfixe de la clé pour identification""" - # Retourne les 12 premiers caractères return api_key[:12] if len(api_key) >= 12 else api_key async def create_api_key( @@ -46,23 +44,14 @@ class ApiKeyService: rate_limit_per_minute: int = 60, allowed_endpoints: Optional[List[str]] = None, ) -> tuple[ApiKey, str]: - """ - Crée une nouvelle clé API - - Returns: - tuple[ApiKey, str]: (objet ApiKey, clé en clair - à ne montrer qu'une fois) - """ - # Génération de la clé api_key_plain = self.generate_api_key() key_hash = self.hash_api_key(api_key_plain) key_prefix = self.get_key_prefix(api_key_plain) - # Calcul de la date d'expiration expires_at = None if expires_in_days: expires_at = datetime.now() + timedelta(days=expires_in_days) - # Création de l'objet api_key_obj = ApiKey( key_hash=key_hash, key_prefix=key_prefix, @@ -81,24 +70,18 @@ class ApiKeyService: await self.session.commit() await self.session.refresh(api_key_obj) - logger.info(f"✅ Clé API créée: {name} (prefix: {key_prefix})") + logger.info(f" Clé API créée: {name} (prefix: {key_prefix})") return api_key_obj, api_key_plain async def verify_api_key(self, api_key_plain: str) -> Optional[ApiKey]: - """ - Vérifie une clé API et retourne l'objet si valide - - Returns: - Optional[ApiKey]: L'objet ApiKey si valide, None sinon - """ key_hash = self.hash_api_key(api_key_plain) result = await self.session.execute( select(ApiKey).where( and_( ApiKey.key_hash == key_hash, - ApiKey.is_active == True, + ApiKey.is_active, ApiKey.revoked_at.is_(None), or_( ApiKey.expires_at.is_(None), ApiKey.expires_at > datetime.now() @@ -110,14 +93,13 @@ class ApiKeyService: api_key_obj = result.scalar_one_or_none() if api_key_obj: - # Mise à jour des statistiques api_key_obj.total_requests += 1 api_key_obj.last_used_at = datetime.now() await self.session.commit() - logger.debug(f"🔑 Clé API validée: {api_key_obj.name}") + logger.debug(f" Clé API validée: {api_key_obj.name}") else: - logger.warning(f"⚠️ Clé API invalide ou expirée") + logger.warning(" Clé API invalide ou expirée") return api_key_obj @@ -152,7 +134,7 @@ class ApiKeyService: api_key_obj.revoked_at = datetime.now() await self.session.commit() - logger.info(f"🚫 Clé API révoquée: {api_key_obj.name}") + logger.info(f" Clé API révoquée: {api_key_obj.name}") return True async def get_by_id(self, key_id: str) -> Optional[ApiKey]: @@ -161,14 +143,6 @@ class ApiKeyService: return result.scalar_one_or_none() async def check_rate_limit(self, api_key_obj: ApiKey) -> tuple[bool, Dict]: - """ - Vérifie le rate limit d'une clé API (à implémenter avec Redis/cache) - - Returns: - tuple[bool, Dict]: (is_allowed, info_dict) - """ - # TODO: Implémenter avec Redis pour un vrai rate limiting - # Pour l'instant, retourne toujours True return True, { "allowed": True, "limit": api_key_obj.rate_limit_per_minute, @@ -178,13 +152,11 @@ class ApiKeyService: async def check_endpoint_access(self, api_key_obj: ApiKey, endpoint: str) -> bool: """Vérifie si la clé a accès à un endpoint spécifique""" if not api_key_obj.allowed_endpoints: - # Si aucune restriction, accès total return True try: allowed = json.loads(api_key_obj.allowed_endpoints) - # Support des wildcards for pattern in allowed: if pattern == "*": return True @@ -197,7 +169,7 @@ class ApiKeyService: return False except json.JSONDecodeError: - logger.error(f"❌ Erreur parsing allowed_endpoints pour {api_key_obj.id}") + logger.error(f" Erreur parsing allowed_endpoints pour {api_key_obj.id}") return False diff --git a/services/universign_document.py b/services/universign_document.py index 9cb714e..394c3ce 100644 --- a/services/universign_document.py +++ b/services/universign_document.py @@ -23,7 +23,7 @@ class UniversignDocumentService: def fetch_transaction_documents(self, transaction_id: str) -> Optional[List[Dict]]: try: - logger.info(f"📋 Récupération documents pour transaction: {transaction_id}") + logger.info(f" Récupération documents pour transaction: {transaction_id}") response = requests.get( f"{self.api_url}/transactions/{transaction_id}", diff --git a/services/universign_sync.py b/services/universign_sync.py index 807802d..da634f2 100644 --- a/services/universign_sync.py +++ b/services/universign_sync.py @@ -344,7 +344,7 @@ class UniversignSyncService: universign_data = result["transaction"] universign_status_raw = universign_data.get("state", "draft") - logger.info(f"📊 Statut Universign brut: {universign_status_raw}") + logger.info(f" Statut Universign brut: {universign_status_raw}") new_local_status = map_universign_to_local(universign_status_raw) previous_local_status = transaction.local_status.value @@ -367,7 +367,7 @@ class UniversignSyncService: if status_changed: logger.info( - f"🔔 CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" + f"CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" ) try: @@ -392,7 +392,7 @@ class UniversignSyncService: if new_local_status == "EN_COURS" and not transaction.sent_at: transaction.sent_at = datetime.now() - logger.info("📅 Date d'envoi mise à jour") + logger.info("Date d'envoi mise à jour") if new_local_status == "SIGNE" and not transaction.signed_at: transaction.signed_at = datetime.now() @@ -404,8 +404,7 @@ class UniversignSyncService: if new_local_status == "EXPIRE" and not transaction.expired_at: transaction.expired_at = datetime.now() - logger.info("⏰ Date d'expiration mise à jour") - + logger.info("Date d'expiration mise à jour") documents = universign_data.get("documents", []) if documents: @@ -432,9 +431,7 @@ class UniversignSyncService: logger.warning(f"Échec téléchargement: {download_error}") except Exception as e: - logger.error( - f" Erreur téléchargement document: {e}", exc_info=True - ) + logger.error(f" Erreur téléchargement document: {e}", exc_info=True) await self._sync_signers(session, transaction, universign_data) @@ -529,9 +526,7 @@ class UniversignSyncService: logger.warning(f"Échec téléchargement: {download_error}") except Exception as e: - logger.error( - f" Erreur téléchargement document: {e}", exc_info=True - ) + logger.error(f" Erreur téléchargement document: {e}", exc_info=True) else: logger.debug( f"Document déjà téléchargé: {transaction.signed_document_path}" diff --git a/utils/generic_functions.py b/utils/generic_functions.py index 4cce52f..29a361e 100644 --- a/utils/generic_functions.py +++ b/utils/generic_functions.py @@ -425,7 +425,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = { "REFUSE": { "fr": "Signature refusée", "en": "Signature refused", - "icon": "❌", + "icon": "", "color": "red", }, "EXPIRE": { @@ -437,7 +437,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = { "ERREUR": { "fr": "Erreur technique", "en": "Technical error", - "icon": "⚠️", + "icon": "", "color": "red", }, } diff --git a/utils/universign_status_mapping.py b/utils/universign_status_mapping.py index 50e29cc..8391698 100644 --- a/utils/universign_status_mapping.py +++ b/utils/universign_status_mapping.py @@ -96,7 +96,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = { "REFUSE": { "fr": "Signature refusée", "en": "Signature refused", - "icon": "❌", + "icon": "", "color": "red", }, "EXPIRE": { From abc9ff820a807d67ecdced7b3ba2bd69d5733a80 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 11:11:32 +0300 Subject: [PATCH 05/30] feat(security): implement api key management and authentication system --- config/cors_config.py | 125 +++++++++++++ database/models/api_key.py | 56 ++++++ middleware/security.py | 236 +++++++++++++++++++++++++ routes/api_keys.py | 154 ++++++++++++++++ schemas/api_key.py | 77 ++++++++ scripts/manage_security.py | 264 +++++++++++++++++++++++++++ scripts/test_security.py | 354 +++++++++++++++++++++++++++++++++++++ services/api_key.py | 205 +++++++++++++++++++++ 8 files changed, 1471 insertions(+) create mode 100644 config/cors_config.py create mode 100644 database/models/api_key.py create mode 100644 middleware/security.py create mode 100644 routes/api_keys.py create mode 100644 schemas/api_key.py create mode 100644 scripts/manage_security.py create mode 100644 scripts/test_security.py create mode 100644 services/api_key.py diff --git a/config/cors_config.py b/config/cors_config.py new file mode 100644 index 0000000..0f3a4d2 --- /dev/null +++ b/config/cors_config.py @@ -0,0 +1,125 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from typing import List +import os +import logging + +logger = logging.getLogger(__name__) + + +def configure_cors_open(app: FastAPI): + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["*"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info(" CORS configuré: Mode OUVERT (sécurisé par API Keys)") + logger.info(" - Origins: * (toutes)") + logger.info(" - Headers: * (dont X-API-Key)") + logger.info(" - Credentials: False") + + +def configure_cors_whitelist(app: FastAPI): + allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "") + + if allowed_origins_str: + allowed_origins = [ + origin.strip() + for origin in allowed_origins_str.split(",") + if origin.strip() + ] + else: + allowed_origins = ["*"] + + app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info(" CORS configuré: Mode WHITELIST") + logger.info(f" - Origins autorisées: {len(allowed_origins)}") + for origin in allowed_origins: + logger.info(f" • {origin}") + + +def configure_cors_regex(app: FastAPI): + origin_regex = r"*" + + app.add_middleware( + CORSMiddleware, + allow_origin_regex=origin_regex, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info(" CORS configuré: Mode REGEX") + logger.info(f" - Pattern: {origin_regex}") + + +def configure_cors_hybrid(app: FastAPI): + from starlette.middleware.base import BaseHTTPMiddleware + + class HybridCORSMiddleware(BaseHTTPMiddleware): + def __init__(self, app, known_origins: List[str]): + super().__init__(app) + self.known_origins = set(known_origins) + + async def dispatch(self, request, call_next): + origin = request.headers.get("origin") + + if origin in self.known_origins: + response = await call_next(request) + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, PATCH, OPTIONS" + ) + response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization, X-API-Key" + ) + return response + + response = await call_next(request) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, PATCH, OPTIONS" + ) + response.headers["Access-Control-Allow-Headers"] = "*" + return response + + known_origins = ["*"] + + app.add_middleware(HybridCORSMiddleware, known_origins=known_origins) + + logger.info(" CORS configuré: Mode HYBRIDE") + logger.info(f" - Whitelist: {len(known_origins)} domaines") + logger.info(" - Fallback: * (ouvert)") + + +def setup_cors(app: FastAPI, mode: str = "open"): + if mode == "open": + configure_cors_open(app) + elif mode == "whitelist": + configure_cors_whitelist(app) + elif mode == "regex": + configure_cors_regex(app) + elif mode == "hybrid": + configure_cors_hybrid(app) + else: + logger.warning( + f" Mode CORS inconnu: {mode}. Utilisation de 'open' par défaut." + ) + configure_cors_open(app) diff --git a/database/models/api_key.py b/database/models/api_key.py new file mode 100644 index 0000000..0d246ab --- /dev/null +++ b/database/models/api_key.py @@ -0,0 +1,56 @@ +from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text +from datetime import datetime +import uuid + +from database.models.generic_model import Base + + +class ApiKey(Base): + """Modèle pour les clés API publiques""" + + __tablename__ = "api_keys" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + key_hash = Column(String(64), unique=True, nullable=False, index=True) + key_prefix = Column(String(10), nullable=False) + + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + + user_id = Column(String(36), nullable=True) + created_by = Column(String(255), nullable=False) + + is_active = Column(Boolean, default=True, nullable=False) + rate_limit_per_minute = Column(Integer, default=60, nullable=False) + allowed_endpoints = Column(Text, nullable=True) + + total_requests = Column(Integer, default=0, nullable=False) + last_used_at = Column(DateTime, nullable=True) + + created_at = Column(DateTime, default=datetime.now, nullable=False) + expires_at = Column(DateTime, nullable=True) + revoked_at = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" + + +class SwaggerUser(Base): + """Modèle pour les utilisateurs autorisés à accéder au Swagger""" + + __tablename__ = "swagger_users" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + username = Column(String(100), unique=True, nullable=False, index=True) + hashed_password = Column(String(255), nullable=False) + + full_name = Column(String(255), nullable=True) + email = Column(String(255), nullable=True) + + is_active = Column(Boolean, default=True, nullable=False) + + created_at = Column(DateTime, default=datetime.now, nullable=False) + last_login = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" diff --git a/middleware/security.py b/middleware/security.py new file mode 100644 index 0000000..137e7dd --- /dev/null +++ b/middleware/security.py @@ -0,0 +1,236 @@ +from fastapi import Request, status +from fastapi.responses import JSONResponse +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from sqlalchemy import select +from typing import Optional +from datetime import datetime +import logging + +from database import get_session +from database.models.api_key import SwaggerUser +from security.auth import verify_password + +logger = logging.getLogger(__name__) + +security = HTTPBasic() + + +async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: + username = credentials.username + password = credentials.password + + try: + async for session in get_session(): + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + swagger_user = result.scalar_one_or_none() + + if swagger_user and swagger_user.is_active: + if verify_password(password, swagger_user.hashed_password): + swagger_user.last_login = datetime.now() + await session.commit() + + logger.info(f" Accès Swagger autorisé (DB): {username}") + return True + + logger.warning(f" Tentative d'accès Swagger refusée: {username}") + return False + + except Exception as e: + logger.error(f" Erreur vérification Swagger credentials: {e}") + return False + + +class SwaggerAuthMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive=receive) + path = request.url.path + + protected_paths = ["/docs", "/redoc", "/openapi.json"] + + if any(path.startswith(protected_path) for protected_path in protected_paths): + auth_header = request.headers.get("Authorization") + + if not auth_header or not auth_header.startswith("Basic "): + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise pour accéder à la documentation" + }, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + try: + import base64 + + encoded_credentials = auth_header.split(" ")[1] + decoded_credentials = base64.b64decode(encoded_credentials).decode( + "utf-8" + ) + username, password = decoded_credentials.split(":", 1) + + credentials = HTTPBasicCredentials(username=username, password=password) + + if not await verify_swagger_credentials(credentials): + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Identifiants invalides"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + except Exception as e: + logger.error(f" Erreur parsing auth header: {e}") + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Format d'authentification invalide"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + +class ApiKeyMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive=receive) + path = request.url.path + + excluded_paths = [ + "/docs", + "/redoc", + "/openapi.json", + "/health", + "/", + "/auth/login", + "/auth/register", + "/auth/verify-email", + "/auth/reset-password", + "/auth/request-reset", + "/auth/refresh", + ] + + if any(path.startswith(excluded_path) for excluded_path in excluded_paths): + await self.app(scope, receive, send) + return + + auth_header = request.headers.get("Authorization") + has_jwt = auth_header and auth_header.startswith("Bearer ") + + api_key = request.headers.get("X-API-Key") + has_api_key = api_key is not None + + if has_jwt: + logger.debug(f" JWT détecté pour {path}") + await self.app(scope, receive, send) + return + + elif has_api_key: + logger.debug(f" API Key détectée pour {path}") + + from services.api_key import ApiKeyService + + try: + async for session in get_session(): + api_key_service = ApiKeyService(session) + api_key_obj = await api_key_service.verify_api_key(api_key) + + if not api_key_obj: + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Clé API invalide ou expirée", + "hint": "Utilisez X-API-Key: sdk_live_xxx ou Authorization: Bearer ", + }, + ) + await response(scope, receive, send) + return + + is_allowed, rate_info = await api_key_service.check_rate_limit( + api_key_obj + ) + if not is_allowed: + response = JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content={"detail": "Rate limit dépassé"}, + headers={ + "X-RateLimit-Limit": str(rate_info["limit"]), + "X-RateLimit-Remaining": "0", + }, + ) + await response(scope, receive, send) + return + + has_access = await api_key_service.check_endpoint_access( + api_key_obj, path + ) + if not has_access: + response = JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={ + "detail": "Accès non autorisé à cet endpoint", + "endpoint": path, + "api_key": api_key_obj.key_prefix + "...", + }, + ) + await response(scope, receive, send) + return + + request.state.api_key = api_key_obj + request.state.authenticated_via = "api_key" + + logger.info(f" API Key valide: {api_key_obj.name} → {path}") + + await self.app(scope, receive, send) + return + + except Exception as e: + logger.error(f" Erreur validation API Key: {e}", exc_info=True) + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "detail": "Erreur interne lors de la validation de la clé" + }, + ) + await response(scope, receive, send) + return + + else: + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise", + "hint": "Utilisez soit 'X-API-Key: sdk_live_xxx' soit 'Authorization: Bearer '", + }, + headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, + ) + await response(scope, receive, send) + return + + +def get_api_key_from_request(request: Request) -> Optional: + """Récupère l'objet ApiKey depuis la requête si présent""" + return getattr(request.state, "api_key", None) + + +def get_auth_method(request: Request) -> str: + return getattr(request.state, "authenticated_via", "none") diff --git a/routes/api_keys.py b/routes/api_keys.py new file mode 100644 index 0000000..27f0efc --- /dev/null +++ b/routes/api_keys.py @@ -0,0 +1,154 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +import logging + +from database import get_session, User +from core.dependencies import get_current_user, require_role +from services.api_key import ApiKeyService, api_key_to_response +from schemas.api_key import ( + ApiKeyCreate, + ApiKeyCreatedResponse, + ApiKeyResponse, + ApiKeyList, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api-keys", tags=["API Keys Management"]) + + +@router.post( + "", + response_model=ApiKeyCreatedResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_role("admin", "super_admin"))], +) +async def create_api_key( + data: ApiKeyCreate, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = ApiKeyService(session) + + api_key_obj, api_key_plain = await service.create_api_key( + name=data.name, + description=data.description, + created_by=user.email, + user_id=user.id, + expires_in_days=data.expires_in_days, + rate_limit_per_minute=data.rate_limit_per_minute, + allowed_endpoints=data.allowed_endpoints, + ) + + logger.info(f" Clé API créée par {user.email}: {data.name}") + + response_data = api_key_to_response(api_key_obj) + response_data["api_key"] = api_key_plain + + return ApiKeyCreatedResponse(**response_data) + + +@router.get("", response_model=ApiKeyList) +async def list_api_keys( + include_revoked: bool = Query(False, description="Inclure les clés révoquées"), + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = ApiKeyService(session) + + user_id = None if user.role in ["admin", "super_admin"] else user.id + + keys = await service.list_api_keys(include_revoked=include_revoked, user_id=user_id) + + items = [ApiKeyResponse(**api_key_to_response(k)) for k in keys] + + return ApiKeyList(total=len(items), items=items) + + +@router.get("/{key_id}", response_model=ApiKeyResponse) +async def get_api_key( + key_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """ Récupérer une clé API par son ID""" + service = ApiKeyService(session) + + api_key_obj = await service.get_by_id(key_id) + + if not api_key_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Clé API {key_id} introuvable", + ) + + if user.role not in ["admin", "super_admin"]: + if api_key_obj.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Accès refusé à cette clé", + ) + + return ApiKeyResponse(**api_key_to_response(api_key_obj)) + + +@router.delete("/{key_id}", status_code=status.HTTP_200_OK) +async def revoke_api_key( + key_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = ApiKeyService(session) + + api_key_obj = await service.get_by_id(key_id) + + if not api_key_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Clé API {key_id} introuvable", + ) + + if user.role not in ["admin", "super_admin"]: + if api_key_obj.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Accès refusé à cette clé", + ) + + success = await service.revoke_api_key(key_id) + + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erreur lors de la révocation", + ) + + logger.info(f" Clé API révoquée par {user.email}: {api_key_obj.name}") + + return { + "success": True, + "message": f"Clé API '{api_key_obj.name}' révoquée avec succès", + } + + +@router.post("/verify", status_code=status.HTTP_200_OK) +async def verify_api_key_endpoint( + api_key: str = Query(..., description="Clé API à vérifier"), + session: AsyncSession = Depends(get_session), +): + service = ApiKeyService(session) + + api_key_obj = await service.verify_api_key(api_key) + + if not api_key_obj: + return { + "valid": False, + "message": "Clé API invalide, expirée ou révoquée", + } + + return { + "valid": True, + "message": "Clé API valide", + "key_name": api_key_obj.name, + "rate_limit": api_key_obj.rate_limit_per_minute, + "expires_at": api_key_obj.expires_at, + } diff --git a/schemas/api_key.py b/schemas/api_key.py new file mode 100644 index 0000000..4ec49b6 --- /dev/null +++ b/schemas/api_key.py @@ -0,0 +1,77 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + + +class ApiKeyCreate(BaseModel): + """Schema pour créer une clé API""" + + name: str = Field(..., min_length=3, max_length=255, description="Nom de la clé") + description: Optional[str] = Field(None, description="Description de l'usage") + expires_in_days: Optional[int] = Field( + None, ge=1, le=3650, description="Expiration en jours (max 10 ans)" + ) + rate_limit_per_minute: int = Field( + 60, ge=1, le=1000, description="Limite de requêtes par minute" + ) + allowed_endpoints: Optional[List[str]] = Field( + None, description="Endpoints autorisés ([] = tous, ['/clients*'] = wildcard)" + ) + + +class ApiKeyResponse(BaseModel): + """Schema de réponse pour une clé API""" + + id: str + name: str + description: Optional[str] + key_prefix: str + is_active: bool + is_expired: bool + rate_limit_per_minute: int + allowed_endpoints: Optional[List[str]] + total_requests: int + last_used_at: Optional[datetime] + created_at: datetime + expires_at: Optional[datetime] + revoked_at: Optional[datetime] + created_by: str + + +class ApiKeyCreatedResponse(ApiKeyResponse): + """Schema de réponse après création (inclut la clé en clair)""" + + api_key: str = Field( + ..., description=" Clé API en clair - à sauvegarder immédiatement" + ) + + +class ApiKeyList(BaseModel): + """Liste de clés API""" + + total: int + items: List[ApiKeyResponse] + + +class SwaggerUserCreate(BaseModel): + """Schema pour créer un utilisateur Swagger""" + + username: str = Field(..., min_length=3, max_length=100) + password: str = Field(..., min_length=8) + full_name: Optional[str] = None + email: Optional[str] = None + + +class SwaggerUserResponse(BaseModel): + """Schema de réponse pour un utilisateur Swagger""" + + id: str + username: str + full_name: Optional[str] + email: Optional[str] + is_active: bool + created_at: datetime + last_login: Optional[datetime] + + class Config: + from_attributes = True diff --git a/scripts/manage_security.py b/scripts/manage_security.py new file mode 100644 index 0000000..1f234b9 --- /dev/null +++ b/scripts/manage_security.py @@ -0,0 +1,264 @@ +import asyncio +import sys +from pathlib import Path +import argparse + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from database import get_session +from database.models.api_key import SwaggerUser +from services.api_key import ApiKeyService +from security.auth import hash_password +from sqlalchemy import select +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def add_swagger_user(username: str, password: str, full_name: str = None): + """Ajouter un utilisateur Swagger""" + async with get_session() as session: + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + existing = result.scalar_one_or_none() + + if existing: + logger.error(f" L'utilisateur {username} existe déjà") + return + + user = SwaggerUser( + username=username, + hashed_password=hash_password(password), + full_name=full_name or username, + is_active=True, + ) + + session.add(user) + await session.commit() + + logger.info(f" Utilisateur Swagger créé: {username}") + print("\n Utilisateur créé avec succès") + print(f" Username: {username}") + print(" Accès: https://votre-serveur/docs") + + +async def list_swagger_users(): + """Lister les utilisateurs Swagger""" + async with get_session() as session: + result = await session.execute(select(SwaggerUser)) + users = result.scalars().all() + + if not users: + print("Aucun utilisateur Swagger trouvé") + return + + print(f"\n {len(users)} utilisateur(s) Swagger:\n") + for user in users: + status = " Actif" if user.is_active else " Inactif" + print(f" • {user.username:<20} {status}") + if user.full_name: + print(f" Nom: {user.full_name}") + if user.last_login: + print(f" Dernière connexion: {user.last_login}") + print() + + +async def delete_swagger_user(username: str): + """Supprimer un utilisateur Swagger""" + async with get_session() as session: + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + user = result.scalar_one_or_none() + + if not user: + logger.error(f" Utilisateur {username} introuvable") + return + + await session.delete(user) + await session.commit() + + logger.info(f"🗑️ Utilisateur supprimé: {username}") + + +async def create_api_key( + name: str, + description: str = None, + expires_in_days: int = 365, + rate_limit: int = 60, + endpoints: list = None, +): + """Créer une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + + api_key_obj, api_key_plain = await service.create_api_key( + name=name, + description=description, + created_by="CLI", + expires_in_days=expires_in_days, + rate_limit_per_minute=rate_limit, + allowed_endpoints=endpoints, + ) + + print("\n Clé API créée avec succès\n") + print(f" ID: {api_key_obj.id}") + print(f" Nom: {name}") + print(f" Clé: {api_key_plain}") + print(f" Préfixe: {api_key_obj.key_prefix}") + print(f" Rate limit: {rate_limit} req/min") + print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}") + print("\n IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !\n") + + +async def list_api_keys(): + """Lister les clés API""" + async with get_session() as session: + service = ApiKeyService(session) + keys = await service.list_api_keys() + + if not keys: + print("Aucune clé API trouvée") + return + + print(f"\n {len(keys)} clé(s) API:\n") + for key in keys: + status = "" if key.is_active else "" + expired = ( + "⏰ Expirée" + if key.expires_at and key.expires_at < datetime.now() + else "" + ) + + print(f" {status} {key.name:<30} ({key.key_prefix}...)") + print(f" ID: {key.id}") + print(f" Requêtes: {key.total_requests}") + print(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + if expired: + print(f" {expired}") + print() + + +async def revoke_api_key(key_id: str): + """Révoquer une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + + api_key = await service.get_by_id(key_id) + if not api_key: + logger.error(f" Clé {key_id} introuvable") + return + + success = await service.revoke_api_key(key_id) + + if success: + logger.info(f" Clé révoquée: {api_key.name}") + print(f"\n Clé '{api_key.name}' révoquée avec succès") + else: + logger.error(" Erreur lors de la révocation") + + +async def verify_api_key_cmd(api_key: str): + """Vérifier une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + api_key_obj = await service.verify_api_key(api_key) + + if api_key_obj: + print("\n Clé API valide\n") + print(f" Nom: {api_key_obj.name}") + print(f" ID: {api_key_obj.id}") + print(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") + print(f" Requêtes: {api_key_obj.total_requests}") + print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}\n") + else: + print("\n Clé API invalide, expirée ou révoquée\n") + + +async def main(): + parser = argparse.ArgumentParser( + description="Gestion de la sécurité Sage Dataven API" + ) + + subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles") + + swagger_parser = subparsers.add_parser( + "swagger", help="Gestion utilisateurs Swagger" + ) + swagger_subparsers = swagger_parser.add_subparsers(dest="action") + + swagger_add = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") + swagger_add.add_argument("username", help="Nom d'utilisateur") + swagger_add.add_argument("password", help="Mot de passe") + swagger_add.add_argument("--full-name", help="Nom complet") + + swagger_subparsers.add_parser("list", help="Lister les utilisateurs") + + swagger_delete = swagger_subparsers.add_parser( + "delete", help="Supprimer un utilisateur" + ) + swagger_delete.add_argument("username", help="Nom d'utilisateur") + + apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API") + apikey_subparsers = apikey_parser.add_subparsers(dest="action") + + apikey_create = apikey_subparsers.add_parser("create", help="Créer une clé API") + apikey_create.add_argument("name", help="Nom de la clé") + apikey_create.add_argument("--description", help="Description") + apikey_create.add_argument( + "--days", type=int, default=365, help="Expiration en jours" + ) + apikey_create.add_argument( + "--rate-limit", type=int, default=60, help="Limite req/min" + ) + apikey_create.add_argument("--endpoints", nargs="+", help="Endpoints autorisés") + + apikey_subparsers.add_parser("list", help="Lister les clés") + + apikey_revoke = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") + apikey_revoke.add_argument("key_id", help="ID de la clé") + + apikey_verify = apikey_subparsers.add_parser("verify", help="Vérifier une clé") + apikey_verify.add_argument("api_key", help="Clé API à vérifier") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + if args.command == "swagger": + if args.action == "add": + await add_swagger_user(args.username, args.password, args.full_name) + elif args.action == "list": + await list_swagger_users() + elif args.action == "delete": + await delete_swagger_user(args.username) + else: + swagger_parser.print_help() + + elif args.command == "apikey": + if args.action == "create": + await create_api_key( + args.name, + args.description, + args.days, + args.rate_limit, + args.endpoints, + ) + elif args.action == "list": + await list_api_keys() + elif args.action == "revoke": + await revoke_api_key(args.key_id) + elif args.action == "verify": + await verify_api_key_cmd(args.api_key) + else: + apikey_parser.print_help() + + +if __name__ == "__main__": + from datetime import datetime + + asyncio.run(main()) diff --git a/scripts/test_security.py b/scripts/test_security.py new file mode 100644 index 0000000..497870e --- /dev/null +++ b/scripts/test_security.py @@ -0,0 +1,354 @@ +import requests +import argparse +import sys +from typing import Tuple + + +class SecurityTester: + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + self.results = {"passed": 0, "failed": 0, "tests": []} + + def log_test(self, name: str, passed: bool, details: str = ""): + """Enregistrer le résultat d'un test""" + status = " PASS" if passed else " FAIL" + print(f"{status} - {name}") + if details: + print(f" {details}") + + self.results["tests"].append( + {"name": name, "passed": passed, "details": details} + ) + + if passed: + self.results["passed"] += 1 + else: + self.results["failed"] += 1 + + def test_swagger_without_auth(self) -> bool: + """Test 1: Swagger UI devrait demander une authentification""" + print("\n Test 1: Protection Swagger UI") + + try: + response = requests.get(f"{self.base_url}/docs", timeout=5) + + if response.status_code == 401: + self.log_test( + "Swagger protégé", + True, + "Code 401 retourné sans authentification", + ) + return True + else: + self.log_test( + "Swagger protégé", + False, + f"Code {response.status_code} au lieu de 401", + ) + return False + + except Exception as e: + self.log_test("Swagger protégé", False, f"Erreur: {str(e)}") + return False + + def test_swagger_with_auth(self, username: str, password: str) -> bool: + """Test 2: Swagger UI accessible avec credentials valides""" + print("\n Test 2: Accès Swagger avec authentification") + + try: + response = requests.get( + f"{self.base_url}/docs", auth=(username, password), timeout=5 + ) + + if response.status_code == 200: + self.log_test( + "Accès Swagger avec auth", + True, + f"Authentifié comme {username}", + ) + return True + else: + self.log_test( + "Accès Swagger avec auth", + False, + f"Code {response.status_code}, credentials invalides?", + ) + return False + + except Exception as e: + self.log_test("Accès Swagger avec auth", False, f"Erreur: {str(e)}") + return False + + def test_api_without_auth(self) -> bool: + """Test 3: Endpoints API devraient demander une authentification""" + print("\n Test 3: Protection des endpoints API") + + test_endpoints = ["/api/v1/clients", "/api/v1/documents"] + + all_protected = True + for endpoint in test_endpoints: + try: + response = requests.get(f"{self.base_url}{endpoint}", timeout=5) + + if response.status_code == 401: + print(f" {endpoint} protégé (401)") + else: + print( + f" {endpoint} accessible sans auth (code {response.status_code})" + ) + all_protected = False + + except Exception as e: + print(f" {endpoint} erreur: {str(e)}") + all_protected = False + + self.log_test("Endpoints API protégés", all_protected) + return all_protected + + def test_health_endpoint_public(self) -> bool: + """Test 4: Endpoint /health devrait être accessible sans auth""" + print("\n Test 4: Endpoint /health public") + + try: + response = requests.get(f"{self.base_url}/health", timeout=5) + + if response.status_code == 200: + self.log_test("/health accessible", True, "Endpoint public fonctionne") + return True + else: + self.log_test( + "/health accessible", + False, + f"Code {response.status_code} inattendu", + ) + return False + + except Exception as e: + self.log_test("/health accessible", False, f"Erreur: {str(e)}") + return False + + def test_api_key_creation(self, username: str, password: str) -> Tuple[bool, str]: + """Test 5: Créer une clé API via l'endpoint""" + print("\n Test 5: Création d'une clé API") + + try: + login_response = requests.post( + f"{self.base_url}/api/v1/auth/login", + json={"email": username, "password": password}, + timeout=5, + ) + + if login_response.status_code != 200: + self.log_test( + "Création clé API", + False, + "Impossible de se connecter pour obtenir un JWT", + ) + return False, "" + + jwt_token = login_response.json().get("access_token") + + create_response = requests.post( + f"{self.base_url}/api/v1/api-keys", + headers={"Authorization": f"Bearer {jwt_token}"}, + json={ + "name": "Test API Key", + "description": "Clé de test automatisé", + "rate_limit_per_minute": 60, + "expires_in_days": 30, + }, + timeout=5, + ) + + if create_response.status_code == 201: + api_key = create_response.json().get("api_key") + self.log_test("Création clé API", True, f"Clé créée: {api_key[:20]}...") + return True, api_key + else: + self.log_test( + "Création clé API", + False, + f"Code {create_response.status_code}", + ) + return False, "" + + except Exception as e: + self.log_test("Création clé API", False, f"Erreur: {str(e)}") + return False, "" + + def test_api_key_usage(self, api_key: str) -> bool: + """Test 6: Utiliser une clé API pour accéder à un endpoint""" + print("\n Test 6: Utilisation d'une clé API") + + if not api_key: + self.log_test("Utilisation clé API", False, "Pas de clé disponible") + return False + + try: + response = requests.get( + f"{self.base_url}/api/v1/clients", + headers={"X-API-Key": api_key}, + timeout=5, + ) + + if response.status_code == 200: + self.log_test("Utilisation clé API", True, "Clé acceptée") + return True + else: + self.log_test( + "Utilisation clé API", + False, + f"Code {response.status_code}, clé refusée?", + ) + return False + + except Exception as e: + self.log_test("Utilisation clé API", False, f"Erreur: {str(e)}") + return False + + def test_invalid_api_key(self) -> bool: + """Test 7: Une clé invalide devrait être refusée""" + print("\n Test 7: Rejet de clé API invalide") + + invalid_key = "sdk_live_invalid_key_12345" + + try: + response = requests.get( + f"{self.base_url}/api/v1/clients", + headers={"X-API-Key": invalid_key}, + timeout=5, + ) + + if response.status_code == 401: + self.log_test("Clé invalide rejetée", True, "Code 401 comme attendu") + return True + else: + self.log_test( + "Clé invalide rejetée", + False, + f"Code {response.status_code} au lieu de 401", + ) + return False + + except Exception as e: + self.log_test("Clé invalide rejetée", False, f"Erreur: {str(e)}") + return False + + def test_rate_limiting(self, api_key: str) -> bool: + """Test 8: Rate limiting (optionnel, peut prendre du temps)""" + print("\n Test 8: Rate limiting (test simple)") + + if not api_key: + self.log_test("Rate limiting", False, "Pas de clé disponible") + return False + + print(" Envoi de 70 requêtes rapides...") + + rate_limited = False + for i in range(70): + try: + response = requests.get( + f"{self.base_url}/health", + headers={"X-API-Key": api_key}, + timeout=1, + ) + + if response.status_code == 429: + rate_limited = True + print(f" Rate limit atteint à la requête {i + 1}") + break + + except Exception: + pass + + if rate_limited: + self.log_test("Rate limiting", True, "Rate limit détecté") + return True + else: + self.log_test( + "Rate limiting", + True, + "Aucun rate limit détecté (peut être normal si pas implémenté)", + ) + return True + + def print_summary(self): + """Afficher le résumé des tests""" + print("\n" + "=" * 60) + print(" RÉSUMÉ DES TESTS") + print("=" * 60) + + total = self.results["passed"] + self.results["failed"] + success_rate = (self.results["passed"] / total * 100) if total > 0 else 0 + + print(f"\nTotal: {total} tests") + print(f" Réussis: {self.results['passed']}") + print(f" Échoués: {self.results['failed']}") + print(f"Taux de réussite: {success_rate:.1f}%\n") + + if self.results["failed"] == 0: + print("🎉 Tous les tests sont passés ! Sécurité OK.") + return 0 + else: + print(" Certains tests ont échoué. Vérifiez la configuration.") + return 1 + + +def main(): + parser = argparse.ArgumentParser( + description="Test automatisé de la sécurité de l'API" + ) + + parser.add_argument( + "--url", + required=True, + help="URL de base de l'API (ex: http://localhost:8000)", + ) + + parser.add_argument( + "--swagger-user", required=True, help="Utilisateur Swagger pour les tests" + ) + + parser.add_argument( + "--swagger-pass", required=True, help="Mot de passe Swagger pour les tests" + ) + + parser.add_argument( + "--skip-rate-limit", + action="store_true", + help="Sauter le test de rate limiting (long)", + ) + + args = parser.parse_args() + + print(" Démarrage des tests de sécurité") + print(f" URL cible: {args.url}\n") + + tester = SecurityTester(args.url) + + tester.test_swagger_without_auth() + tester.test_swagger_with_auth(args.swagger_user, args.swagger_pass) + tester.test_api_without_auth() + tester.test_health_endpoint_public() + + success, api_key = tester.test_api_key_creation( + args.swagger_user, args.swagger_pass + ) + + if success and api_key: + tester.test_api_key_usage(api_key) + tester.test_invalid_api_key() + + if not args.skip_rate_limit: + tester.test_rate_limiting(api_key) + else: + print("\n Test de rate limiting sauté") + else: + print("\n Tests avec clé API sautés (création échouée)") + + exit_code = tester.print_summary() + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/services/api_key.py b/services/api_key.py new file mode 100644 index 0000000..ad3cf6f --- /dev/null +++ b/services/api_key.py @@ -0,0 +1,205 @@ +import secrets +import hashlib +import json +from datetime import datetime, timedelta +from typing import Optional, List, Dict +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_ +import logging + +from database.models.api_key import ApiKey + +logger = logging.getLogger(__name__) + + +class ApiKeyService: + """Service de gestion des clés API""" + + def __init__(self, session: AsyncSession): + self.session = session + + @staticmethod + def generate_api_key() -> str: + """Génère une clé API unique et sécurisée""" + random_part = secrets.token_urlsafe(32) + return f"sdk_live_{random_part}" + + @staticmethod + def hash_api_key(api_key: str) -> str: + """Hash la clé API pour stockage sécurisé""" + return hashlib.sha256(api_key.encode()).hexdigest() + + @staticmethod + def get_key_prefix(api_key: str) -> str: + """Extrait le préfixe de la clé pour identification""" + return api_key[:12] if len(api_key) >= 12 else api_key + + async def create_api_key( + self, + name: str, + description: Optional[str] = None, + created_by: str = "system", + user_id: Optional[str] = None, + expires_in_days: Optional[int] = None, + rate_limit_per_minute: int = 60, + allowed_endpoints: Optional[List[str]] = None, + ) -> tuple[ApiKey, str]: + api_key_plain = self.generate_api_key() + key_hash = self.hash_api_key(api_key_plain) + key_prefix = self.get_key_prefix(api_key_plain) + + expires_at = None + if expires_in_days: + expires_at = datetime.now() + timedelta(days=expires_in_days) + + api_key_obj = ApiKey( + key_hash=key_hash, + key_prefix=key_prefix, + name=name, + description=description, + created_by=created_by, + user_id=user_id, + expires_at=expires_at, + rate_limit_per_minute=rate_limit_per_minute, + allowed_endpoints=json.dumps(allowed_endpoints) + if allowed_endpoints + else None, + ) + + self.session.add(api_key_obj) + await self.session.commit() + await self.session.refresh(api_key_obj) + + logger.info(f" Clé API créée: {name} (prefix: {key_prefix})") + + return api_key_obj, api_key_plain + + async def verify_api_key(self, api_key_plain: str) -> Optional[ApiKey]: + key_hash = self.hash_api_key(api_key_plain) + + result = await self.session.execute( + select(ApiKey).where( + and_( + ApiKey.key_hash == key_hash, + ApiKey.is_active, + ApiKey.revoked_at.is_(None), + or_( + ApiKey.expires_at.is_(None), ApiKey.expires_at > datetime.now() + ), + ) + ) + ) + + api_key_obj = result.scalar_one_or_none() + + if api_key_obj: + api_key_obj.total_requests += 1 + api_key_obj.last_used_at = datetime.now() + await self.session.commit() + + logger.debug(f" Clé API validée: {api_key_obj.name}") + else: + logger.warning(" Clé API invalide ou expirée") + + return api_key_obj + + async def list_api_keys( + self, + include_revoked: bool = False, + user_id: Optional[str] = None, + ) -> List[ApiKey]: + """Liste les clés API""" + query = select(ApiKey) + + if not include_revoked: + query = query.where(ApiKey.revoked_at.is_(None)) + + if user_id: + query = query.where(ApiKey.user_id == user_id) + + query = query.order_by(ApiKey.created_at.desc()) + + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def revoke_api_key(self, key_id: str) -> bool: + """Révoque une clé API""" + result = await self.session.execute(select(ApiKey).where(ApiKey.id == key_id)) + api_key_obj = result.scalar_one_or_none() + + if not api_key_obj: + return False + + api_key_obj.is_active = False + api_key_obj.revoked_at = datetime.now() + await self.session.commit() + + logger.info(f" Clé API révoquée: {api_key_obj.name}") + return True + + async def get_by_id(self, key_id: str) -> Optional[ApiKey]: + """Récupère une clé API par son ID""" + result = await self.session.execute(select(ApiKey).where(ApiKey.id == key_id)) + return result.scalar_one_or_none() + + async def check_rate_limit(self, api_key_obj: ApiKey) -> tuple[bool, Dict]: + return True, { + "allowed": True, + "limit": api_key_obj.rate_limit_per_minute, + "remaining": api_key_obj.rate_limit_per_minute, + } + + async def check_endpoint_access(self, api_key_obj: ApiKey, endpoint: str) -> bool: + """Vérifie si la clé a accès à un endpoint spécifique""" + if not api_key_obj.allowed_endpoints: + return True + + try: + allowed = json.loads(api_key_obj.allowed_endpoints) + + for pattern in allowed: + if pattern == "*": + return True + if pattern.endswith("*"): + prefix = pattern[:-1] + if endpoint.startswith(prefix): + return True + if pattern == endpoint: + return True + + return False + except json.JSONDecodeError: + logger.error(f" Erreur parsing allowed_endpoints pour {api_key_obj.id}") + return False + + +def api_key_to_response(api_key_obj: ApiKey, show_key: bool = False) -> Dict: + """Convertit un objet ApiKey en réponse API""" + + allowed_endpoints = None + if api_key_obj.allowed_endpoints: + try: + allowed_endpoints = json.loads(api_key_obj.allowed_endpoints) + except json.JSONDecodeError: + pass + + is_expired = False + if api_key_obj.expires_at: + is_expired = api_key_obj.expires_at < datetime.now() + + return { + "id": api_key_obj.id, + "name": api_key_obj.name, + "description": api_key_obj.description, + "key_prefix": api_key_obj.key_prefix, + "is_active": api_key_obj.is_active, + "is_expired": is_expired, + "rate_limit_per_minute": api_key_obj.rate_limit_per_minute, + "allowed_endpoints": allowed_endpoints, + "total_requests": api_key_obj.total_requests, + "last_used_at": api_key_obj.last_used_at, + "created_at": api_key_obj.created_at, + "expires_at": api_key_obj.expires_at, + "revoked_at": api_key_obj.revoked_at, + "created_by": api_key_obj.created_by, + } From 2aafd525cdf6d3f5ff5cae1bfea63941910de101 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 11:23:10 +0300 Subject: [PATCH 06/30] refactor(api): update middleware and cors configuration --- api.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index bfb0e8d..c3d9e39 100644 --- a/api.py +++ b/api.py @@ -95,7 +95,11 @@ from utils.generic_functions import ( universign_envoyer, ) + +from middleware.security import SwaggerAuthMiddleware, ApiKeyMiddleware from core.dependencies import get_current_user +from config.cors_config import setup_cors +from routes.api_keys import router as api_keys_router if os.path.exists("/app"): LOGS_DIR = FilePath("/app/logs") @@ -162,13 +166,18 @@ app = FastAPI( openapi_tags=TAGS_METADATA, ) -app.add_middleware( +""" app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_methods=["GET", "POST", "PUT", "DELETE"], allow_headers=["*"], allow_credentials=True, -) +) """ + + +setup_cors(app, mode="open") +app.add_middleware(SwaggerAuthMiddleware) +app.add_middleware(ApiKeyMiddleware) app.include_router(auth_router) From f59e56490c20c2e5a07c1ce117efe341e43c80a8 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 11:24:42 +0300 Subject: [PATCH 07/30] feat(api): add api keys router to middleware stack --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index c3d9e39..1cab0ea 100644 --- a/api.py +++ b/api.py @@ -179,7 +179,7 @@ setup_cors(app, mode="open") app.add_middleware(SwaggerAuthMiddleware) app.add_middleware(ApiKeyMiddleware) - +app.include_router(api_keys_router) app.include_router(auth_router) app.include_router(sage_gateway_router) app.include_router(universign_router) From e0f08fd83ad61207e8cfe54a4d897b68cf996de8 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 11:29:12 +0300 Subject: [PATCH 08/30] refactor(dependencies): rename require_role_hybrid to require_role for consistency --- core/dependencies.py | 2 +- routes/api_keys.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/dependencies.py b/core/dependencies.py index ff443a6..8bb30ab 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -126,7 +126,7 @@ async def get_current_user_optional_hybrid( return None -def require_role_hybrid(*allowed_roles: str): +def require_role(*allowed_roles: str): async def role_checker( request: Request, user: User = Depends(get_current_user_hybrid) ) -> User: diff --git a/routes/api_keys.py b/routes/api_keys.py index 27f0efc..1e753de 100644 --- a/routes/api_keys.py +++ b/routes/api_keys.py @@ -70,7 +70,7 @@ async def get_api_key( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): - """ Récupérer une clé API par son ID""" + """Récupérer une clé API par son ID""" service = ApiKeyService(session) api_key_obj = await service.get_by_id(key_id) From 9bd0f6245958d476555dbc2ffddd43e8e20387a8 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 11:49:57 +0300 Subject: [PATCH 09/30] feat(api): add authentication to all endpoints and update OpenAPI schema --- api.py | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 1cab0ea..851608c 100644 --- a/api.py +++ b/api.py @@ -175,6 +175,28 @@ app = FastAPI( ) """ +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = app.openapi() + + # Définir deux schémas de sécurité + openapi_schema["components"]["securitySchemes"] = { + "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, + "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, + } + + openapi_schema["security"] = [{"HTTPBearer": []}, {"ApiKeyAuth": []}] + + app.openapi_schema = openapi_schema + return app.openapi_schema + + +# Après app = FastAPI(...), ajouter: +app.openapi = custom_openapi + + setup_cors(app, mode="open") app.add_middleware(SwaggerAuthMiddleware) app.add_middleware(ApiKeyMiddleware) @@ -189,6 +211,7 @@ app.include_router(entreprises_router) @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def obtenir_clients( query: Optional[str] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -202,6 +225,7 @@ async def obtenir_clients( @app.get("/clients/{code}", response_model=ClientDetails, tags=["Clients"]) async def lire_client_detail( code: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -224,6 +248,7 @@ async def modifier_client( code: str, client_update: ClientUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -249,6 +274,7 @@ async def modifier_client( async def ajouter_client( client: ClientCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -273,6 +299,7 @@ async def ajouter_client( @app.get("/articles", response_model=List[Article], tags=["Articles"]) async def rechercher_articles( query: Optional[str] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -291,6 +318,7 @@ async def rechercher_articles( ) async def creer_article( article: ArticleCreate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -331,6 +359,7 @@ async def creer_article( async def modifier_article( reference: str = Path(..., description="Référence de l'article à modifier"), article: ArticleUpdate = Body(...), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -374,6 +403,7 @@ async def modifier_article( @app.get("/articles/{reference}", response_model=Article, tags=["Articles"]) async def lire_article( reference: str = Path(..., description="Référence de l'article"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -403,6 +433,7 @@ async def lire_article( @app.post("/devis", response_model=Devis, status_code=201, tags=["Devis"]) async def creer_devis( devis: DevisRequest, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -442,6 +473,7 @@ async def modifier_devis( id: str, devis_update: DevisUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -487,6 +519,7 @@ async def modifier_devis( async def creer_commande( commande: CommandeCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -536,6 +569,7 @@ async def modifier_commande( id: str, commande_update: CommandeUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -584,6 +618,7 @@ async def lister_devis( inclure_lignes: bool = Query( True, description="Inclure les lignes de chaque devis" ), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -600,6 +635,7 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis( id: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -620,6 +656,7 @@ async def lire_devis( @app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf( id: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -642,6 +679,7 @@ async def telecharger_document_pdf( description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)", ), numero: str = Path(..., description="Numéro du document"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -698,6 +736,7 @@ async def envoyer_devis_email( id: str, request: EmailEnvoi, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -753,6 +792,7 @@ async def changer_statut_document( nouveau_statut: int = Query( ..., ge=0, le=6, description="0=Saisi, 1=Confirmé, 2=Accepté" ), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): document_type_sql = None @@ -869,6 +909,7 @@ async def changer_statut_document( @app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande( id: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -887,6 +928,7 @@ async def lire_commande( async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -902,6 +944,7 @@ async def lister_commandes( async def devis_vers_commande( id: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -946,6 +989,7 @@ async def devis_vers_commande( async def commande_vers_facture( id: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1047,6 +1091,7 @@ async def envoyer_emails_lot( async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1080,6 +1125,7 @@ async def relancer_devis_signature( id: str, relance: RelanceDevis, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1146,6 +1192,7 @@ class ContactClientResponse(BaseModel): @app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) async def recuperer_contact_devis( id: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1173,6 +1220,7 @@ async def recuperer_contact_devis( async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1187,6 +1235,7 @@ async def lister_factures( @app.get("/factures/{numero}", tags=["Factures"]) async def lire_facture_detail( numero: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1213,6 +1262,7 @@ class RelanceFacture(BaseModel): async def creer_facture( facture: FactureCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1262,6 +1312,7 @@ async def modifier_facture( id: str, facture_update: FactureUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1331,6 +1382,7 @@ async def relancer_facture( id: str, relance: RelanceFacture, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1401,6 +1453,7 @@ async def journal_emails( destinataire: Optional[str] = Query(None), limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): query = select(EmailLog) @@ -1436,6 +1489,7 @@ async def journal_emails( async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): query = select(EmailLog) @@ -1592,6 +1646,7 @@ async def supprimer_template( @app.post("/templates/emails/preview", tags=["Emails"]) async def previsualiser_email( preview: TemplatePreview, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): if preview.template_id not in templates_email_db: @@ -1630,6 +1685,7 @@ async def previsualiser_email( @app.get("/prospects", tags=["Prospects"]) async def rechercher_prospects( query: Optional[str] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1643,6 +1699,7 @@ async def rechercher_prospects( @app.get("/prospects/{code}", tags=["Prospects"]) async def lire_prospect( code: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1662,6 +1719,7 @@ async def lire_prospect( ) async def rechercher_fournisseurs( query: Optional[str] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1683,6 +1741,7 @@ async def rechercher_fournisseurs( async def ajouter_fournisseur( fournisseur: FournisseurCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1712,6 +1771,7 @@ async def modifier_fournisseur( code: str, fournisseur_update: FournisseurUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1734,6 +1794,7 @@ async def modifier_fournisseur( @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur( code: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1752,6 +1813,7 @@ async def lire_fournisseur( async def lister_avoirs( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1765,6 +1827,7 @@ async def lister_avoirs( @app.get("/avoirs/{numero}", tags=["Avoirs"]) async def lire_avoir( numero: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1783,6 +1846,7 @@ async def lire_avoir( async def creer_avoir( avoir: AvoirCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1830,6 +1894,7 @@ async def modifier_avoir( id: str, avoir_update: AvoirUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1875,6 +1940,7 @@ async def modifier_avoir( async def lister_livraisons( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1888,6 +1954,7 @@ async def lister_livraisons( @app.get("/livraisons/{numero}", tags=["Livraisons"]) async def lire_livraison( numero: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1906,6 +1973,7 @@ async def lire_livraison( async def creer_livraison( livraison: LivraisonCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1959,6 +2027,7 @@ async def modifier_livraison( id: str, livraison_update: LivraisonUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2004,6 +2073,7 @@ async def modifier_livraison( async def livraison_vers_facture( id: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2047,6 +2117,7 @@ async def livraison_vers_facture( async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2107,6 +2178,7 @@ async def devis_vers_facture_direct( async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2178,6 +2250,7 @@ async def commande_vers_livraison( ) async def lister_familles( filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2203,6 +2276,7 @@ async def lister_familles( ) async def lire_famille( code: str = Path(..., description="Code de la famille (ex: ZDIVERS)"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2238,6 +2312,7 @@ async def lire_famille( ) async def creer_famille( famille: FamilleCreate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2281,6 +2356,7 @@ async def creer_famille( ) async def creer_entree_stock( entree: EntreeStock, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2317,6 +2393,7 @@ async def creer_entree_stock( ) async def creer_sortie_stock( sortie: SortieStock, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2352,6 +2429,7 @@ async def creer_sortie_stock( ) async def lire_mouvement_stock( numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2384,6 +2462,7 @@ async def lire_mouvement_stock( summary="Statistiques sur les familles", ) async def statistiques_familles( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2492,6 +2571,7 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) async def creer_contact( numero: str, contact: ContactCreate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2524,6 +2604,7 @@ async def creer_contact( @app.get("/tiers/{numero}/contacts", response_model=List[Contact], tags=["Contacts"]) async def lister_contacts( numero: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2542,6 +2623,7 @@ async def lister_contacts( async def obtenir_contact( numero: str, contact_numero: int, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2567,6 +2649,7 @@ async def modifier_contact( numero: str, contact_numero: int, contact: ContactUpdate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2598,6 +2681,7 @@ async def modifier_contact( async def supprimer_contact( numero: str, contact_numero: int, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2612,6 +2696,7 @@ async def supprimer_contact( async def definir_contact_defaut( numero: str, contact_numero: int, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2633,6 +2718,7 @@ async def obtenir_tiers( description="Filtre par type: 0/client, 1/fournisseur, 2/prospect, 3/all ou strings", ), query: Optional[str] = Query(None, description="Recherche sur code ou intitulé"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2647,6 +2733,7 @@ async def obtenir_tiers( @app.get("/tiers/{code}", response_model=TiersDetails, tags=["Tiers"]) async def lire_tiers_detail( code: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2684,6 +2771,7 @@ async def lister_collaborateurs( actifs_seulement: bool = Query( True, description="Exclure les collaborateurs en sommeil" ), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste tous les collaborateurs""" @@ -2702,6 +2790,7 @@ async def lister_collaborateurs( ) async def lire_collaborateur_detail( numero: int, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Lit un collaborateur par son numéro""" @@ -2728,6 +2817,7 @@ async def lire_collaborateur_detail( ) async def creer_collaborateur( collaborateur: CollaborateurCreate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Crée un nouveau collaborateur""" @@ -2754,6 +2844,7 @@ async def creer_collaborateur( async def modifier_collaborateur( numero: int, collaborateur: CollaborateurUpdate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Modifie un collaborateur existant""" @@ -2776,6 +2867,7 @@ async def modifier_collaborateur( @app.get("/societe/info", response_model=SocieteInfo, tags=["Société"]) async def obtenir_informations_societe( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2795,6 +2887,7 @@ async def obtenir_informations_societe( @app.get("/societe/logo", tags=["Société"]) async def obtenir_logo_societe( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Retourne le logo en tant qu'image directe""" @@ -2819,6 +2912,7 @@ async def obtenir_logo_societe( @app.get("/societe/preview", response_class=HTMLResponse, tags=["Société"]) async def preview_societe( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Page HTML pour visualiser les infos société avec logo""" @@ -2892,6 +2986,7 @@ async def preview_societe( async def valider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2915,6 +3010,7 @@ async def valider_facture( async def devalider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2938,6 +3034,7 @@ async def devalider_facture( async def get_statut_validation_facture( numero_facture: str, _: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2958,6 +3055,7 @@ async def regler_facture( numero_facture: str, reglement: ReglementFactureCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -3001,6 +3099,7 @@ async def regler_facture( async def regler_factures_multiple( reglement: ReglementMultipleCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -3039,6 +3138,7 @@ async def regler_factures_multiple( async def get_reglements_facture( numero_facture: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -3063,6 +3163,7 @@ async def get_reglements_client( date_fin: Optional[datetime] = Query(None, description="Date fin"), inclure_soldees: bool = Query(True, description="Inclure les factures soldées"), session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -3087,6 +3188,7 @@ async def get_reglements_client( @app.get("/journaux/banque", tags=["Règlements"]) async def get_journaux_banque( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -3099,6 +3201,7 @@ async def get_journaux_banque( @app.get("/reglements/modes", tags=["Référentiels"]) async def get_modes_reglement( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des modes de règlement disponibles dans Sage""" @@ -3112,6 +3215,7 @@ async def get_modes_reglement( @app.get("/devises", tags=["Référentiels"]) async def get_devises( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des devises disponibles dans Sage""" @@ -3125,6 +3229,7 @@ async def get_devises( @app.get("/journaux/tresorerie", tags=["Référentiels"]) async def get_journaux_tresorerie( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des journaux de trésorerie (banque + caisse)""" @@ -3143,6 +3248,7 @@ async def get_comptes_generaux( None, description="client | fournisseur | banque | caisse | tva | produit | charge", ), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des comptes généraux""" @@ -3156,6 +3262,7 @@ async def get_comptes_generaux( @app.get("/tva/taux", tags=["Référentiels"]) async def get_tva_taux( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des taux de TVA""" @@ -3169,6 +3276,7 @@ async def get_tva_taux( @app.get("/parametres/encaissement", tags=["Référentiels"]) async def get_parametres_encaissement( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Paramètres TVA sur encaissement""" @@ -3215,6 +3323,7 @@ async def get_reglement_detail(rg_no): @app.get("/health", tags=["System"]) async def health_check( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): gateway_health = sage.health() @@ -3236,9 +3345,23 @@ async def health_check( async def root(): return { "api": "Sage 100c Dataven - VPS Linux", - "version": "2.0.0", - "documentation": "/docs", + "version": "3.0.0", + "documentation": "/docs (authentification requise)", "health": "/health", + "authentication": { + "methods": [ + { + "type": "JWT", + "header": "Authorization: Bearer ", + "endpoint": "/api/auth/login", + }, + { + "type": "API Key", + "header": "X-API-Key: sdk_live_xxx", + "endpoint": "/api/api-keys", + }, + ] + }, } From cc0062b3bc716f784f17f5df33b5a14787621c3a Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 12:11:20 +0300 Subject: [PATCH 10/30] refactor(security): improve security management script with better logging and structure --- api.py | 2 - routes/universign.py | 1 - scripts/manage_security.py | 298 ++++++++++++++++++------------- tools/extract_pydantic_models.py | 2 - 4 files changed, 177 insertions(+), 126 deletions(-) diff --git a/api.py b/api.py index 851608c..0196fa7 100644 --- a/api.py +++ b/api.py @@ -181,7 +181,6 @@ def custom_openapi(): openapi_schema = app.openapi() - # Définir deux schémas de sécurité openapi_schema["components"]["securitySchemes"] = { "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, @@ -193,7 +192,6 @@ def custom_openapi(): return app.openapi_schema -# Après app = FastAPI(...), ajouter: app.openapi = custom_openapi diff --git a/routes/universign.py b/routes/universign.py index 2bf0e7a..bada5aa 100644 --- a/routes/universign.py +++ b/routes/universign.py @@ -35,7 +35,6 @@ logger = logging.getLogger(__name__) router = APIRouter( prefix="/universign", tags=["Universign"], - # dependencies=[Depends(get_current_user)] ) sync_service = UniversignSyncService( diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 1f234b9..3745f53 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,86 +1,94 @@ import asyncio import sys -from pathlib import Path -import argparse - -sys.path.insert(0, str(Path(__file__).parent.parent)) - from database import get_session -from database.models.api_key import SwaggerUser +from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password from sqlalchemy import select +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import argparse +from datetime import datetime import logging -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" - async with get_session() as session: + + async for session in get_session(): result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) ) existing = result.scalar_one_or_none() if existing: - logger.error(f" L'utilisateur {username} existe déjà") + logger.error(f" L'utilisateur '{username}' existe déjà") return - user = SwaggerUser( + swagger_user = SwaggerUser( username=username, hashed_password=hash_password(password), full_name=full_name or username, is_active=True, ) - session.add(user) + session.add(swagger_user) await session.commit() logger.info(f" Utilisateur Swagger créé: {username}") - print("\n Utilisateur créé avec succès") - print(f" Username: {username}") - print(" Accès: https://votre-serveur/docs") + logger.info(f" Nom complet: {swagger_user.full_name}") + logger.info(f" Actif: {swagger_user.is_active}") + + break async def list_swagger_users(): - """Lister les utilisateurs Swagger""" - async with get_session() as session: + """Lister tous les utilisateurs Swagger""" + + async for session in get_session(): result = await session.execute(select(SwaggerUser)) users = result.scalars().all() if not users: - print("Aucun utilisateur Swagger trouvé") - return + logger.info(" Aucun utilisateur Swagger") + break + + logger.info(f" {len(users)} utilisateur(s) Swagger:\n") - print(f"\n {len(users)} utilisateur(s) Swagger:\n") for user in users: - status = " Actif" if user.is_active else " Inactif" - print(f" • {user.username:<20} {status}") - if user.full_name: - print(f" Nom: {user.full_name}") - if user.last_login: - print(f" Dernière connexion: {user.last_login}") - print() + status = "" if user.is_active else "" + logger.info(f" {status} {user.username}") + logger.info(f" Nom: {user.full_name}") + logger.info(f" Créé: {user.created_at}") + logger.info(f" Dernière connexion: {user.last_login or 'Jamais'}") + logger.info("") + + break async def delete_swagger_user(username: str): """Supprimer un utilisateur Swagger""" - async with get_session() as session: + + async for session in get_session(): result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) ) user = result.scalar_one_or_none() if not user: - logger.error(f" Utilisateur {username} introuvable") - return + logger.error(f" Utilisateur '{username}' introuvable") + break await session.delete(user) await session.commit() - logger.info(f"🗑️ Utilisateur supprimé: {username}") + logger.info(f" Utilisateur Swagger supprimé: {username}") + break async def create_api_key( @@ -91,137 +99,180 @@ async def create_api_key( endpoints: list = None, ): """Créer une clé API""" - async with get_session() as session: + + async for session in get_session(): service = ApiKeyService(session) api_key_obj, api_key_plain = await service.create_api_key( name=name, description=description, - created_by="CLI", + created_by="cli", expires_in_days=expires_in_days, rate_limit_per_minute=rate_limit, allowed_endpoints=endpoints, ) - print("\n Clé API créée avec succès\n") - print(f" ID: {api_key_obj.id}") - print(f" Nom: {name}") - print(f" Clé: {api_key_plain}") - print(f" Préfixe: {api_key_obj.key_prefix}") - print(f" Rate limit: {rate_limit} req/min") - print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}") - print("\n IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !\n") + logger.info("=" * 60) + logger.info(" Clé API créée avec succès") + logger.info("=" * 60) + logger.info(f" ID: {api_key_obj.id}") + logger.info(f" Nom: {api_key_obj.name}") + logger.info(f" Clé: {api_key_plain}") + logger.info(f" Préfixe: {api_key_obj.key_prefix}") + logger.info(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") + logger.info(f" Créée le: {api_key_obj.created_at}") + logger.info(f" Expire le: {api_key_obj.expires_at}") + + if api_key_obj.allowed_endpoints: + logger.info( + f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" + ) + else: + logger.info(" Endpoints autorisés: Tous") + + logger.info("=" * 60) + logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") + logger.info("=" * 60) + + break async def list_api_keys(): - """Lister les clés API""" - async with get_session() as session: + """Lister toutes les clés API""" + + async for session in get_session(): service = ApiKeyService(session) keys = await service.list_api_keys() if not keys: - print("Aucune clé API trouvée") - return + logger.info(" Aucune clé API") + break + + logger.info(f" {len(keys)} clé(s) API:\n") - print(f"\n {len(keys)} clé(s) API:\n") for key in keys: - status = "" if key.is_active else "" - expired = ( - "⏰ Expirée" - if key.expires_at and key.expires_at < datetime.now() + status = ( + "" + if key.is_active + and (not key.expires_at or key.expires_at > datetime.now()) else "" ) - print(f" {status} {key.name:<30} ({key.key_prefix}...)") - print(f" ID: {key.id}") - print(f" Requêtes: {key.total_requests}") - print(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") - if expired: - print(f" {expired}") - print() + logger.info(f" {status} {key.name:<30} ({key.key_prefix}...)") + logger.info(f" ID: {key.id}") + logger.info(f" Rate limit: {key.rate_limit_per_minute} req/min") + logger.info(f" Requêtes: {key.total_requests}") + logger.info(f" Créée le: {key.created_at}") + logger.info(f" Expire le: {key.expires_at or 'Jamais'}") + logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + + if key.allowed_endpoints: + logger.info( + f" Endpoints: {', '.join(key.allowed_endpoints[:3])}..." + ) + + logger.info("") + + break async def revoke_api_key(key_id: str): """Révoquer une clé API""" - async with get_session() as session: + + async for session in get_session(): service = ApiKeyService(session) - api_key = await service.get_by_id(key_id) - if not api_key: - logger.error(f" Clé {key_id} introuvable") - return + result = await session.execute(select(ApiKey).where(ApiKey.id == key_id)) + key = result.scalar_one_or_none() - success = await service.revoke_api_key(key_id) + if not key: + logger.error(f" Clé API '{key_id}' introuvable") + break - if success: - logger.info(f" Clé révoquée: {api_key.name}") - print(f"\n Clé '{api_key.name}' révoquée avec succès") - else: - logger.error(" Erreur lors de la révocation") + key.is_active = False + await session.commit() + + logger.info(f" Clé API révoquée: {key.name}") + logger.info(f" ID: {key.id}") + logger.info(f" Préfixe: {key.key_prefix}") + + break -async def verify_api_key_cmd(api_key: str): +async def verify_api_key(api_key: str): """Vérifier une clé API""" - async with get_session() as session: - service = ApiKeyService(session) - api_key_obj = await service.verify_api_key(api_key) - if api_key_obj: - print("\n Clé API valide\n") - print(f" Nom: {api_key_obj.name}") - print(f" ID: {api_key_obj.id}") - print(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") - print(f" Requêtes: {api_key_obj.total_requests}") - print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}\n") - else: - print("\n Clé API invalide, expirée ou révoquée\n") + async for session in get_session(): + service = ApiKeyService(session) + + key = await service.verify_api_key(api_key) + + if not key: + logger.error(" Clé API invalide ou expirée") + break + + logger.info("=" * 60) + logger.info(" Clé API valide") + logger.info("=" * 60) + logger.info(f" Nom: {key.name}") + logger.info(f" ID: {key.id}") + logger.info(f" Rate limit: {key.rate_limit_per_minute} req/min") + logger.info(f" Requêtes totales: {key.total_requests}") + logger.info(f" Expire le: {key.expires_at or 'Jamais'}") + logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + logger.info("=" * 60) + + break async def main(): parser = argparse.ArgumentParser( - description="Gestion de la sécurité Sage Dataven API" + description="Gestion des utilisateurs Swagger et clés API" ) - subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles") swagger_parser = subparsers.add_parser( - "swagger", help="Gestion utilisateurs Swagger" + "swagger", help="Gestion des utilisateurs Swagger" ) - swagger_subparsers = swagger_parser.add_subparsers(dest="action") + swagger_subparsers = swagger_parser.add_subparsers(dest="swagger_command") - swagger_add = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") - swagger_add.add_argument("username", help="Nom d'utilisateur") - swagger_add.add_argument("password", help="Mot de passe") - swagger_add.add_argument("--full-name", help="Nom complet") + add_parser = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") + add_parser.add_argument("username", help="Nom d'utilisateur") + add_parser.add_argument("password", help="Mot de passe") + add_parser.add_argument("--full-name", help="Nom complet (optionnel)") swagger_subparsers.add_parser("list", help="Lister les utilisateurs") - swagger_delete = swagger_subparsers.add_parser( + delete_parser = swagger_subparsers.add_parser( "delete", help="Supprimer un utilisateur" ) - swagger_delete.add_argument("username", help="Nom d'utilisateur") + delete_parser.add_argument("username", help="Nom d'utilisateur") - apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API") - apikey_subparsers = apikey_parser.add_subparsers(dest="action") + apikey_parser = subparsers.add_parser("apikey", help="Gestion des clés API") + apikey_subparsers = apikey_parser.add_subparsers(dest="apikey_command") - apikey_create = apikey_subparsers.add_parser("create", help="Créer une clé API") - apikey_create.add_argument("name", help="Nom de la clé") - apikey_create.add_argument("--description", help="Description") - apikey_create.add_argument( - "--days", type=int, default=365, help="Expiration en jours" + create_parser = apikey_subparsers.add_parser("create", help="Créer une clé API") + create_parser.add_argument("name", help="Nom de la clé") + create_parser.add_argument("--description", help="Description (optionnel)") + create_parser.add_argument( + "--days", type=int, default=365, help="Jours avant expiration (défaut: 365)" ) - apikey_create.add_argument( - "--rate-limit", type=int, default=60, help="Limite req/min" + create_parser.add_argument( + "--rate-limit", type=int, default=60, help="Requêtes par minute (défaut: 60)" + ) + create_parser.add_argument( + "--endpoints", + nargs="+", + help="Endpoints autorisés (ex: /clients /articles)", ) - apikey_create.add_argument("--endpoints", nargs="+", help="Endpoints autorisés") - apikey_subparsers.add_parser("list", help="Lister les clés") + apikey_subparsers.add_parser("list", help="Lister les clés API") - apikey_revoke = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") - apikey_revoke.add_argument("key_id", help="ID de la clé") + revoke_parser = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") + revoke_parser.add_argument("key_id", help="ID de la clé") - apikey_verify = apikey_subparsers.add_parser("verify", help="Vérifier une clé") - apikey_verify.add_argument("api_key", help="Clé API à vérifier") + verify_parser = apikey_subparsers.add_parser("verify", help="Vérifier une clé") + verify_parser.add_argument("api_key", help="Clé API complète") args = parser.parse_args() @@ -230,35 +281,40 @@ async def main(): return if args.command == "swagger": - if args.action == "add": + if args.swagger_command == "add": await add_swagger_user(args.username, args.password, args.full_name) - elif args.action == "list": + elif args.swagger_command == "list": await list_swagger_users() - elif args.action == "delete": + elif args.swagger_command == "delete": await delete_swagger_user(args.username) else: swagger_parser.print_help() elif args.command == "apikey": - if args.action == "create": + if args.apikey_command == "create": await create_api_key( - args.name, - args.description, - args.days, - args.rate_limit, - args.endpoints, + name=args.name, + description=args.description, + expires_in_days=args.days, + rate_limit=args.rate_limit, + endpoints=args.endpoints, ) - elif args.action == "list": + elif args.apikey_command == "list": await list_api_keys() - elif args.action == "revoke": + elif args.apikey_command == "revoke": await revoke_api_key(args.key_id) - elif args.action == "verify": - await verify_api_key_cmd(args.api_key) + elif args.apikey_command == "verify": + await verify_api_key(args.api_key) else: apikey_parser.print_help() if __name__ == "__main__": - from datetime import datetime - - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("\n👋 Interrupted") + sys.exit(0) + except Exception as e: + logger.error(f" Erreur: {e}") + sys.exit(1) diff --git a/tools/extract_pydantic_models.py b/tools/extract_pydantic_models.py index 595e15f..5718790 100644 --- a/tools/extract_pydantic_models.py +++ b/tools/extract_pydantic_models.py @@ -24,7 +24,6 @@ for node in tree.body: continue other_nodes.append(node) -# --- Extraction des classes --- imports = """ from pydantic import BaseModel, Field from typing import Optional, List @@ -44,7 +43,6 @@ for cls in pydantic_classes: print(f"✅ Modèle extrait : {class_name} → {file_path}") -# --- Réécriture du fichier source sans les modèles --- new_tree = ast.Module(body=other_nodes, type_ignores=[]) new_source = ast.unparse(new_tree) From dd65ae4d9625aa06cea5c060a30e0b4be5a59850 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 12:17:52 +0300 Subject: [PATCH 11/30] style: remove emoji from log messages --- routes/auth.py | 2 +- scripts/manage_security.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index d6e6761..d401d86 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -510,7 +510,7 @@ async def logout( token_record.revoked_at = datetime.now() await session.commit() - logger.info(f"👋 Déconnexion: {user.email}") + logger.info(f" Déconnexion: {user.email}") return {"success": True, "message": "Déconnexion réussie"} diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 3745f53..a495033 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,6 +1,6 @@ import asyncio import sys -from database import get_session +from database.db_config import get_session from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password @@ -313,7 +313,7 @@ if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - logger.info("\n👋 Interrupted") + logger.info("\n Interrupted") sys.exit(0) except Exception as e: logger.error(f" Erreur: {e}") From e51a5e0a0b48480296d9b44fbd66dbaa8c6a3dff Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 12:19:51 +0300 Subject: [PATCH 12/30] refactor(database): update import to use direct get_session import --- scripts/manage_security.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index a495033..be2d256 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,6 +1,6 @@ import asyncio import sys -from database.db_config import get_session +from database import get_session from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password From cce1cdf76a31175a4625c81fde51ee555076ee69 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 12:32:49 +0300 Subject: [PATCH 13/30] refactor(scripts): improve manage_security.py organization and error handling --- scripts/manage_security.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index be2d256..ce2a3e0 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,17 +1,20 @@ import asyncio import sys +from pathlib import Path +import argparse +from datetime import datetime +import logging + from database import get_session from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password from sqlalchemy import select -from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent)) +current_dir = Path(__file__).resolve().parent +parent_dir = current_dir.parent +sys.path.insert(0, str(parent_dir)) -import argparse -from datetime import datetime -import logging logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) @@ -131,7 +134,7 @@ async def create_api_key( logger.info(" Endpoints autorisés: Tous") logger.info("=" * 60) - logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") + logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") logger.info("=" * 60) break @@ -317,4 +320,7 @@ if __name__ == "__main__": sys.exit(0) except Exception as e: logger.error(f" Erreur: {e}") + import traceback + + traceback.print_exc() sys.exit(1) From 72d1ac58d110d602270d7c08bdff20d80eeedfc1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 12:40:56 +0300 Subject: [PATCH 14/30] refactor(security): reorganize imports and improve logging message --- scripts/manage_security.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index ce2a3e0..b1c14de 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,24 +1,25 @@ import asyncio import sys +import os from pathlib import Path -import argparse -from datetime import datetime -import logging - -from database import get_session -from database.models.api_key import SwaggerUser, ApiKey -from services.api_key import ApiKeyService -from security.auth import hash_password -from sqlalchemy import select current_dir = Path(__file__).resolve().parent parent_dir = current_dir.parent sys.path.insert(0, str(parent_dir)) +import argparse +from datetime import datetime +import logging logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) +from database import get_session +from database.models.api_key import SwaggerUser, ApiKey +from services.api_key import ApiKeyService +from security.auth import hash_password, verify_password +from sqlalchemy import select + async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" @@ -131,7 +132,7 @@ async def create_api_key( f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" ) else: - logger.info(" Endpoints autorisés: Tous") + logger.info(f" Endpoints autorisés: Tous") logger.info("=" * 60) logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") From 022149c23786092b4aedda6aaa1ed2e50eb6219c Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 13:46:27 +0300 Subject: [PATCH 15/30] refactor(api): replace get_sage_client_for_user with get_current_user for dependency injection --- api.py | 174 ++++++++++++++++++------------------- scripts/manage_security.py | 22 +++-- 2 files changed, 97 insertions(+), 99 deletions(-) diff --git a/api.py b/api.py index 0196fa7..7fe1356 100644 --- a/api.py +++ b/api.py @@ -210,7 +210,7 @@ app.include_router(entreprises_router) async def obtenir_clients( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: clients = sage.lister_clients(filtre=query or "") @@ -224,7 +224,7 @@ async def obtenir_clients( async def lire_client_detail( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: client = sage.lire_client(code) @@ -247,7 +247,7 @@ async def modifier_client( client_update: ClientUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.modifier_client(code, client_update.dict(exclude_none=True)) @@ -273,7 +273,7 @@ async def ajouter_client( client: ClientCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: nouveau_client = sage.creer_client(client.model_dump(mode="json")) @@ -298,7 +298,7 @@ async def ajouter_client( async def rechercher_articles( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: articles = sage.lister_articles(filtre=query or "") @@ -317,7 +317,7 @@ async def rechercher_articles( async def creer_article( article: ArticleCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: if not article.reference or not article.designation: @@ -358,7 +358,7 @@ async def modifier_article( reference: str = Path(..., description="Référence de l'article à modifier"), article: ArticleUpdate = Body(...), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: article_data = article.dict(exclude_unset=True) @@ -402,7 +402,7 @@ async def modifier_article( async def lire_article( reference: str = Path(..., description="Référence de l'article"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: article = sage.lire_article(reference) @@ -432,7 +432,7 @@ async def lire_article( async def creer_devis( devis: DevisRequest, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis_data = { @@ -472,7 +472,7 @@ async def modifier_devis( devis_update: DevisUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: update_data = {} @@ -518,7 +518,7 @@ async def creer_commande( commande: CommandeCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: commande_data = { @@ -568,7 +568,7 @@ async def modifier_commande( commande_update: CommandeUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: update_data = {} @@ -617,7 +617,7 @@ async def lister_devis( True, description="Inclure les lignes de chaque devis" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis_list = sage.lister_devis( @@ -634,7 +634,7 @@ async def lister_devis( async def lire_devis( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis = sage.lire_devis(id) @@ -655,7 +655,7 @@ async def lire_devis( async def telecharger_devis_pdf( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) @@ -678,7 +678,7 @@ async def telecharger_document_pdf( ), numero: str = Path(..., description="Numéro du document"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: types_labels = { @@ -735,7 +735,7 @@ async def envoyer_devis_email( request: EmailEnvoi, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: tous_destinataires = [request.destinataire] + request.cc + request.cci @@ -791,7 +791,7 @@ async def changer_statut_document( ..., ge=0, le=6, description="0=Saisi, 1=Confirmé, 2=Accepté" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): document_type_sql = None document_type_code = None @@ -908,7 +908,7 @@ async def changer_statut_document( async def lire_commande( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: commande = sage.lire_document(id, TypeDocumentSQL.BON_COMMANDE) @@ -927,7 +927,7 @@ async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: commandes = sage.lister_commandes(limit=limit, statut=statut) @@ -943,7 +943,7 @@ async def devis_vers_commande( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.transformer_document( @@ -988,7 +988,7 @@ async def commande_vers_facture( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.transformer_document( @@ -1090,7 +1090,7 @@ async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: remise_max = sage.lire_remise_max_client(client_id) @@ -1124,7 +1124,7 @@ async def relancer_devis_signature( relance: RelanceDevis, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis = sage.lire_devis(id) @@ -1191,7 +1191,7 @@ class ContactClientResponse(BaseModel): async def recuperer_contact_devis( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis = sage.lire_devis(id) @@ -1219,7 +1219,7 @@ async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: factures = sage.lister_factures(limit=limit, statut=statut) @@ -1234,7 +1234,7 @@ async def lister_factures( async def lire_facture_detail( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: facture = sage.lire_document(numero, TypeDocumentSQL.FACTURE) @@ -1261,7 +1261,7 @@ async def creer_facture( facture: FactureCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: facture_data = { @@ -1311,7 +1311,7 @@ async def modifier_facture( facture_update: FactureUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: update_data = {} @@ -1381,7 +1381,7 @@ async def relancer_facture( relance: RelanceFacture, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: facture = sage.lire_document(id, TypeDocumentSQL.FACTURE) @@ -1452,7 +1452,7 @@ async def journal_emails( limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): query = select(EmailLog) @@ -1488,7 +1488,7 @@ async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): query = select(EmailLog) if statut: @@ -1645,7 +1645,7 @@ async def supprimer_template( async def previsualiser_email( preview: TemplatePreview, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): if preview.template_id not in templates_email_db: raise HTTPException(404, f"Template {preview.template_id} introuvable") @@ -1684,7 +1684,7 @@ async def previsualiser_email( async def rechercher_prospects( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: prospects = sage.lister_prospects(filtre=query or "") @@ -1698,7 +1698,7 @@ async def rechercher_prospects( async def lire_prospect( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: prospect = sage.lire_prospect(code) @@ -1718,7 +1718,7 @@ async def lire_prospect( async def rechercher_fournisseurs( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: fournisseurs = sage.lister_fournisseurs(filtre=query or "") @@ -1740,7 +1740,7 @@ async def ajouter_fournisseur( fournisseur: FournisseurCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: nouveau_fournisseur = sage.creer_fournisseur(fournisseur.dict()) @@ -1770,7 +1770,7 @@ async def modifier_fournisseur( fournisseur_update: FournisseurUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.modifier_fournisseur( @@ -1793,7 +1793,7 @@ async def modifier_fournisseur( async def lire_fournisseur( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: fournisseur = sage.lire_fournisseur(code) @@ -1812,7 +1812,7 @@ async def lister_avoirs( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: avoirs = sage.lister_avoirs(limit=limit, statut=statut) @@ -1826,7 +1826,7 @@ async def lister_avoirs( async def lire_avoir( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: avoir = sage.lire_document(numero, TypeDocumentSQL.BON_AVOIR) @@ -1845,7 +1845,7 @@ async def creer_avoir( avoir: AvoirCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: avoir_data = { @@ -1893,7 +1893,7 @@ async def modifier_avoir( avoir_update: AvoirUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: update_data = {} @@ -1939,7 +1939,7 @@ async def lister_livraisons( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: livraisons = sage.lister_livraisons(limit=limit, statut=statut) @@ -1953,7 +1953,7 @@ async def lister_livraisons( async def lire_livraison( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: livraison = sage.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) @@ -1972,7 +1972,7 @@ async def creer_livraison( livraison: LivraisonCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: livraison_data = { @@ -2026,7 +2026,7 @@ async def modifier_livraison( livraison_update: LivraisonUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: update_data = {} @@ -2072,7 +2072,7 @@ async def livraison_vers_facture( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.transformer_document( @@ -2116,7 +2116,7 @@ async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis_existant = sage.lire_devis(id) @@ -2177,7 +2177,7 @@ async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: commande_existante = sage.lire_document(id, TypeDocumentSQL.BON_COMMANDE) @@ -2249,7 +2249,7 @@ async def commande_vers_livraison( async def lister_familles( filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: familles = sage.lister_familles(filtre or "") @@ -2275,7 +2275,7 @@ async def lister_familles( async def lire_famille( code: str = Path(..., description="Code de la famille (ex: ZDIVERS)"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: famille = sage.lire_famille(code) @@ -2311,7 +2311,7 @@ async def lire_famille( async def creer_famille( famille: FamilleCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: if not famille.code or not famille.intitule: @@ -2355,7 +2355,7 @@ async def creer_famille( async def creer_entree_stock( entree: EntreeStock, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: entree_data = entree.dict() @@ -2392,7 +2392,7 @@ async def creer_entree_stock( async def creer_sortie_stock( sortie: SortieStock, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: sortie_data = sortie.dict() @@ -2428,7 +2428,7 @@ async def creer_sortie_stock( async def lire_mouvement_stock( numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: mouvement = sage.lire_mouvement_stock(numero) @@ -2461,7 +2461,7 @@ async def lire_mouvement_stock( ) async def statistiques_familles( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: stats = sage.get_stats_familles() @@ -2570,7 +2570,7 @@ async def creer_contact( numero: str, contact: ContactCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: try: @@ -2603,7 +2603,7 @@ async def creer_contact( async def lister_contacts( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: contacts = sage.lister_contacts(numero) @@ -2622,7 +2622,7 @@ async def obtenir_contact( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: contact = sage.obtenir_contact(numero, contact_numero) @@ -2648,7 +2648,7 @@ async def modifier_contact( contact_numero: int, contact: ContactUpdate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: contact_existant = sage.obtenir_contact(numero, contact_numero) @@ -2680,7 +2680,7 @@ async def supprimer_contact( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: sage.supprimer_contact(numero, contact_numero) @@ -2695,7 +2695,7 @@ async def definir_contact_defaut( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.definir_contact_defaut(numero, contact_numero) @@ -2717,7 +2717,7 @@ async def obtenir_tiers( ), query: Optional[str] = Query(None, description="Recherche sur code ou intitulé"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: type_normalise = normaliser_type_tiers(type_tiers) @@ -2732,7 +2732,7 @@ async def obtenir_tiers( async def lire_tiers_detail( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: tiers = sage.lire_tiers(code) @@ -2770,7 +2770,7 @@ async def lister_collaborateurs( True, description="Exclure les collaborateurs en sommeil" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste tous les collaborateurs""" try: @@ -2789,7 +2789,7 @@ async def lister_collaborateurs( async def lire_collaborateur_detail( numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Lit un collaborateur par son numéro""" try: @@ -2816,7 +2816,7 @@ async def lire_collaborateur_detail( async def creer_collaborateur( collaborateur: CollaborateurCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Crée un nouveau collaborateur""" try: @@ -2843,7 +2843,7 @@ async def modifier_collaborateur( numero: int, collaborateur: CollaborateurUpdate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Modifie un collaborateur existant""" try: @@ -2866,7 +2866,7 @@ async def modifier_collaborateur( @app.get("/societe/info", response_model=SocieteInfo, tags=["Société"]) async def obtenir_informations_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: societe = sage.lire_informations_societe() @@ -2886,7 +2886,7 @@ async def obtenir_informations_societe( @app.get("/societe/logo", tags=["Société"]) async def obtenir_logo_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Retourne le logo en tant qu'image directe""" try: @@ -2911,7 +2911,7 @@ async def obtenir_logo_societe( @app.get("/societe/preview", response_class=HTMLResponse, tags=["Société"]) async def preview_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Page HTML pour visualiser les infos société avec logo""" try: @@ -2985,7 +2985,7 @@ async def valider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.valider_facture(numero_facture) @@ -3009,7 +3009,7 @@ async def devalider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.devalider_facture(numero_facture) @@ -3033,7 +3033,7 @@ async def get_statut_validation_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.get_statut_validation(numero_facture) @@ -3054,7 +3054,7 @@ async def regler_facture( reglement: ReglementFactureCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.regler_facture( @@ -3098,7 +3098,7 @@ async def regler_factures_multiple( reglement: ReglementMultipleCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.regler_factures_client( @@ -3137,7 +3137,7 @@ async def get_reglements_facture( numero_facture: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.get_reglements_facture(numero_facture) @@ -3162,7 +3162,7 @@ async def get_reglements_client( inclure_soldees: bool = Query(True, description="Inclure les factures soldées"), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.get_reglements_client( @@ -3187,7 +3187,7 @@ async def get_reglements_client( @app.get("/journaux/banque", tags=["Règlements"]) async def get_journaux_banque( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.get_journaux_banque() @@ -3200,7 +3200,7 @@ async def get_journaux_banque( @app.get("/reglements/modes", tags=["Référentiels"]) async def get_modes_reglement( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste des modes de règlement disponibles dans Sage""" try: @@ -3214,7 +3214,7 @@ async def get_modes_reglement( @app.get("/devises", tags=["Référentiels"]) async def get_devises( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste des devises disponibles dans Sage""" try: @@ -3228,7 +3228,7 @@ async def get_devises( @app.get("/journaux/tresorerie", tags=["Référentiels"]) async def get_journaux_tresorerie( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste des journaux de trésorerie (banque + caisse)""" try: @@ -3247,7 +3247,7 @@ async def get_comptes_generaux( description="client | fournisseur | banque | caisse | tva | produit | charge", ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste des comptes généraux""" try: @@ -3261,7 +3261,7 @@ async def get_comptes_generaux( @app.get("/tva/taux", tags=["Référentiels"]) async def get_tva_taux( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste des taux de TVA""" try: @@ -3275,7 +3275,7 @@ async def get_tva_taux( @app.get("/parametres/encaissement", tags=["Référentiels"]) async def get_parametres_encaissement( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Paramètres TVA sur encaissement""" try: @@ -3322,7 +3322,7 @@ async def get_reglement_detail(rg_no): @app.get("/health", tags=["System"]) async def health_check( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): gateway_health = sage.health() diff --git a/scripts/manage_security.py b/scripts/manage_security.py index b1c14de..066e92c 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,25 +1,23 @@ import asyncio import sys -import os from pathlib import Path - -current_dir = Path(__file__).resolve().parent -parent_dir = current_dir.parent -sys.path.insert(0, str(parent_dir)) +from database import get_session +from database.models.api_key import SwaggerUser, ApiKey +from services.api_key import ApiKeyService +from security.auth import hash_password +from sqlalchemy import select import argparse from datetime import datetime import logging +current_dir = Path(__file__).resolve().parent +parent_dir = current_dir.parent +sys.path.insert(0, str(parent_dir)) + logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) -from database import get_session -from database.models.api_key import SwaggerUser, ApiKey -from services.api_key import ApiKeyService -from security.auth import hash_password, verify_password -from sqlalchemy import select - async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" @@ -132,7 +130,7 @@ async def create_api_key( f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" ) else: - logger.info(f" Endpoints autorisés: Tous") + logger.info(" Endpoints autorisés: Tous") logger.info("=" * 60) logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") From 5b584bf9692aafaabd8f453b7cf5b96cdb36804d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 13:51:09 +0300 Subject: [PATCH 16/30] refactor(security): improve auth middleware and logging --- middleware/security.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/middleware/security.py b/middleware/security.py index 137e7dd..c6e75e7 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -34,7 +34,7 @@ async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: logger.info(f" Accès Swagger autorisé (DB): {username}") return True - logger.warning(f" Tentative d'accès Swagger refusée: {username}") + logger.warning(f"Tentative d'accès Swagger refusée: {username}") return False except Exception as e: @@ -43,6 +43,7 @@ async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: class SwaggerAuthMiddleware: + def __init__(self, app): self.app = app @@ -54,7 +55,7 @@ class SwaggerAuthMiddleware: request = Request(scope, receive=receive) path = request.url.path - protected_paths = ["/docs", "/redoc", "/openapi.json"] + protected_paths = ["/docs", "/redoc"] if any(path.startswith(protected_path) for protected_path in protected_paths): auth_header = request.headers.get("Authorization") @@ -104,6 +105,7 @@ class SwaggerAuthMiddleware: class ApiKeyMiddleware: + def __init__(self, app): self.app = app @@ -115,21 +117,24 @@ class ApiKeyMiddleware: request = Request(scope, receive=receive) path = request.url.path - excluded_paths = [ + public_exact_paths = [ + "/", + "/health", "/docs", "/redoc", "/openapi.json", - "/health", - "/", - "/auth/login", - "/auth/register", - "/auth/verify-email", - "/auth/reset-password", - "/auth/request-reset", - "/auth/refresh", ] - if any(path.startswith(excluded_path) for excluded_path in excluded_paths): + public_path_prefixes = [ + "/api/v1/auth/", + ] + + is_public = path in public_exact_paths or any( + path.startswith(prefix) for prefix in public_path_prefixes + ) + + if is_public: + logger.debug(f"Chemin public: {path}") await self.app(scope, receive, send) return @@ -140,12 +145,12 @@ class ApiKeyMiddleware: has_api_key = api_key is not None if has_jwt: - logger.debug(f" JWT détecté pour {path}") + logger.debug(f"🔑 JWT détecté pour {path}") await self.app(scope, receive, send) return elif has_api_key: - logger.debug(f" API Key détectée pour {path}") + logger.debug(f"🔑 API Key détectée pour {path}") from services.api_key import ApiKeyService @@ -218,8 +223,9 @@ class ApiKeyMiddleware: response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ - "detail": "Authentification requise", + "detail": "Authentification requise (JWT ou API Key)", "hint": "Utilisez soit 'X-API-Key: sdk_live_xxx' soit 'Authorization: Bearer '", + "endpoint": path, }, headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, ) @@ -233,4 +239,5 @@ def get_api_key_from_request(request: Request) -> Optional: def get_auth_method(request: Request) -> str: + return getattr(request.state, "authenticated_via", "none") From 0001dbe634d326ecc4a14f12abd179652a0c58f9 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 13:54:36 +0300 Subject: [PATCH 17/30] docs(api): add comments for security schemas and openapi setup --- api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api.py b/api.py index 0196fa7..851608c 100644 --- a/api.py +++ b/api.py @@ -181,6 +181,7 @@ def custom_openapi(): openapi_schema = app.openapi() + # Définir deux schémas de sécurité openapi_schema["components"]["securitySchemes"] = { "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, @@ -192,6 +193,7 @@ def custom_openapi(): return app.openapi_schema +# Après app = FastAPI(...), ajouter: app.openapi = custom_openapi From fa95d0d11728e0335ab8ee5b7b2bf1343082352a Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 13:56:16 +0300 Subject: [PATCH 18/30] refactor(api): replace get_current_user with get_sage_client_for_user in dependencies --- api.py | 174 ++++++++++++++++++------------------- middleware/security.py | 37 ++++---- scripts/manage_security.py | 22 ++--- 3 files changed, 114 insertions(+), 119 deletions(-) diff --git a/api.py b/api.py index a35be7c..851608c 100644 --- a/api.py +++ b/api.py @@ -212,7 +212,7 @@ app.include_router(entreprises_router) async def obtenir_clients( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: clients = sage.lister_clients(filtre=query or "") @@ -226,7 +226,7 @@ async def obtenir_clients( async def lire_client_detail( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: client = sage.lire_client(code) @@ -249,7 +249,7 @@ async def modifier_client( client_update: ClientUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.modifier_client(code, client_update.dict(exclude_none=True)) @@ -275,7 +275,7 @@ async def ajouter_client( client: ClientCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: nouveau_client = sage.creer_client(client.model_dump(mode="json")) @@ -300,7 +300,7 @@ async def ajouter_client( async def rechercher_articles( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: articles = sage.lister_articles(filtre=query or "") @@ -319,7 +319,7 @@ async def rechercher_articles( async def creer_article( article: ArticleCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: if not article.reference or not article.designation: @@ -360,7 +360,7 @@ async def modifier_article( reference: str = Path(..., description="Référence de l'article à modifier"), article: ArticleUpdate = Body(...), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: article_data = article.dict(exclude_unset=True) @@ -404,7 +404,7 @@ async def modifier_article( async def lire_article( reference: str = Path(..., description="Référence de l'article"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: article = sage.lire_article(reference) @@ -434,7 +434,7 @@ async def lire_article( async def creer_devis( devis: DevisRequest, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis_data = { @@ -474,7 +474,7 @@ async def modifier_devis( devis_update: DevisUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -520,7 +520,7 @@ async def creer_commande( commande: CommandeCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: commande_data = { @@ -570,7 +570,7 @@ async def modifier_commande( commande_update: CommandeUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -619,7 +619,7 @@ async def lister_devis( True, description="Inclure les lignes de chaque devis" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis_list = sage.lister_devis( @@ -636,7 +636,7 @@ async def lister_devis( async def lire_devis( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis = sage.lire_devis(id) @@ -657,7 +657,7 @@ async def lire_devis( async def telecharger_devis_pdf( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) @@ -680,7 +680,7 @@ async def telecharger_document_pdf( ), numero: str = Path(..., description="Numéro du document"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: types_labels = { @@ -737,7 +737,7 @@ async def envoyer_devis_email( request: EmailEnvoi, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: tous_destinataires = [request.destinataire] + request.cc + request.cci @@ -793,7 +793,7 @@ async def changer_statut_document( ..., ge=0, le=6, description="0=Saisi, 1=Confirmé, 2=Accepté" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): document_type_sql = None document_type_code = None @@ -910,7 +910,7 @@ async def changer_statut_document( async def lire_commande( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: commande = sage.lire_document(id, TypeDocumentSQL.BON_COMMANDE) @@ -929,7 +929,7 @@ async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: commandes = sage.lister_commandes(limit=limit, statut=statut) @@ -945,7 +945,7 @@ async def devis_vers_commande( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.transformer_document( @@ -990,7 +990,7 @@ async def commande_vers_facture( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.transformer_document( @@ -1092,7 +1092,7 @@ async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: remise_max = sage.lire_remise_max_client(client_id) @@ -1126,7 +1126,7 @@ async def relancer_devis_signature( relance: RelanceDevis, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis = sage.lire_devis(id) @@ -1193,7 +1193,7 @@ class ContactClientResponse(BaseModel): async def recuperer_contact_devis( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis = sage.lire_devis(id) @@ -1221,7 +1221,7 @@ async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: factures = sage.lister_factures(limit=limit, statut=statut) @@ -1236,7 +1236,7 @@ async def lister_factures( async def lire_facture_detail( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: facture = sage.lire_document(numero, TypeDocumentSQL.FACTURE) @@ -1263,7 +1263,7 @@ async def creer_facture( facture: FactureCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: facture_data = { @@ -1313,7 +1313,7 @@ async def modifier_facture( facture_update: FactureUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -1383,7 +1383,7 @@ async def relancer_facture( relance: RelanceFacture, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: facture = sage.lire_document(id, TypeDocumentSQL.FACTURE) @@ -1454,7 +1454,7 @@ async def journal_emails( limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): query = select(EmailLog) @@ -1490,7 +1490,7 @@ async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): query = select(EmailLog) if statut: @@ -1647,7 +1647,7 @@ async def supprimer_template( async def previsualiser_email( preview: TemplatePreview, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): if preview.template_id not in templates_email_db: raise HTTPException(404, f"Template {preview.template_id} introuvable") @@ -1686,7 +1686,7 @@ async def previsualiser_email( async def rechercher_prospects( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: prospects = sage.lister_prospects(filtre=query or "") @@ -1700,7 +1700,7 @@ async def rechercher_prospects( async def lire_prospect( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: prospect = sage.lire_prospect(code) @@ -1720,7 +1720,7 @@ async def lire_prospect( async def rechercher_fournisseurs( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: fournisseurs = sage.lister_fournisseurs(filtre=query or "") @@ -1742,7 +1742,7 @@ async def ajouter_fournisseur( fournisseur: FournisseurCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: nouveau_fournisseur = sage.creer_fournisseur(fournisseur.dict()) @@ -1772,7 +1772,7 @@ async def modifier_fournisseur( fournisseur_update: FournisseurUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.modifier_fournisseur( @@ -1795,7 +1795,7 @@ async def modifier_fournisseur( async def lire_fournisseur( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: fournisseur = sage.lire_fournisseur(code) @@ -1814,7 +1814,7 @@ async def lister_avoirs( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: avoirs = sage.lister_avoirs(limit=limit, statut=statut) @@ -1828,7 +1828,7 @@ async def lister_avoirs( async def lire_avoir( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: avoir = sage.lire_document(numero, TypeDocumentSQL.BON_AVOIR) @@ -1847,7 +1847,7 @@ async def creer_avoir( avoir: AvoirCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: avoir_data = { @@ -1895,7 +1895,7 @@ async def modifier_avoir( avoir_update: AvoirUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -1941,7 +1941,7 @@ async def lister_livraisons( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: livraisons = sage.lister_livraisons(limit=limit, statut=statut) @@ -1955,7 +1955,7 @@ async def lister_livraisons( async def lire_livraison( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: livraison = sage.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) @@ -1974,7 +1974,7 @@ async def creer_livraison( livraison: LivraisonCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: livraison_data = { @@ -2028,7 +2028,7 @@ async def modifier_livraison( livraison_update: LivraisonUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -2074,7 +2074,7 @@ async def livraison_vers_facture( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.transformer_document( @@ -2118,7 +2118,7 @@ async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis_existant = sage.lire_devis(id) @@ -2179,7 +2179,7 @@ async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: commande_existante = sage.lire_document(id, TypeDocumentSQL.BON_COMMANDE) @@ -2251,7 +2251,7 @@ async def commande_vers_livraison( async def lister_familles( filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: familles = sage.lister_familles(filtre or "") @@ -2277,7 +2277,7 @@ async def lister_familles( async def lire_famille( code: str = Path(..., description="Code de la famille (ex: ZDIVERS)"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: famille = sage.lire_famille(code) @@ -2313,7 +2313,7 @@ async def lire_famille( async def creer_famille( famille: FamilleCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: if not famille.code or not famille.intitule: @@ -2357,7 +2357,7 @@ async def creer_famille( async def creer_entree_stock( entree: EntreeStock, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: entree_data = entree.dict() @@ -2394,7 +2394,7 @@ async def creer_entree_stock( async def creer_sortie_stock( sortie: SortieStock, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: sortie_data = sortie.dict() @@ -2430,7 +2430,7 @@ async def creer_sortie_stock( async def lire_mouvement_stock( numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: mouvement = sage.lire_mouvement_stock(numero) @@ -2463,7 +2463,7 @@ async def lire_mouvement_stock( ) async def statistiques_familles( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: stats = sage.get_stats_familles() @@ -2572,7 +2572,7 @@ async def creer_contact( numero: str, contact: ContactCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: try: @@ -2605,7 +2605,7 @@ async def creer_contact( async def lister_contacts( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: contacts = sage.lister_contacts(numero) @@ -2624,7 +2624,7 @@ async def obtenir_contact( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: contact = sage.obtenir_contact(numero, contact_numero) @@ -2650,7 +2650,7 @@ async def modifier_contact( contact_numero: int, contact: ContactUpdate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: contact_existant = sage.obtenir_contact(numero, contact_numero) @@ -2682,7 +2682,7 @@ async def supprimer_contact( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: sage.supprimer_contact(numero, contact_numero) @@ -2697,7 +2697,7 @@ async def definir_contact_defaut( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.definir_contact_defaut(numero, contact_numero) @@ -2719,7 +2719,7 @@ async def obtenir_tiers( ), query: Optional[str] = Query(None, description="Recherche sur code ou intitulé"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: type_normalise = normaliser_type_tiers(type_tiers) @@ -2734,7 +2734,7 @@ async def obtenir_tiers( async def lire_tiers_detail( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: tiers = sage.lire_tiers(code) @@ -2772,7 +2772,7 @@ async def lister_collaborateurs( True, description="Exclure les collaborateurs en sommeil" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste tous les collaborateurs""" try: @@ -2791,7 +2791,7 @@ async def lister_collaborateurs( async def lire_collaborateur_detail( numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Lit un collaborateur par son numéro""" try: @@ -2818,7 +2818,7 @@ async def lire_collaborateur_detail( async def creer_collaborateur( collaborateur: CollaborateurCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Crée un nouveau collaborateur""" try: @@ -2845,7 +2845,7 @@ async def modifier_collaborateur( numero: int, collaborateur: CollaborateurUpdate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Modifie un collaborateur existant""" try: @@ -2868,7 +2868,7 @@ async def modifier_collaborateur( @app.get("/societe/info", response_model=SocieteInfo, tags=["Société"]) async def obtenir_informations_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: societe = sage.lire_informations_societe() @@ -2888,7 +2888,7 @@ async def obtenir_informations_societe( @app.get("/societe/logo", tags=["Société"]) async def obtenir_logo_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Retourne le logo en tant qu'image directe""" try: @@ -2913,7 +2913,7 @@ async def obtenir_logo_societe( @app.get("/societe/preview", response_class=HTMLResponse, tags=["Société"]) async def preview_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Page HTML pour visualiser les infos société avec logo""" try: @@ -2987,7 +2987,7 @@ async def valider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.valider_facture(numero_facture) @@ -3011,7 +3011,7 @@ async def devalider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.devalider_facture(numero_facture) @@ -3035,7 +3035,7 @@ async def get_statut_validation_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.get_statut_validation(numero_facture) @@ -3056,7 +3056,7 @@ async def regler_facture( reglement: ReglementFactureCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.regler_facture( @@ -3100,7 +3100,7 @@ async def regler_factures_multiple( reglement: ReglementMultipleCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.regler_factures_client( @@ -3139,7 +3139,7 @@ async def get_reglements_facture( numero_facture: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.get_reglements_facture(numero_facture) @@ -3164,7 +3164,7 @@ async def get_reglements_client( inclure_soldees: bool = Query(True, description="Inclure les factures soldées"), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.get_reglements_client( @@ -3189,7 +3189,7 @@ async def get_reglements_client( @app.get("/journaux/banque", tags=["Règlements"]) async def get_journaux_banque( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.get_journaux_banque() @@ -3202,7 +3202,7 @@ async def get_journaux_banque( @app.get("/reglements/modes", tags=["Référentiels"]) async def get_modes_reglement( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des modes de règlement disponibles dans Sage""" try: @@ -3216,7 +3216,7 @@ async def get_modes_reglement( @app.get("/devises", tags=["Référentiels"]) async def get_devises( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des devises disponibles dans Sage""" try: @@ -3230,7 +3230,7 @@ async def get_devises( @app.get("/journaux/tresorerie", tags=["Référentiels"]) async def get_journaux_tresorerie( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des journaux de trésorerie (banque + caisse)""" try: @@ -3249,7 +3249,7 @@ async def get_comptes_generaux( description="client | fournisseur | banque | caisse | tva | produit | charge", ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des comptes généraux""" try: @@ -3263,7 +3263,7 @@ async def get_comptes_generaux( @app.get("/tva/taux", tags=["Référentiels"]) async def get_tva_taux( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des taux de TVA""" try: @@ -3277,7 +3277,7 @@ async def get_tva_taux( @app.get("/parametres/encaissement", tags=["Référentiels"]) async def get_parametres_encaissement( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Paramètres TVA sur encaissement""" try: @@ -3324,7 +3324,7 @@ async def get_reglement_detail(rg_no): @app.get("/health", tags=["System"]) async def health_check( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): gateway_health = sage.health() diff --git a/middleware/security.py b/middleware/security.py index c6e75e7..137e7dd 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -34,7 +34,7 @@ async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: logger.info(f" Accès Swagger autorisé (DB): {username}") return True - logger.warning(f"Tentative d'accès Swagger refusée: {username}") + logger.warning(f" Tentative d'accès Swagger refusée: {username}") return False except Exception as e: @@ -43,7 +43,6 @@ async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: class SwaggerAuthMiddleware: - def __init__(self, app): self.app = app @@ -55,7 +54,7 @@ class SwaggerAuthMiddleware: request = Request(scope, receive=receive) path = request.url.path - protected_paths = ["/docs", "/redoc"] + protected_paths = ["/docs", "/redoc", "/openapi.json"] if any(path.startswith(protected_path) for protected_path in protected_paths): auth_header = request.headers.get("Authorization") @@ -105,7 +104,6 @@ class SwaggerAuthMiddleware: class ApiKeyMiddleware: - def __init__(self, app): self.app = app @@ -117,24 +115,21 @@ class ApiKeyMiddleware: request = Request(scope, receive=receive) path = request.url.path - public_exact_paths = [ - "/", - "/health", + excluded_paths = [ "/docs", "/redoc", "/openapi.json", + "/health", + "/", + "/auth/login", + "/auth/register", + "/auth/verify-email", + "/auth/reset-password", + "/auth/request-reset", + "/auth/refresh", ] - public_path_prefixes = [ - "/api/v1/auth/", - ] - - is_public = path in public_exact_paths or any( - path.startswith(prefix) for prefix in public_path_prefixes - ) - - if is_public: - logger.debug(f"Chemin public: {path}") + if any(path.startswith(excluded_path) for excluded_path in excluded_paths): await self.app(scope, receive, send) return @@ -145,12 +140,12 @@ class ApiKeyMiddleware: has_api_key = api_key is not None if has_jwt: - logger.debug(f"🔑 JWT détecté pour {path}") + logger.debug(f" JWT détecté pour {path}") await self.app(scope, receive, send) return elif has_api_key: - logger.debug(f"🔑 API Key détectée pour {path}") + logger.debug(f" API Key détectée pour {path}") from services.api_key import ApiKeyService @@ -223,9 +218,8 @@ class ApiKeyMiddleware: response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ - "detail": "Authentification requise (JWT ou API Key)", + "detail": "Authentification requise", "hint": "Utilisez soit 'X-API-Key: sdk_live_xxx' soit 'Authorization: Bearer '", - "endpoint": path, }, headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, ) @@ -239,5 +233,4 @@ def get_api_key_from_request(request: Request) -> Optional: def get_auth_method(request: Request) -> str: - return getattr(request.state, "authenticated_via", "none") diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 066e92c..b1c14de 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,23 +1,25 @@ import asyncio import sys +import os from pathlib import Path -from database import get_session -from database.models.api_key import SwaggerUser, ApiKey -from services.api_key import ApiKeyService -from security.auth import hash_password -from sqlalchemy import select - -import argparse -from datetime import datetime -import logging current_dir = Path(__file__).resolve().parent parent_dir = current_dir.parent sys.path.insert(0, str(parent_dir)) +import argparse +from datetime import datetime +import logging + logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) +from database import get_session +from database.models.api_key import SwaggerUser, ApiKey +from services.api_key import ApiKeyService +from security.auth import hash_password, verify_password +from sqlalchemy import select + async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" @@ -130,7 +132,7 @@ async def create_api_key( f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" ) else: - logger.info(" Endpoints autorisés: Tous") + logger.info(f" Endpoints autorisés: Tous") logger.info("=" * 60) logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") From 918f5d3f1980d74040b9db41b40099709867fc57 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 14:06:10 +0300 Subject: [PATCH 19/30] docs(api): fix incorrect comment syntax in openapi configuration --- api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 851608c..53a2273 100644 --- a/api.py +++ b/api.py @@ -193,8 +193,8 @@ def custom_openapi(): return app.openapi_schema -# Après app = FastAPI(...), ajouter: -app.openapi = custom_openapi +""" # Après app = FastAPI(...), ajouter: +app.openapi = custom_openapi """ setup_cors(app, mode="open") From 41ca202d4b128e3371192aa1fc857e1aa8c00be9 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 14:19:48 +0300 Subject: [PATCH 20/30] refactor(security): move security config to environment variables and improve error handling --- scripts/manage_security.py | 22 +++++++++++----------- security/auth.py | 19 ++++++++++++------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index b1c14de..17df3af 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,25 +1,25 @@ import asyncio import sys -import os from pathlib import Path -current_dir = Path(__file__).resolve().parent -parent_dir = current_dir.parent -sys.path.insert(0, str(parent_dir)) +from database import get_session +from database.models.api_key import SwaggerUser, ApiKey +from services.api_key import ApiKeyService +from security.auth import hash_password +from sqlalchemy import select import argparse from datetime import datetime import logging +current_dir = Path(__file__).resolve().parent +parent_dir = current_dir.parent +sys.path.insert(0, str(parent_dir)) + + logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) -from database import get_session -from database.models.api_key import SwaggerUser, ApiKey -from services.api_key import ApiKeyService -from security.auth import hash_password, verify_password -from sqlalchemy import select - async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" @@ -132,7 +132,7 @@ async def create_api_key( f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" ) else: - logger.info(f" Endpoints autorisés: Tous") + logger.info(" Endpoints autorisés: Tous") logger.info("=" * 60) logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") diff --git a/security/auth.py b/security/auth.py index 970a90f..3708708 100644 --- a/security/auth.py +++ b/security/auth.py @@ -4,11 +4,12 @@ from typing import Optional, Dict import jwt import secrets import hashlib +import os -SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV" -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 10080 -REFRESH_TOKEN_EXPIRE_DAYS = 7 +SECRET_KEY = os.getenv("JWT_SECRET") +ALGORITHM = os.getenv("JWT_ALGORITHM") +ACCESS_TOKEN_EXPIRE_MINUTES = os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES") +REFRESH_TOKEN_EXPIRE_DAYS = os.getenv("REFRESH_TOKEN_EXPIRE_DAYS") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -67,9 +68,13 @@ def decode_token(token: str) -> Optional[Dict]: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return payload except jwt.ExpiredSignatureError: - return None - except jwt.JWTError: - return None + raise jwt.InvalidTokenError("Token expiré") + except jwt.DecodeError: + raise jwt.InvalidTokenError("Token invalide (format incorrect)") + except jwt.InvalidTokenError as e: + raise jwt.InvalidTokenError(f"Token invalide: {str(e)}") + except Exception as e: + raise jwt.InvalidTokenError(f"Erreur lors du décodage du token: {str(e)}") def validate_password_strength(password: str) -> tuple[bool, str]: From c84e4ddc20b40ca4fcc660db7016cb862bdcc3fa Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 14:25:06 +0300 Subject: [PATCH 21/30] refactor(auth): simplify authentication logic and improve error handling --- api.py | 1 - core/dependencies.py | 163 +++++++++++++++---------------------------- 2 files changed, 56 insertions(+), 108 deletions(-) diff --git a/api.py b/api.py index 53a2273..c35e280 100644 --- a/api.py +++ b/api.py @@ -181,7 +181,6 @@ def custom_openapi(): openapi_schema = app.openapi() - # Définir deux schémas de sécurité openapi_schema["components"]["securitySchemes"] = { "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, diff --git a/core/dependencies.py b/core/dependencies.py index 8bb30ab..76c85be 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -2,13 +2,11 @@ from fastapi import Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from typing import Optional +from jwt.exceptions import InvalidTokenError + from database import get_session, User from security.auth import decode_token -from typing import Optional -from datetime import datetime -import logging - -logger = logging.getLogger(__name__) security = HTTPBearer(auto_error=False) @@ -18,62 +16,6 @@ async def get_current_user_hybrid( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), session: AsyncSession = Depends(get_session), ) -> User: - if credentials and credentials.credentials: - token = credentials.credentials - - payload = decode_token(token) - if not payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token invalide ou expiré", - headers={"WWW-Authenticate": "Bearer"}, - ) - - if payload.get("type") != "access": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Type de token incorrect", - headers={"WWW-Authenticate": "Bearer"}, - ) - - user_id: str = payload.get("sub") - if not user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token malformé", - headers={"WWW-Authenticate": "Bearer"}, - ) - - result = await session.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Utilisateur introuvable", - headers={"WWW-Authenticate": "Bearer"}, - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" - ) - - if not user.is_verified: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception.", - ) - - if user.locked_until and user.locked_until > datetime.now(): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé suite à trop de tentatives échouées", - ) - - logger.debug(f" Authentifié via JWT: {user.email}") - return user - api_key_obj = getattr(request.state, "api_key", None) if api_key_obj: @@ -84,69 +26,76 @@ async def get_current_user_hybrid( user = result.scalar_one_or_none() if user: - logger.debug( - f" Authentifié via API Key ({api_key_obj.name}) → User: {user.email}" - ) + user._is_api_key_user = True + user._api_key_obj = api_key_obj return user - from database import User as UserModel - - virtual_user = UserModel( + virtual_user = User( id=f"api_key_{api_key_obj.id}", - email=f"{api_key_obj.name.lower().replace(' ', '_')}@api-key.local", - nom="API Key", - prenom=api_key_obj.name, + email=f"api_key_{api_key_obj.id}@virtual.local", + username=api_key_obj.name, role="api_client", - is_verified=True, is_active=True, ) virtual_user._is_api_key_user = True virtual_user._api_key_obj = api_key_obj - logger.debug(f" Authentifié via API Key: {api_key_obj.name} (user virtuel)") return virtual_user - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentification requise (JWT ou API Key)", - headers={"WWW-Authenticate": "Bearer"}, - ) + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentification requise (JWT ou API Key)", + headers={"WWW-Authenticate": "Bearer"}, + ) + token = credentials.credentials -async def get_current_user_optional_hybrid( - request: Request, - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), - session: AsyncSession = Depends(get_session), -) -> Optional[User]: - """Version optionnelle de get_current_user_hybrid (ne lève pas d'erreur)""" try: - return await get_current_user_hybrid(request, credentials, session) - except HTTPException: - return None + payload = decode_token(token) + user_id: str = payload.get("sub") - -def require_role(*allowed_roles: str): - async def role_checker( - request: Request, user: User = Depends(get_current_user_hybrid) - ) -> User: - is_api_key_user = getattr(user, "_is_api_key_user", False) - - if is_api_key_user: - if "api_client" not in allowed_roles and "*" not in allowed_roles: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}. API Keys non autorisées.", - ) - logger.debug(" API Key autorisée pour cette route") - return user - - if user.role not in allowed_roles and "*" not in allowed_roles: + if user_id is None: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}", + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalide: user_id manquant", + headers={"WWW-Authenticate": "Bearer"}, ) + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Utilisateur introuvable", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Utilisateur inactif", + ) + + return user + + except InvalidTokenError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Token invalide: {str(e)}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def require_role_hybrid(*allowed_roles: str): + async def role_checker(user: User = Depends(get_current_user_hybrid)) -> User: + if user.role not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Accès interdit. Rôles autorisés: {', '.join(allowed_roles)}", + ) return user return role_checker @@ -158,9 +107,9 @@ def is_api_key_user(user: User) -> bool: def get_api_key_from_user(user: User): - """Récupère l'objet API Key depuis un user virtuel""" + """Récupère l'objet ApiKey depuis un utilisateur (si applicable)""" return getattr(user, "_api_key_obj", None) get_current_user = get_current_user_hybrid -get_current_user_optional = get_current_user_optional_hybrid +require_role = require_role_hybrid From 3cdb490ee5975877b43d8463c99b92a19769f3fb Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 14:47:07 +0300 Subject: [PATCH 22/30] refactor(security): improve middleware structure and configuration handling --- api.py | 4 +- database/db_config.py | 4 +- middleware/security.py | 345 +++++++++++++++++++++-------------------- security/auth.py | 11 +- 4 files changed, 188 insertions(+), 176 deletions(-) diff --git a/api.py b/api.py index c35e280..06f57e4 100644 --- a/api.py +++ b/api.py @@ -96,7 +96,7 @@ from utils.generic_functions import ( ) -from middleware.security import SwaggerAuthMiddleware, ApiKeyMiddleware +from middleware.security import SwaggerAuthMiddleware, ApiKeyMiddlewareHTTP from core.dependencies import get_current_user from config.cors_config import setup_cors from routes.api_keys import router as api_keys_router @@ -198,7 +198,7 @@ app.openapi = custom_openapi """ setup_cors(app, mode="open") app.add_middleware(SwaggerAuthMiddleware) -app.add_middleware(ApiKeyMiddleware) +app.add_middleware(ApiKeyMiddlewareHTTP) app.include_router(api_keys_router) app.include_router(auth_router) diff --git a/database/db_config.py b/database/db_config.py index bb98f5c..692822c 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -1,14 +1,14 @@ -import os from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.pool import NullPool from sqlalchemy import event, text import logging +from config.config import settings from database.models.generic_model import Base logger = logging.getLogger(__name__) -DATABASE_URL = os.getenv("DATABASE_URL") +DATABASE_URL = settings.database_url def _configure_sqlite_connection(dbapi_connection, connection_record): diff --git a/middleware/security.py b/middleware/security.py index 137e7dd..2bea533 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -1,49 +1,23 @@ from fastapi import Request, status from fastapi.responses import JSONResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp from sqlalchemy import select -from typing import Optional +from typing import Callable from datetime import datetime import logging - -from database import get_session -from database.models.api_key import SwaggerUser -from security.auth import verify_password +import base64 logger = logging.getLogger(__name__) security = HTTPBasic() -async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: - username = credentials.username - password = credentials.password - - try: - async for session in get_session(): - result = await session.execute( - select(SwaggerUser).where(SwaggerUser.username == username) - ) - swagger_user = result.scalar_one_or_none() - - if swagger_user and swagger_user.is_active: - if verify_password(password, swagger_user.hashed_password): - swagger_user.last_login = datetime.now() - await session.commit() - - logger.info(f" Accès Swagger autorisé (DB): {username}") - return True - - logger.warning(f" Tentative d'accès Swagger refusée: {username}") - return False - - except Exception as e: - logger.error(f" Erreur vérification Swagger credentials: {e}") - return False - - class SwaggerAuthMiddleware: - def __init__(self, app): + PROTECTED_PATHS = ["/docs", "/redoc", "/openapi.json"] + + def __init__(self, app: ASGIApp): self.app = app async def __call__(self, scope, receive, send): @@ -54,183 +28,220 @@ class SwaggerAuthMiddleware: request = Request(scope, receive=receive) path = request.url.path - protected_paths = ["/docs", "/redoc", "/openapi.json"] + if not any(path.startswith(p) for p in self.PROTECTED_PATHS): + await self.app(scope, receive, send) + return - if any(path.startswith(protected_path) for protected_path in protected_paths): - auth_header = request.headers.get("Authorization") + auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Basic "): + if not auth_header or not auth_header.startswith("Basic "): + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Authentification requise pour la documentation"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + try: + encoded_credentials = auth_header.split(" ")[1] + decoded_credentials = base64.b64decode(encoded_credentials).decode("utf-8") + username, password = decoded_credentials.split(":", 1) + + credentials = HTTPBasicCredentials(username=username, password=password) + + if not await self._verify_credentials(credentials): response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "detail": "Authentification requise pour accéder à la documentation" - }, + content={"detail": "Identifiants invalides"}, headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, ) await response(scope, receive, send) return - try: - import base64 - - encoded_credentials = auth_header.split(" ")[1] - decoded_credentials = base64.b64decode(encoded_credentials).decode( - "utf-8" - ) - username, password = decoded_credentials.split(":", 1) - - credentials = HTTPBasicCredentials(username=username, password=password) - - if not await verify_swagger_credentials(credentials): - response = JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={"detail": "Identifiants invalides"}, - headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, - ) - await response(scope, receive, send) - return - - except Exception as e: - logger.error(f" Erreur parsing auth header: {e}") - response = JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={"detail": "Format d'authentification invalide"}, - headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, - ) - await response(scope, receive, send) - return + except Exception as e: + logger.error(f"Erreur parsing auth header: {e}") + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Format d'authentification invalide"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return await self.app(scope, receive, send) + async def _verify_credentials(self, credentials: HTTPBasicCredentials) -> bool: + """Vérifie les identifiants dans la base de données""" + from database.db_config import async_session_factory + from database.models.api_key import SwaggerUser + from security.auth import verify_password -class ApiKeyMiddleware: - def __init__(self, app): - self.app = app + try: + async with async_session_factory() as session: + result = await session.execute( + select(SwaggerUser).where( + SwaggerUser.username == credentials.username + ) + ) + swagger_user = result.scalar_one_or_none() - async def __call__(self, scope, receive, send): - if scope["type"] != "http": - await self.app(scope, receive, send) - return + if swagger_user and swagger_user.is_active: + if verify_password( + credentials.password, swagger_user.hashed_password + ): + swagger_user.last_login = datetime.now() + await session.commit() + logger.info(f"✓ Accès Swagger autorisé: {credentials.username}") + return True - request = Request(scope, receive=receive) + logger.warning(f"✗ Accès Swagger refusé: {credentials.username}") + return False + + except Exception as e: + logger.error(f"Erreur vérification credentials: {e}") + return False + + +class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): + EXCLUDED_PATHS = [ + "/docs", + "/redoc", + "/openapi.json", + "/", + "/auth/login", + "/auth/register", + "/auth/verify-email", + "/auth/reset-password", + "/auth/request-reset", + "/auth/refresh", + ] + + async def dispatch(self, request: Request, call_next: Callable): path = request.url.path + method = request.method - excluded_paths = [ - "/docs", - "/redoc", - "/openapi.json", - "/health", - "/", - "/auth/login", - "/auth/register", - "/auth/verify-email", - "/auth/reset-password", - "/auth/request-reset", - "/auth/refresh", - ] - - if any(path.startswith(excluded_path) for excluded_path in excluded_paths): - await self.app(scope, receive, send) - return + if self._is_excluded_path(path): + return await call_next(request) auth_header = request.headers.get("Authorization") has_jwt = auth_header and auth_header.startswith("Bearer ") api_key = request.headers.get("X-API-Key") - has_api_key = api_key is not None + has_api_key = bool(api_key) if has_jwt: - logger.debug(f" JWT détecté pour {path}") - await self.app(scope, receive, send) - return + logger.debug(f"JWT détecté pour {method} {path}") + return await call_next(request) - elif has_api_key: - logger.debug(f" API Key détectée pour {path}") + if has_api_key: + logger.debug(f"API Key détectée pour {method} {path}") + return await self._handle_api_key_auth( + request, api_key, path, method, call_next + ) + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise", + "hint": "Utilisez 'X-API-Key: sdk_live_xxx' ou 'Authorization: Bearer '", + }, + headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, + ) + + def _is_excluded_path(self, path: str) -> bool: + """Vérifie si le chemin est exclu de l'authentification""" + if path == "/": + return True + + for excluded in self.EXCLUDED_PATHS: + if excluded == "/": + continue + if path == excluded or path.startswith(excluded + "/"): + return True + + return False + + async def _handle_api_key_auth( + self, + request: Request, + api_key: str, + path: str, + method: str, + call_next: Callable, + ): + """Gère l'authentification par API Key""" + try: + from database.db_config import async_session_factory from services.api_key import ApiKeyService - try: - async for session in get_session(): - api_key_service = ApiKeyService(session) - api_key_obj = await api_key_service.verify_api_key(api_key) + async with async_session_factory() as session: + service = ApiKeyService(session) + api_key_obj = await service.verify_api_key(api_key) - if not api_key_obj: - response = JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "detail": "Clé API invalide ou expirée", - "hint": "Utilisez X-API-Key: sdk_live_xxx ou Authorization: Bearer ", - }, - ) - await response(scope, receive, send) - return - - is_allowed, rate_info = await api_key_service.check_rate_limit( - api_key_obj + if not api_key_obj: + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Clé API invalide ou expirée", + "hint": "Vérifiez votre clé X-API-Key", + }, ) - if not is_allowed: - response = JSONResponse( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - content={"detail": "Rate limit dépassé"}, - headers={ - "X-RateLimit-Limit": str(rate_info["limit"]), - "X-RateLimit-Remaining": "0", - }, - ) - await response(scope, receive, send) - return - has_access = await api_key_service.check_endpoint_access( - api_key_obj, path + is_allowed, rate_info = await service.check_rate_limit(api_key_obj) + if not is_allowed: + return JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content={"detail": "Rate limit dépassé"}, + headers={ + "X-RateLimit-Limit": str(rate_info["limit"]), + "X-RateLimit-Remaining": "0", + }, ) - if not has_access: - response = JSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={ - "detail": "Accès non autorisé à cet endpoint", - "endpoint": path, - "api_key": api_key_obj.key_prefix + "...", - }, - ) - await response(scope, receive, send) - return - request.state.api_key = api_key_obj - request.state.authenticated_via = "api_key" + has_access = await service.check_endpoint_access(api_key_obj, path) + if not has_access: + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={ + "detail": "Accès non autorisé à cet endpoint", + "endpoint": path, + "api_key": api_key_obj.key_prefix + "...", + }, + ) - logger.info(f" API Key valide: {api_key_obj.name} → {path}") + request.state.api_key = api_key_obj + request.state.authenticated_via = "api_key" - await self.app(scope, receive, send) - return + logger.info(f"✓ API Key valide: {api_key_obj.name} → {method} {path}") - except Exception as e: - logger.error(f" Erreur validation API Key: {e}", exc_info=True) - response = JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={ - "detail": "Erreur interne lors de la validation de la clé" - }, - ) - await response(scope, receive, send) - return + return await call_next(request) - else: - response = JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "detail": "Authentification requise", - "hint": "Utilisez soit 'X-API-Key: sdk_live_xxx' soit 'Authorization: Bearer '", - }, - headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, + except Exception as e: + logger.error(f"Erreur validation API Key: {e}", exc_info=True) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Erreur interne lors de la validation"}, ) - await response(scope, receive, send) - return -def get_api_key_from_request(request: Request) -> Optional: +ApiKeyMiddleware = ApiKeyMiddlewareHTTP + + +def get_api_key_from_request(request: Request): """Récupère l'objet ApiKey depuis la requête si présent""" return getattr(request.state, "api_key", None) def get_auth_method(request: Request) -> str: + """Retourne la méthode d'authentification utilisée""" return getattr(request.state, "authenticated_via", "none") + + +__all__ = [ + "SwaggerAuthMiddleware", + "ApiKeyMiddlewareHTTP", + "ApiKeyMiddleware", + "get_api_key_from_request", + "get_auth_method", +] diff --git a/security/auth.py b/security/auth.py index 3708708..e05b6a0 100644 --- a/security/auth.py +++ b/security/auth.py @@ -4,12 +4,13 @@ from typing import Optional, Dict import jwt import secrets import hashlib -import os -SECRET_KEY = os.getenv("JWT_SECRET") -ALGORITHM = os.getenv("JWT_ALGORITHM") -ACCESS_TOKEN_EXPIRE_MINUTES = os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES") -REFRESH_TOKEN_EXPIRE_DAYS = os.getenv("REFRESH_TOKEN_EXPIRE_DAYS") +from config.config import settings + +SECRET_KEY = settings.jwt_secret +ALGORITHM = settings.jwt_algorithm +ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minutes +REFRESH_TOKEN_EXPIRE_DAYS = settings.refresh_token_expire_days pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") From 1a08894b4718e3d374056a9bc4f57a0068d06266 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 15:01:57 +0300 Subject: [PATCH 23/30] refactor(scripts): improve logging format and endpoint handling in security management --- middleware/security.py | 2 +- scripts/manage_security.py | 80 +++++++++++++++++++++++--------------- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/middleware/security.py b/middleware/security.py index 2bea533..493ed3e 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -4,7 +4,7 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials from starlette.middleware.base import BaseHTTPMiddleware from starlette.types import ASGIApp from sqlalchemy import select -from typing import Callable +from typing import Optional, Callable from datetime import datetime import logging import base64 diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 17df3af..52b21c7 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,7 +1,6 @@ import asyncio import sys from pathlib import Path - from database import get_session from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService @@ -31,7 +30,7 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): existing = result.scalar_one_or_none() if existing: - logger.error(f" L'utilisateur '{username}' existe déjà") + logger.error(f"❌ L'utilisateur '{username}' existe déjà") return swagger_user = SwaggerUser( @@ -44,7 +43,7 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): session.add(swagger_user) await session.commit() - logger.info(f" Utilisateur Swagger créé: {username}") + logger.info(f"✅ Utilisateur Swagger créé: {username}") logger.info(f" Nom complet: {swagger_user.full_name}") logger.info(f" Actif: {swagger_user.is_active}") @@ -59,13 +58,13 @@ async def list_swagger_users(): users = result.scalars().all() if not users: - logger.info(" Aucun utilisateur Swagger") + logger.info("📭 Aucun utilisateur Swagger") break - logger.info(f" {len(users)} utilisateur(s) Swagger:\n") + logger.info(f"👥 {len(users)} utilisateur(s) Swagger:\n") for user in users: - status = "" if user.is_active else "" + status = "✅" if user.is_active else "❌" logger.info(f" {status} {user.username}") logger.info(f" Nom: {user.full_name}") logger.info(f" Créé: {user.created_at}") @@ -85,13 +84,13 @@ async def delete_swagger_user(username: str): user = result.scalar_one_or_none() if not user: - logger.error(f" Utilisateur '{username}' introuvable") + logger.error(f"❌ Utilisateur '{username}' introuvable") break await session.delete(user) await session.commit() - logger.info(f" Utilisateur Swagger supprimé: {username}") + logger.info(f"🗑️ Utilisateur Swagger supprimé: {username}") break @@ -116,9 +115,9 @@ async def create_api_key( allowed_endpoints=endpoints, ) - logger.info("=" * 60) - logger.info(" Clé API créée avec succès") - logger.info("=" * 60) + logger.info("=" * 70) + logger.info("🔑 Clé API créée avec succès") + logger.info("=" * 70) logger.info(f" ID: {api_key_obj.id}") logger.info(f" Nom: {api_key_obj.name}") logger.info(f" Clé: {api_key_plain}") @@ -128,15 +127,16 @@ async def create_api_key( logger.info(f" Expire le: {api_key_obj.expires_at}") if api_key_obj.allowed_endpoints: - logger.info( - f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" - ) + import json + + endpoints_list = json.loads(api_key_obj.allowed_endpoints) + logger.info(f" Endpoints autorisés: {', '.join(endpoints_list)}") else: logger.info(" Endpoints autorisés: Tous") - logger.info("=" * 60) - logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") - logger.info("=" * 60) + logger.info("=" * 70) + logger.info("⚠️ IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") + logger.info("=" * 70) break @@ -149,17 +149,17 @@ async def list_api_keys(): keys = await service.list_api_keys() if not keys: - logger.info(" Aucune clé API") + logger.info("📭 Aucune clé API") break - logger.info(f" {len(keys)} clé(s) API:\n") + logger.info(f"🔑 {len(keys)} clé(s) API:\n") for key in keys: status = ( - "" + "✅" if key.is_active and (not key.expires_at or key.expires_at > datetime.now()) - else "" + else "❌" ) logger.info(f" {status} {key.name:<30} ({key.key_prefix}...)") @@ -171,9 +171,15 @@ async def list_api_keys(): logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") if key.allowed_endpoints: - logger.info( - f" Endpoints: {', '.join(key.allowed_endpoints[:3])}..." - ) + import json + + try: + endpoints = json.loads(key.allowed_endpoints) + logger.info( + f" Endpoints: {', '.join(endpoints[:5])}{'...' if len(endpoints) > 5 else ''}" + ) + except: + pass logger.info("") @@ -190,13 +196,13 @@ async def revoke_api_key(key_id: str): key = result.scalar_one_or_none() if not key: - logger.error(f" Clé API '{key_id}' introuvable") + logger.error(f"❌ Clé API '{key_id}' introuvable") break key.is_active = False await session.commit() - logger.info(f" Clé API révoquée: {key.name}") + logger.info(f"🗑️ Clé API révoquée: {key.name}") logger.info(f" ID: {key.id}") logger.info(f" Préfixe: {key.key_prefix}") @@ -212,11 +218,11 @@ async def verify_api_key(api_key: str): key = await service.verify_api_key(api_key) if not key: - logger.error(" Clé API invalide ou expirée") + logger.error("❌ Clé API invalide ou expirée") break logger.info("=" * 60) - logger.info(" Clé API valide") + logger.info("✅ Clé API valide") logger.info("=" * 60) logger.info(f" Nom: {key.name}") logger.info(f" ID: {key.id}") @@ -224,6 +230,18 @@ async def verify_api_key(api_key: str): logger.info(f" Requêtes totales: {key.total_requests}") logger.info(f" Expire le: {key.expires_at or 'Jamais'}") logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + + if key.allowed_endpoints: + import json + + try: + endpoints = json.loads(key.allowed_endpoints) + logger.info(f" Endpoints autorisés: {endpoints}") + except: + pass + else: + logger.info(" Endpoints autorisés: Tous") + logger.info("=" * 60) break @@ -267,7 +285,7 @@ async def main(): create_parser.add_argument( "--endpoints", nargs="+", - help="Endpoints autorisés (ex: /clients /articles)", + help="Endpoints autorisés (ex: /clients /articles /devis/*)", ) apikey_subparsers.add_parser("list", help="Lister les clés API") @@ -317,10 +335,10 @@ if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - logger.info("\n Interrupted") + logger.info("\n⏹️ Interrupted") sys.exit(0) except Exception as e: - logger.error(f" Erreur: {e}") + logger.error(f"❌ Erreur: {e}") import traceback traceback.print_exc() From f8cec7ebc501b4587adca20a86b4ee211a6eccc5 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 15:13:19 +0300 Subject: [PATCH 24/30] refactor(security): improve security scripts and api documentation --- api.py | 12 ++- middleware/security.py | 4 +- scripts/manage_security.py | 178 ++++++++++++++++++------------------- 3 files changed, 99 insertions(+), 95 deletions(-) diff --git a/api.py b/api.py index 06f57e4..97d4425 100644 --- a/api.py +++ b/api.py @@ -1,5 +1,5 @@ from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body -from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.utils import get_openapi from fastapi.responses import StreamingResponse, HTMLResponse, Response from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field, EmailStr @@ -179,7 +179,12 @@ def custom_openapi(): if app.openapi_schema: return app.openapi_schema - openapi_schema = app.openapi() + openapi_schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) openapi_schema["components"]["securitySchemes"] = { "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, @@ -192,8 +197,7 @@ def custom_openapi(): return app.openapi_schema -""" # Après app = FastAPI(...), ajouter: -app.openapi = custom_openapi """ +app.openapi = custom_openapi setup_cors(app, mode="open") diff --git a/middleware/security.py b/middleware/security.py index 493ed3e..8b2d90a 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -1,3 +1,4 @@ + from fastapi import Request, status from fastapi.responses import JSONResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials @@ -15,6 +16,7 @@ security = HTTPBasic() class SwaggerAuthMiddleware: + PROTECTED_PATHS = ["/docs", "/redoc", "/openapi.json"] def __init__(self, app: ASGIApp): @@ -241,7 +243,7 @@ def get_auth_method(request: Request) -> str: __all__ = [ "SwaggerAuthMiddleware", "ApiKeyMiddlewareHTTP", - "ApiKeyMiddleware", + "ApiKeyMiddleware", # Alias "get_api_key_from_request", "get_auth_method", ] diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 52b21c7..e2c0297 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,28 +1,44 @@ -import asyncio +#!/usr/bin/env python3 +""" +Script de gestion des utilisateurs Swagger et clés API +====================================================== + +Usage (depuis /app dans le container Docker): + python scripts/manage_security.py swagger add + python scripts/manage_security.py swagger list + python scripts/manage_security.py apikey create --endpoints "/clients" "/devis" + python scripts/manage_security.py apikey list + python scripts/manage_security.py apikey verify +""" + import sys from pathlib import Path -from database import get_session + +_script_dir = Path(__file__).resolve().parent +_app_dir = _script_dir.parent +if str(_app_dir) not in sys.path: + sys.path.insert(0, str(_app_dir)) + +import asyncio +import argparse +import logging +from datetime import datetime + +from sqlalchemy import select + +from database.db_config import get_session from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password -from sqlalchemy import select - -import argparse -from datetime import datetime -import logging - -current_dir = Path(__file__).resolve().parent -parent_dir = current_dir.parent -sys.path.insert(0, str(parent_dir)) - logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) + + async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" - async for session in get_session(): result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) @@ -45,14 +61,11 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): logger.info(f"✅ Utilisateur Swagger créé: {username}") logger.info(f" Nom complet: {swagger_user.full_name}") - logger.info(f" Actif: {swagger_user.is_active}") - break async def list_swagger_users(): """Lister tous les utilisateurs Swagger""" - async for session in get_session(): result = await session.execute(select(SwaggerUser)) users = result.scalars().all() @@ -62,21 +75,17 @@ async def list_swagger_users(): break logger.info(f"👥 {len(users)} utilisateur(s) Swagger:\n") - for user in users: status = "✅" if user.is_active else "❌" logger.info(f" {status} {user.username}") logger.info(f" Nom: {user.full_name}") logger.info(f" Créé: {user.created_at}") - logger.info(f" Dernière connexion: {user.last_login or 'Jamais'}") - logger.info("") - + logger.info(f" Dernière connexion: {user.last_login or 'Jamais'}\n") break async def delete_swagger_user(username: str): """Supprimer un utilisateur Swagger""" - async for session in get_session(): result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) @@ -89,11 +98,12 @@ async def delete_swagger_user(username: str): await session.delete(user) await session.commit() - logger.info(f"🗑️ Utilisateur Swagger supprimé: {username}") break + + async def create_api_key( name: str, description: str = None, @@ -102,7 +112,6 @@ async def create_api_key( endpoints: list = None, ): """Créer une clé API""" - async for session in get_session(): service = ApiKeyService(session) @@ -123,27 +132,27 @@ async def create_api_key( logger.info(f" Clé: {api_key_plain}") logger.info(f" Préfixe: {api_key_obj.key_prefix}") logger.info(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") - logger.info(f" Créée le: {api_key_obj.created_at}") logger.info(f" Expire le: {api_key_obj.expires_at}") if api_key_obj.allowed_endpoints: import json - endpoints_list = json.loads(api_key_obj.allowed_endpoints) - logger.info(f" Endpoints autorisés: {', '.join(endpoints_list)}") + try: + endpoints_list = json.loads(api_key_obj.allowed_endpoints) + logger.info(f" Endpoints: {', '.join(endpoints_list)}") + except: + logger.info(f" Endpoints: {api_key_obj.allowed_endpoints}") else: - logger.info(" Endpoints autorisés: Tous") + logger.info(" Endpoints: Tous (aucune restriction)") logger.info("=" * 70) - logger.info("⚠️ IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") + logger.info("⚠️ SAUVEGARDEZ CETTE CLÉ - Elle ne sera plus affichée !") logger.info("=" * 70) - break async def list_api_keys(): """Lister toutes les clés API""" - async for session in get_session(): service = ApiKeyService(session) keys = await service.list_api_keys() @@ -155,19 +164,16 @@ async def list_api_keys(): logger.info(f"🔑 {len(keys)} clé(s) API:\n") for key in keys: - status = ( - "✅" - if key.is_active - and (not key.expires_at or key.expires_at > datetime.now()) - else "❌" + is_valid = key.is_active and ( + not key.expires_at or key.expires_at > datetime.now() ) + status = "✅" if is_valid else "❌" logger.info(f" {status} {key.name:<30} ({key.key_prefix}...)") logger.info(f" ID: {key.id}") logger.info(f" Rate limit: {key.rate_limit_per_minute} req/min") logger.info(f" Requêtes: {key.total_requests}") - logger.info(f" Créée le: {key.created_at}") - logger.info(f" Expire le: {key.expires_at or 'Jamais'}") + logger.info(f" Expire: {key.expires_at or 'Jamais'}") logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") if key.allowed_endpoints: @@ -175,23 +181,21 @@ async def list_api_keys(): try: endpoints = json.loads(key.allowed_endpoints) - logger.info( - f" Endpoints: {', '.join(endpoints[:5])}{'...' if len(endpoints) > 5 else ''}" - ) + display = ", ".join(endpoints[:4]) + if len(endpoints) > 4: + display += f"... (+{len(endpoints) - 4})" + logger.info(f" Endpoints: {display}") except: pass - + else: + logger.info(" Endpoints: Tous") logger.info("") - break async def revoke_api_key(key_id: str): """Révoquer une clé API""" - async for session in get_session(): - service = ApiKeyService(session) - result = await session.execute(select(ApiKey).where(ApiKey.id == key_id)) key = result.scalar_one_or_none() @@ -200,21 +204,18 @@ async def revoke_api_key(key_id: str): break key.is_active = False + key.revoked_at = datetime.now() await session.commit() logger.info(f"🗑️ Clé API révoquée: {key.name}") logger.info(f" ID: {key.id}") - logger.info(f" Préfixe: {key.key_prefix}") - break async def verify_api_key(api_key: str): """Vérifier une clé API""" - async for session in get_session(): service = ApiKeyService(session) - key = await service.verify_api_key(api_key) if not key: @@ -228,8 +229,7 @@ async def verify_api_key(api_key: str): logger.info(f" ID: {key.id}") logger.info(f" Rate limit: {key.rate_limit_per_minute} req/min") logger.info(f" Requêtes totales: {key.total_requests}") - logger.info(f" Expire le: {key.expires_at or 'Jamais'}") - logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + logger.info(f" Expire: {key.expires_at or 'Jamais'}") if key.allowed_endpoints: import json @@ -241,60 +241,58 @@ async def verify_api_key(api_key: str): pass else: logger.info(" Endpoints autorisés: Tous") - logger.info("=" * 60) - break + + async def main(): parser = argparse.ArgumentParser( - description="Gestion des utilisateurs Swagger et clés API" + description="Gestion des utilisateurs Swagger et clés API", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Exemples: + python scripts/manage_security.py swagger add admin MyP@ssw0rd + python scripts/manage_security.py swagger list + python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100 + python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*" + python scripts/manage_security.py apikey list + python scripts/manage_security.py apikey verify sdk_live_xxxxx + """, ) - subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles") + subparsers = parser.add_subparsers(dest="command", help="Commandes") - swagger_parser = subparsers.add_parser( - "swagger", help="Gestion des utilisateurs Swagger" - ) - swagger_subparsers = swagger_parser.add_subparsers(dest="swagger_command") + swagger_parser = subparsers.add_parser("swagger", help="Gestion Swagger") + swagger_sub = swagger_parser.add_subparsers(dest="swagger_command") - add_parser = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") - add_parser.add_argument("username", help="Nom d'utilisateur") - add_parser.add_argument("password", help="Mot de passe") - add_parser.add_argument("--full-name", help="Nom complet (optionnel)") + add_p = swagger_sub.add_parser("add", help="Ajouter utilisateur") + add_p.add_argument("username", help="Nom d'utilisateur") + add_p.add_argument("password", help="Mot de passe") + add_p.add_argument("--full-name", help="Nom complet") - swagger_subparsers.add_parser("list", help="Lister les utilisateurs") + swagger_sub.add_parser("list", help="Lister utilisateurs") - delete_parser = swagger_subparsers.add_parser( - "delete", help="Supprimer un utilisateur" - ) - delete_parser.add_argument("username", help="Nom d'utilisateur") + del_p = swagger_sub.add_parser("delete", help="Supprimer utilisateur") + del_p.add_argument("username", help="Nom d'utilisateur") - apikey_parser = subparsers.add_parser("apikey", help="Gestion des clés API") - apikey_subparsers = apikey_parser.add_subparsers(dest="apikey_command") + apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API") + apikey_sub = apikey_parser.add_subparsers(dest="apikey_command") - create_parser = apikey_subparsers.add_parser("create", help="Créer une clé API") - create_parser.add_argument("name", help="Nom de la clé") - create_parser.add_argument("--description", help="Description (optionnel)") - create_parser.add_argument( - "--days", type=int, default=365, help="Jours avant expiration (défaut: 365)" - ) - create_parser.add_argument( - "--rate-limit", type=int, default=60, help="Requêtes par minute (défaut: 60)" - ) - create_parser.add_argument( - "--endpoints", - nargs="+", - help="Endpoints autorisés (ex: /clients /articles /devis/*)", - ) + create_p = apikey_sub.add_parser("create", help="Créer clé API") + create_p.add_argument("name", help="Nom de la clé") + create_p.add_argument("--description", help="Description") + create_p.add_argument("--days", type=int, default=365, help="Expiration (jours)") + create_p.add_argument("--rate-limit", type=int, default=60, help="Req/min") + create_p.add_argument("--endpoints", nargs="+", help="Endpoints autorisés") - apikey_subparsers.add_parser("list", help="Lister les clés API") + apikey_sub.add_parser("list", help="Lister clés") - revoke_parser = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") - revoke_parser.add_argument("key_id", help="ID de la clé") + rev_p = apikey_sub.add_parser("revoke", help="Révoquer clé") + rev_p.add_argument("key_id", help="ID de la clé") - verify_parser = apikey_subparsers.add_parser("verify", help="Vérifier une clé") - verify_parser.add_argument("api_key", help="Clé API complète") + ver_p = apikey_sub.add_parser("verify", help="Vérifier clé") + ver_p.add_argument("api_key", help="Clé API complète") args = parser.parse_args() @@ -335,7 +333,7 @@ if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - logger.info("\n⏹️ Interrupted") + print("\n⏹️ Interrupted") sys.exit(0) except Exception as e: logger.error(f"❌ Erreur: {e}") From 28c8fb3008d004b9ecad3e94df3131c0ac7ddbcc Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 15:27:26 +0300 Subject: [PATCH 25/30] refactor(security): improve user management and session handling --- core/dependencies.py | 5 ++- scripts/manage_security.py | 85 ++++++++++++++------------------------ 2 files changed, 35 insertions(+), 55 deletions(-) diff --git a/core/dependencies.py b/core/dependencies.py index 76c85be..c1468dd 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -33,9 +33,12 @@ async def get_current_user_hybrid( virtual_user = User( id=f"api_key_{api_key_obj.id}", email=f"api_key_{api_key_obj.id}@virtual.local", - username=api_key_obj.name, + nom=api_key_obj.name, + prenom="API", + hashed_password="", role="api_client", is_active=True, + is_verified=True, ) virtual_user._is_api_key_user = True diff --git a/scripts/manage_security.py b/scripts/manage_security.py index e2c0297..cfeadcf 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,24 +1,6 @@ -#!/usr/bin/env python3 -""" -Script de gestion des utilisateurs Swagger et clés API -====================================================== - -Usage (depuis /app dans le container Docker): - python scripts/manage_security.py swagger add - python scripts/manage_security.py swagger list - python scripts/manage_security.py apikey create --endpoints "/clients" "/devis" - python scripts/manage_security.py apikey list - python scripts/manage_security.py apikey verify -""" - import sys +import os from pathlib import Path - -_script_dir = Path(__file__).resolve().parent -_app_dir = _script_dir.parent -if str(_app_dir) not in sys.path: - sys.path.insert(0, str(_app_dir)) - import asyncio import argparse import logging @@ -26,20 +8,26 @@ from datetime import datetime from sqlalchemy import select -from database.db_config import get_session +from database.db_config import async_session_factory +from database.models.user import User from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password +_script_dir = Path(__file__).resolve().parent +_app_dir = _script_dir.parent + +sys.path.insert(0, str(_app_dir)) +os.chdir(str(_app_dir)) + + logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) - - async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" - async for session in get_session(): + async with async_session_factory() as session: result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) ) @@ -61,18 +49,17 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): logger.info(f"✅ Utilisateur Swagger créé: {username}") logger.info(f" Nom complet: {swagger_user.full_name}") - break async def list_swagger_users(): """Lister tous les utilisateurs Swagger""" - async for session in get_session(): + async with async_session_factory() as session: result = await session.execute(select(SwaggerUser)) users = result.scalars().all() if not users: - logger.info("📭 Aucun utilisateur Swagger") - break + logger.info("🔭 Aucun utilisateur Swagger") + return logger.info(f"👥 {len(users)} utilisateur(s) Swagger:\n") for user in users: @@ -81,12 +68,11 @@ async def list_swagger_users(): logger.info(f" Nom: {user.full_name}") logger.info(f" Créé: {user.created_at}") logger.info(f" Dernière connexion: {user.last_login or 'Jamais'}\n") - break async def delete_swagger_user(username: str): """Supprimer un utilisateur Swagger""" - async for session in get_session(): + async with async_session_factory() as session: result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) ) @@ -94,14 +80,11 @@ async def delete_swagger_user(username: str): if not user: logger.error(f"❌ Utilisateur '{username}' introuvable") - break + return await session.delete(user) await session.commit() logger.info(f"🗑️ Utilisateur Swagger supprimé: {username}") - break - - async def create_api_key( @@ -112,7 +95,7 @@ async def create_api_key( endpoints: list = None, ): """Créer une clé API""" - async for session in get_session(): + async with async_session_factory() as session: service = ApiKeyService(session) api_key_obj, api_key_plain = await service.create_api_key( @@ -148,18 +131,17 @@ async def create_api_key( logger.info("=" * 70) logger.info("⚠️ SAUVEGARDEZ CETTE CLÉ - Elle ne sera plus affichée !") logger.info("=" * 70) - break async def list_api_keys(): """Lister toutes les clés API""" - async for session in get_session(): + async with async_session_factory() as session: service = ApiKeyService(session) keys = await service.list_api_keys() if not keys: - logger.info("📭 Aucune clé API") - break + logger.info("🔭 Aucune clé API") + return logger.info(f"🔑 {len(keys)} clé(s) API:\n") @@ -190,18 +172,17 @@ async def list_api_keys(): else: logger.info(" Endpoints: Tous") logger.info("") - break async def revoke_api_key(key_id: str): """Révoquer une clé API""" - async for session in get_session(): + async with async_session_factory() as session: result = await session.execute(select(ApiKey).where(ApiKey.id == key_id)) key = result.scalar_one_or_none() if not key: logger.error(f"❌ Clé API '{key_id}' introuvable") - break + return key.is_active = False key.revoked_at = datetime.now() @@ -209,18 +190,17 @@ async def revoke_api_key(key_id: str): logger.info(f"🗑️ Clé API révoquée: {key.name}") logger.info(f" ID: {key.id}") - break async def verify_api_key(api_key: str): """Vérifier une clé API""" - async for session in get_session(): + async with async_session_factory() as session: service = ApiKeyService(session) key = await service.verify_api_key(api_key) if not key: logger.error("❌ Clé API invalide ou expirée") - break + return logger.info("=" * 60) logger.info("✅ Clé API valide") @@ -242,9 +222,6 @@ async def verify_api_key(api_key: str): else: logger.info(" Endpoints autorisés: Tous") logger.info("=" * 60) - break - - async def main(): @@ -253,12 +230,12 @@ async def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Exemples: - python scripts/manage_security.py swagger add admin MyP@ssw0rd - python scripts/manage_security.py swagger list - python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100 - python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*" - python scripts/manage_security.py apikey list - python scripts/manage_security.py apikey verify sdk_live_xxxxx + python scripts/manage_security.py swagger add admin MyP@ssw0rd + python scripts/manage_security.py swagger list + python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100 + python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*" + python scripts/manage_security.py apikey list + python scripts/manage_security.py apikey verify sdk_live_xxxxx """, ) subparsers = parser.add_subparsers(dest="command", help="Commandes") @@ -333,7 +310,7 @@ if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - print("\n⏹️ Interrupted") + print("\nℹ️ Interrupted") sys.exit(0) except Exception as e: logger.error(f"❌ Erreur: {e}") From 82d1d92e587a5ee9325aace77345cea4124c443d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 15:35:06 +0300 Subject: [PATCH 26/30] refactor(scripts): improve import handling and path management --- scripts/manage_security.py | 69 ++++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index cfeadcf..59f7ee2 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,6 +1,40 @@ import sys import os from pathlib import Path + +_current_file = Path(__file__).resolve() +_script_dir = _current_file.parent +_app_dir = _script_dir.parent + +print(f"DEBUG: Script path: {_current_file}") +print(f"DEBUG: App dir: {_app_dir}") +print(f"DEBUG: Current working dir: {os.getcwd()}") + +if str(_app_dir) in sys.path: + sys.path.remove(str(_app_dir)) +sys.path.insert(0, str(_app_dir)) + +os.chdir(str(_app_dir)) + +print(f"DEBUG: sys.path[0]: {sys.path[0]}") +print(f"DEBUG: New working dir: {os.getcwd()}") + +_test_imports = [ + "database", + "database.db_config", + "database.models", + "services", + "security", +] + +print("\nDEBUG: Vérification des imports...") +for module in _test_imports: + try: + __import__(module) + print(f" ✅ {module}") + except ImportError as e: + print(f" ❌ {module}: {e}") + import asyncio import argparse import logging @@ -8,18 +42,17 @@ from datetime import datetime from sqlalchemy import select -from database.db_config import async_session_factory -from database.models.user import User -from database.models.api_key import SwaggerUser, ApiKey -from services.api_key import ApiKeyService -from security.auth import hash_password - -_script_dir = Path(__file__).resolve().parent -_app_dir = _script_dir.parent - -sys.path.insert(0, str(_app_dir)) -os.chdir(str(_app_dir)) - +try: + from database.db_config import async_session_factory + from database.models.user import User + from database.models.api_key import SwaggerUser, ApiKey + from services.api_key import ApiKeyService + from security.auth import hash_password +except ImportError as e: + print(f"\n❌ ERREUR D'IMPORT: {e}") + print(f" Vérifiez que vous êtes dans /app") + print(f" Commande correcte: cd /app && python scripts/manage_security.py ...") + sys.exit(1) logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) @@ -230,12 +263,12 @@ async def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Exemples: - python scripts/manage_security.py swagger add admin MyP@ssw0rd - python scripts/manage_security.py swagger list - python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100 - python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*" - python scripts/manage_security.py apikey list - python scripts/manage_security.py apikey verify sdk_live_xxxxx + python scripts/manage_security.py swagger add admin MyP@ssw0rd + python scripts/manage_security.py swagger list + python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100 + python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*" + python scripts/manage_security.py apikey list + python scripts/manage_security.py apikey verify sdk_live_xxxxx """, ) subparsers = parser.add_subparsers(dest="command", help="Commandes") From 67ef83c4e332f4c40983b879581e08df94c01575 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 16:01:54 +0300 Subject: [PATCH 27/30] refactor(security): improve authentication logging and endpoint checks --- middleware/security.py | 99 +++++++++++++++++++++----------------- scripts/manage_security.py | 24 ++++----- services/api_key.py | 34 ++++++++++--- 3 files changed, 92 insertions(+), 65 deletions(-) diff --git a/middleware/security.py b/middleware/security.py index 8b2d90a..e9ff831 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -1,11 +1,10 @@ - from fastapi import Request, status from fastapi.responses import JSONResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials from starlette.middleware.base import BaseHTTPMiddleware from starlette.types import ASGIApp from sqlalchemy import select -from typing import Optional, Callable +from typing import Callable from datetime import datetime import logging import base64 @@ -16,7 +15,6 @@ security = HTTPBasic() class SwaggerAuthMiddleware: - PROTECTED_PATHS = ["/docs", "/redoc", "/openapi.json"] def __init__(self, app: ASGIApp): @@ -111,46 +109,11 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): "/redoc", "/openapi.json", "/", - "/auth/login", - "/auth/register", - "/auth/verify-email", - "/auth/reset-password", - "/auth/request-reset", - "/auth/refresh", + "/health", + "/auth", + "/api-keys/verify", ] - async def dispatch(self, request: Request, call_next: Callable): - path = request.url.path - method = request.method - - if self._is_excluded_path(path): - return await call_next(request) - - auth_header = request.headers.get("Authorization") - has_jwt = auth_header and auth_header.startswith("Bearer ") - - api_key = request.headers.get("X-API-Key") - has_api_key = bool(api_key) - - if has_jwt: - logger.debug(f"JWT détecté pour {method} {path}") - return await call_next(request) - - if has_api_key: - logger.debug(f"API Key détectée pour {method} {path}") - return await self._handle_api_key_auth( - request, api_key, path, method, call_next - ) - - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "detail": "Authentification requise", - "hint": "Utilisez 'X-API-Key: sdk_live_xxx' ou 'Authorization: Bearer '", - }, - headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, - ) - def _is_excluded_path(self, path: str) -> bool: """Vérifie si le chemin est exclu de l'authentification""" if path == "/": @@ -164,6 +127,41 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): return False + async def dispatch(self, request: Request, call_next: Callable): + path = request.url.path + method = request.method + + if self._is_excluded_path(path): + logger.debug(f" Route publique: {method} {path}") + return await call_next(request) + + auth_header = request.headers.get("Authorization") + has_jwt = auth_header and auth_header.startswith("Bearer ") + + api_key = request.headers.get("X-API-Key") + has_api_key = bool(api_key) + + if has_jwt: + logger.debug(f" JWT détecté pour {method} {path}") + return await call_next(request) + + if has_api_key: + logger.debug(f" API Key détectée pour {method} {path}") + return await self._handle_api_key_auth( + request, api_key, path, method, call_next + ) + + logger.warning(f" Aucune authentification pour {method} {path}") + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise", + "hint": "Utilisez 'X-API-Key: sdk_live_xxx' ou 'Authorization: Bearer '", + "path": path, + }, + headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, + ) + async def _handle_api_key_auth( self, request: Request, @@ -179,9 +177,11 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): async with async_session_factory() as session: service = ApiKeyService(session) + api_key_obj = await service.verify_api_key(api_key) if not api_key_obj: + logger.warning(f" Clé API invalide pour {method} {path}") return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ @@ -192,6 +192,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): is_allowed, rate_info = await service.check_rate_limit(api_key_obj) if not is_allowed: + logger.warning(f"⚠️ Rate limit dépassé: {api_key_obj.name}") return JSONResponse( status_code=status.HTTP_429_TOO_MANY_REQUESTS, content={"detail": "Rate limit dépassé"}, @@ -203,24 +204,32 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): has_access = await service.check_endpoint_access(api_key_obj, path) if not has_access: + logger.warning( + f"Accès refusé: {api_key_obj.name} → {method} {path}" + ) return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, content={ "detail": "Accès non autorisé à cet endpoint", "endpoint": path, - "api_key": api_key_obj.key_prefix + "...", + "api_key_name": api_key_obj.name, + "allowed_endpoints": ( + api_key_obj.allowed_endpoints + if api_key_obj.allowed_endpoints + else "Tous" + ), }, ) request.state.api_key = api_key_obj request.state.authenticated_via = "api_key" - logger.info(f"✓ API Key valide: {api_key_obj.name} → {method} {path}") + logger.info(f"✅ API Key valide: {api_key_obj.name} → {method} {path}") return await call_next(request) except Exception as e: - logger.error(f"Erreur validation API Key: {e}", exc_info=True) + logger.error(f" Erreur validation API Key: {e}", exc_info=True) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "Erreur interne lors de la validation"}, @@ -243,7 +252,7 @@ def get_auth_method(request: Request) -> str: __all__ = [ "SwaggerAuthMiddleware", "ApiKeyMiddlewareHTTP", - "ApiKeyMiddleware", # Alias + "ApiKeyMiddleware", "get_api_key_from_request", "get_auth_method", ] diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 59f7ee2..6c5ac01 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -31,9 +31,9 @@ print("\nDEBUG: Vérification des imports...") for module in _test_imports: try: __import__(module) - print(f" ✅ {module}") + print(f" {module}") except ImportError as e: - print(f" ❌ {module}: {e}") + print(f" {module}: {e}") import asyncio import argparse @@ -49,7 +49,7 @@ try: from services.api_key import ApiKeyService from security.auth import hash_password except ImportError as e: - print(f"\n❌ ERREUR D'IMPORT: {e}") + print(f"\n ERREUR D'IMPORT: {e}") print(f" Vérifiez que vous êtes dans /app") print(f" Commande correcte: cd /app && python scripts/manage_security.py ...") sys.exit(1) @@ -67,7 +67,7 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): existing = result.scalar_one_or_none() if existing: - logger.error(f"❌ L'utilisateur '{username}' existe déjà") + logger.error(f" L'utilisateur '{username}' existe déjà") return swagger_user = SwaggerUser( @@ -80,7 +80,7 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): session.add(swagger_user) await session.commit() - logger.info(f"✅ Utilisateur Swagger créé: {username}") + logger.info(f" Utilisateur Swagger créé: {username}") logger.info(f" Nom complet: {swagger_user.full_name}") @@ -96,7 +96,7 @@ async def list_swagger_users(): logger.info(f"👥 {len(users)} utilisateur(s) Swagger:\n") for user in users: - status = "✅" if user.is_active else "❌" + status = "" if user.is_active else "" logger.info(f" {status} {user.username}") logger.info(f" Nom: {user.full_name}") logger.info(f" Créé: {user.created_at}") @@ -112,7 +112,7 @@ async def delete_swagger_user(username: str): user = result.scalar_one_or_none() if not user: - logger.error(f"❌ Utilisateur '{username}' introuvable") + logger.error(f" Utilisateur '{username}' introuvable") return await session.delete(user) @@ -182,7 +182,7 @@ async def list_api_keys(): is_valid = key.is_active and ( not key.expires_at or key.expires_at > datetime.now() ) - status = "✅" if is_valid else "❌" + status = "" if is_valid else "" logger.info(f" {status} {key.name:<30} ({key.key_prefix}...)") logger.info(f" ID: {key.id}") @@ -214,7 +214,7 @@ async def revoke_api_key(key_id: str): key = result.scalar_one_or_none() if not key: - logger.error(f"❌ Clé API '{key_id}' introuvable") + logger.error(f" Clé API '{key_id}' introuvable") return key.is_active = False @@ -232,11 +232,11 @@ async def verify_api_key(api_key: str): key = await service.verify_api_key(api_key) if not key: - logger.error("❌ Clé API invalide ou expirée") + logger.error(" Clé API invalide ou expirée") return logger.info("=" * 60) - logger.info("✅ Clé API valide") + logger.info(" Clé API valide") logger.info("=" * 60) logger.info(f" Nom: {key.name}") logger.info(f" ID: {key.id}") @@ -346,7 +346,7 @@ if __name__ == "__main__": print("\nℹ️ Interrupted") sys.exit(0) except Exception as e: - logger.error(f"❌ Erreur: {e}") + logger.error(f" Erreur: {e}") import traceback traceback.print_exc() diff --git a/services/api_key.py b/services/api_key.py index ad3cf6f..04e271e 100644 --- a/services/api_key.py +++ b/services/api_key.py @@ -134,7 +134,7 @@ class ApiKeyService: api_key_obj.revoked_at = datetime.now() await self.session.commit() - logger.info(f" Clé API révoquée: {api_key_obj.name}") + logger.info(f"🗑️ Clé API révoquée: {api_key_obj.name}") return True async def get_by_id(self, key_id: str) -> Optional[ApiKey]: @@ -150,24 +150,42 @@ class ApiKeyService: } async def check_endpoint_access(self, api_key_obj: ApiKey, endpoint: str) -> bool: - """Vérifie si la clé a accès à un endpoint spécifique""" if not api_key_obj.allowed_endpoints: + logger.debug( + f"🔓 API Key {api_key_obj.name}: Aucune restriction d'endpoint" + ) return True try: allowed = json.loads(api_key_obj.allowed_endpoints) + if "*" in allowed or "/*" in allowed: + logger.debug(f"🔓 API Key {api_key_obj.name}: Accès global autorisé") + return True + for pattern in allowed: - if pattern == "*": - return True - if pattern.endswith("*"): - prefix = pattern[:-1] - if endpoint.startswith(prefix): - return True if pattern == endpoint: + logger.debug(f" Match exact: {pattern} == {endpoint}") return True + if pattern.endswith("/*"): + base = pattern[:-2] # "/clients/*" → "/clients" + if endpoint == base or endpoint.startswith(base + "/"): + logger.debug(f" Match wildcard: {pattern} ↔ {endpoint}") + return True + + elif pattern.endswith("*"): + base = pattern[:-1] # "/clients*" → "/clients" + if endpoint.startswith(base): + logger.debug(f" Match prefix: {pattern} ↔ {endpoint}") + return True + + logger.warning( + f" API Key {api_key_obj.name}: Accès refusé à {endpoint}\n" + f" Endpoints autorisés: {allowed}" + ) return False + except json.JSONDecodeError: logger.error(f" Erreur parsing allowed_endpoints pour {api_key_obj.id}") return False From 211dd4fd23025e3835af6564ac72345102c8285e Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 16:18:04 +0300 Subject: [PATCH 28/30] fix(security): improve api key authentication and error handling --- api.py | 14 ++++++++-- middleware/security.py | 59 ++++++++++++++++++++++++++---------------- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/api.py b/api.py index 97d4425..7eb5984 100644 --- a/api.py +++ b/api.py @@ -187,8 +187,18 @@ def custom_openapi(): ) openapi_schema["components"]["securitySchemes"] = { - "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, - "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, + "HTTPBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Authentification JWT pour utilisateurs (POST /auth/login)", + }, + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Clé API pour intégrations externes (format: sdk_live_xxx)", + }, } openapi_schema["security"] = [{"HTTPBearer": []}, {"ApiKeyAuth": []}] diff --git a/middleware/security.py b/middleware/security.py index e9ff831..98801dc 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -132,26 +132,30 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): method = request.method if self._is_excluded_path(path): - logger.debug(f" Route publique: {method} {path}") return await call_next(request) auth_header = request.headers.get("Authorization") - has_jwt = auth_header and auth_header.startswith("Bearer ") + api_key_header = request.headers.get("X-API-Key") - api_key = request.headers.get("X-API-Key") - has_api_key = bool(api_key) + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ")[1] - if has_jwt: - logger.debug(f" JWT détecté pour {method} {path}") - return await call_next(request) + if token.startswith("sdk_live_"): + logger.warning( + " API Key envoyée dans Authorization au lieu de X-API-Key" + ) + api_key_header = token + else: + logger.debug(f" JWT détecté pour {method} {path}") + return await call_next(request) - if has_api_key: + if api_key_header: logger.debug(f" API Key détectée pour {method} {path}") return await self._handle_api_key_auth( - request, api_key, path, method, call_next + request, api_key_header, path, method, call_next ) - logger.warning(f" Aucune authentification pour {method} {path}") + logger.warning(f" Aucune authentification: {method} {path}") return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ @@ -170,7 +174,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): method: str, call_next: Callable, ): - """Gère l'authentification par API Key""" + """Gère l'authentification par API Key avec vérification STRICTE""" try: from database.db_config import async_session_factory from services.api_key import ApiKeyService @@ -181,7 +185,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): api_key_obj = await service.verify_api_key(api_key) if not api_key_obj: - logger.warning(f" Clé API invalide pour {method} {path}") + logger.warning(f" Clé API invalide: {method} {path}") return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ @@ -192,7 +196,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): is_allowed, rate_info = await service.check_rate_limit(api_key_obj) if not is_allowed: - logger.warning(f"⚠️ Rate limit dépassé: {api_key_obj.name}") + logger.warning(f"⚠️ Rate limit: {api_key_obj.name}") return JSONResponse( status_code=status.HTTP_429_TOO_MANY_REQUESTS, content={"detail": "Rate limit dépassé"}, @@ -203,28 +207,37 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): ) has_access = await service.check_endpoint_access(api_key_obj, path) + if not has_access: - logger.warning( - f"Accès refusé: {api_key_obj.name} → {method} {path}" + import json + + allowed = ( + json.loads(api_key_obj.allowed_endpoints) + if api_key_obj.allowed_endpoints + else ["Tous"] ) + + logger.warning( + f" ACCÈS REFUSÉ: {api_key_obj.name}\n" + f" Endpoint demandé: {path}\n" + f" Endpoints autorisés: {allowed}" + ) + return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, content={ "detail": "Accès non autorisé à cet endpoint", - "endpoint": path, + "endpoint_requested": path, "api_key_name": api_key_obj.name, - "allowed_endpoints": ( - api_key_obj.allowed_endpoints - if api_key_obj.allowed_endpoints - else "Tous" - ), + "allowed_endpoints": allowed, + "hint": "Cette clé API n'a pas accès à cet endpoint. Contactez l'administrateur.", }, ) request.state.api_key = api_key_obj request.state.authenticated_via = "api_key" - logger.info(f"✅ API Key valide: {api_key_obj.name} → {method} {path}") + logger.info(f" ACCÈS AUTORISÉ: {api_key_obj.name} → {method} {path}") return await call_next(request) @@ -232,7 +245,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): logger.error(f" Erreur validation API Key: {e}", exc_info=True) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"detail": "Erreur interne lors de la validation"}, + content={"detail": f"Erreur interne: {str(e)}"}, ) From d89c9fd35bbfbf51c26363381a2c47a2d9a1c033 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 16:29:10 +0300 Subject: [PATCH 29/30] chore: ignore clean scripts in gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7d75fa8..ed8f8ab 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,6 @@ tools/ .env.staging .env.production -.trunk \ No newline at end of file +.trunk + +*clean*.py \ No newline at end of file From a7457c397934b2c9135f63eaf53984ce5078f718 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 19:14:00 +0300 Subject: [PATCH 30/30] fix(security): improve auth handling and logging in middleware --- middleware/security.py | 36 ++++++++++++++++-------------------- scripts/manage_security.py | 2 +- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/middleware/security.py b/middleware/security.py index 98801dc..88df4f6 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -112,6 +112,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): "/health", "/auth", "/api-keys/verify", + "/universign/webhook", ] def _is_excluded_path(self, path: str) -> bool: @@ -137,6 +138,12 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): auth_header = request.headers.get("Authorization") api_key_header = request.headers.get("X-API-Key") + if api_key_header: + logger.debug(f"🔑 API Key détectée pour {method} {path}") + return await self._handle_api_key_auth( + request, api_key_header, path, method, call_next + ) + if auth_header and auth_header.startswith("Bearer "): token = auth_header.split(" ")[1] @@ -144,27 +151,16 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): logger.warning( " API Key envoyée dans Authorization au lieu de X-API-Key" ) - api_key_header = token - else: - logger.debug(f" JWT détecté pour {method} {path}") - return await call_next(request) + return await self._handle_api_key_auth( + request, token, path, method, call_next + ) - if api_key_header: - logger.debug(f" API Key détectée pour {method} {path}") - return await self._handle_api_key_auth( - request, api_key_header, path, method, call_next - ) + logger.debug(f"🎫 JWT détecté pour {method} {path} → délégation à FastAPI") + request.state.authenticated_via = "jwt" + return await call_next(request) - logger.warning(f" Aucune authentification: {method} {path}") - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "detail": "Authentification requise", - "hint": "Utilisez 'X-API-Key: sdk_live_xxx' ou 'Authorization: Bearer '", - "path": path, - }, - headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, - ) + logger.debug(f" Aucune auth pour {method} {path} → délégation à FastAPI") + return await call_next(request) async def _handle_api_key_auth( self, @@ -196,7 +192,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): is_allowed, rate_info = await service.check_rate_limit(api_key_obj) if not is_allowed: - logger.warning(f"⚠️ Rate limit: {api_key_obj.name}") + logger.warning(f" Rate limit: {api_key_obj.name}") return JSONResponse( status_code=status.HTTP_429_TOO_MANY_REQUESTS, content={"detail": "Rate limit dépassé"}, diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 6c5ac01..1e5cab9 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -162,7 +162,7 @@ async def create_api_key( logger.info(" Endpoints: Tous (aucune restriction)") logger.info("=" * 70) - logger.info("⚠️ SAUVEGARDEZ CETTE CLÉ - Elle ne sera plus affichée !") + logger.info(" SAUVEGARDEZ CETTE CLÉ - Elle ne sera plus affichée !") logger.info("=" * 70)