From 4434f0716fd6911f95a28f8f009d07edd59f76ee Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 18:03:47 +0300 Subject: [PATCH] feat: implement comprehensive user authentication including registration, login, email verification, password reset, and token management. --- config.py | 4 +- core/dependencies.py | 46 +++-- create_admin.py | 44 ++--- database/__init__.py | 39 ++-- database/db_config.py | 6 +- database/models.py | 137 ++++++++------ email_queue.py | 249 ++++++++++++------------- init_db.py | 22 +-- routes/auth.py | 370 ++++++++++++++++++-------------------- security/auth.py | 38 ++-- services/email_service.py | 60 +++---- 11 files changed, 504 insertions(+), 511 deletions(-) diff --git a/config.py b/config.py index 7e1c020..1b3125e 100644 --- a/config.py +++ b/config.py @@ -6,8 +6,8 @@ class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) - - # === JWT & Auth === + + # === JWT & Auth === jwt_secret: str jwt_algorithm: str access_token_expire_minutes: int diff --git a/core/dependencies.py b/core/dependencies.py index 48bb868..7f8a5f9 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -12,18 +12,18 @@ security = HTTPBearer() async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ) -> User: """ Dépendance FastAPI pour extraire l'utilisateur du JWT - + Usage dans un endpoint: @app.get("/protected") async def protected_route(user: User = Depends(get_current_user)): return {"user_id": user.id} """ token = credentials.credentials - + # Décoder le token payload = decode_token(token) if not payload: @@ -32,7 +32,7 @@ async def get_current_user( detail="Token invalide ou expiré", headers={"WWW-Authenticate": "Bearer"}, ) - + # Vérifier le type if payload.get("type") != "access": raise HTTPException( @@ -40,7 +40,7 @@ async def get_current_user( detail="Type de token incorrect", headers={"WWW-Authenticate": "Bearer"}, ) - + # Extraire user_id user_id: str = payload.get("sub") if not user_id: @@ -49,46 +49,43 @@ async def get_current_user( detail="Token malformé", headers={"WWW-Authenticate": "Bearer"}, ) - + # Charger l'utilisateur - result = await session.execute( - select(User).where(User.id == user_id) - ) + 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"}, ) - + # Vérifications de sécurité if not user.is_active: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte désactivé" + 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." + detail="Email non vérifié. Consultez votre boîte de réception.", ) - + # ✅ FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!) 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" + detail="Compte temporairement verrouillé suite à trop de tentatives échouées", ) - + return user async def get_current_user_optional( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ) -> Optional[User]: """ Version optionnelle - ne lève pas d'erreur si pas de token @@ -96,7 +93,7 @@ async def get_current_user_optional( """ if not credentials: return None - + try: return await get_current_user(credentials, session) except HTTPException: @@ -106,18 +103,19 @@ async def get_current_user_optional( def require_role(*allowed_roles: str): """ Décorateur pour restreindre l'accès par rôle - + Usage: @app.get("/admin/users") async def list_users(user: User = Depends(require_role("admin"))): ... """ + async def role_checker(user: User = Depends(get_current_user)) -> User: if user.role not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}" + detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}", ) return user - - return role_checker \ No newline at end of file + + return role_checker diff --git a/create_admin.py b/create_admin.py index a85b4df..41f11b7 100644 --- a/create_admin.py +++ b/create_admin.py @@ -25,29 +25,31 @@ logger = logging.getLogger(__name__) async def create_admin(): """Crée un utilisateur admin""" - - print("\n" + "="*60) + + print("\n" + "=" * 60) print("🔐 Création d'un compte administrateur") - print("="*60 + "\n") - + print("=" * 60 + "\n") + # Saisie des informations email = input("Email de l'admin: ").strip().lower() - if not email or '@' not in email: + if not email or "@" not in email: print("❌ Email invalide") return False - + prenom = input("Prénom: ").strip() nom = input("Nom: ").strip() - + if not prenom or not nom: 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): ") + password = input( + "Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): " + ) is_valid, error_msg = validate_password_strength(password) - + if is_valid: confirm = input("Confirmez le mot de passe: ") if password == confirm: @@ -56,20 +58,18 @@ async def create_admin(): print("❌ Les mots de passe ne correspondent pas\n") else: print(f"❌ {error_msg}\n") - + # Vérifier si l'email existe déjà async with async_session_factory() as session: from sqlalchemy import select - - result = await session.execute( - select(User).where(User.email == email) - ) + + result = await session.execute(select(User).where(User.email == email)) existing = result.scalar_one_or_none() - + if existing: print(f"\n❌ Un utilisateur avec l'email {email} existe déjà") return False - + # Créer l'admin admin = User( id=str(uuid.uuid4()), @@ -80,19 +80,19 @@ async def create_admin(): role="admin", is_verified=True, # Admin vérifié par défaut is_active=True, - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(admin) await session.commit() - + print("\n✅ Administrateur créé avec succès!") print(f"📧 Email: {email}") print(f"👤 Nom: {prenom} {nom}") print(f"🔑 Rôle: admin") print(f"🆔 ID: {admin.id}") print("\n💡 Vous pouvez maintenant vous connecter à l'API\n") - + return True @@ -106,4 +106,4 @@ if __name__ == "__main__": except Exception as e: print(f"\n❌ Erreur: {e}") logger.exception("Détails:") - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/database/__init__.py b/database/__init__.py index 0e2957a..579c644 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -3,7 +3,7 @@ from database.db_config import ( async_session_factory, init_db, get_session, - close_db + close_db, ) from database.models import ( @@ -23,26 +23,23 @@ from database.models import ( __all__ = [ # Config - 'engine', - 'async_session_factory', - 'init_db', - 'get_session', - 'close_db', - + "engine", + "async_session_factory", + "init_db", + "get_session", + "close_db", # Models existants - 'Base', - 'EmailLog', - 'SignatureLog', - 'WorkflowLog', - 'CacheMetadata', - 'AuditLog', - + "Base", + "EmailLog", + "SignatureLog", + "WorkflowLog", + "CacheMetadata", + "AuditLog", # Enums - 'StatutEmail', - 'StatutSignature', - + "StatutEmail", + "StatutSignature", # Modèles auth - 'User', - 'RefreshToken', - 'LoginAttempt', -] \ No newline at end of file + "User", + "RefreshToken", + "LoginAttempt", +] diff --git a/database/db_config.py b/database/db_config.py index 1973799..f5bc0b4 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -32,10 +32,10 @@ async def init_db(): try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - + logger.info("✅ Base de données initialisée avec succès") logger.info(f"📍 Fichier DB: {DATABASE_URL}") - + except Exception as e: logger.error(f"❌ Erreur initialisation DB: {e}") raise @@ -53,4 +53,4 @@ async def get_session() -> AsyncSession: async def close_db(): """Ferme proprement toutes les connexions""" await engine.dispose() - logger.info("🔌 Connexions DB fermées") \ No newline at end of file + logger.info("🔌 Connexions DB fermées") diff --git a/database/models.py b/database/models.py index 2c260ef..ff7c224 100644 --- a/database/models.py +++ b/database/models.py @@ -1,4 +1,13 @@ -from sqlalchemy import Column, Integer, String, DateTime, Float, Text, Boolean, Enum as SQLEnum +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Float, + Text, + Boolean, + Enum as SQLEnum, +) from sqlalchemy.ext.declarative import declarative_base from datetime import datetime import enum @@ -9,8 +18,10 @@ Base = declarative_base() # Enums # ============================================================================ + class StatutEmail(str, enum.Enum): """Statuts possibles d'un email""" + EN_ATTENTE = "EN_ATTENTE" EN_COURS = "EN_COURS" ENVOYE = "ENVOYE" @@ -18,54 +29,59 @@ class StatutEmail(str, enum.Enum): ERREUR = "ERREUR" BOUNCE = "BOUNCE" + class StatutSignature(str, enum.Enum): """Statuts possibles d'une signature électronique""" + EN_ATTENTE = "EN_ATTENTE" ENVOYE = "ENVOYE" SIGNE = "SIGNE" REFUSE = "REFUSE" EXPIRE = "EXPIRE" + # ============================================================================ # Tables # ============================================================================ + class EmailLog(Base): """ Journal des emails envoyés via l'API Permet le suivi et le retry automatique """ + __tablename__ = "email_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Destinataires destinataire = Column(String(255), nullable=False, index=True) cc = Column(Text, nullable=True) # JSON stringifié cci = Column(Text, nullable=True) # JSON stringifié - + # Contenu sujet = Column(String(500), nullable=False) corps_html = Column(Text, nullable=False) - + # Documents attachés document_ids = Column(Text, nullable=True) # Séparés par virgules type_document = Column(Integer, nullable=True) - + # Statut statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True) - + # Tracking temporel date_creation = Column(DateTime, default=datetime.now, nullable=False) date_envoi = Column(DateTime, nullable=True) date_ouverture = Column(DateTime, nullable=True) - + # Retry automatique nb_tentatives = Column(Integer, default=0) derniere_erreur = Column(Text, nullable=True) prochain_retry = Column(DateTime, nullable=True) - + # Métadonnées ip_envoi = Column(String(45), nullable=True) user_agent = Column(String(500), nullable=True) @@ -79,33 +95,36 @@ class SignatureLog(Base): Journal des demandes de signature Universign Permet le suivi du workflow de signature """ + __tablename__ = "signature_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Document Sage associé document_id = Column(String(100), nullable=False, index=True) type_document = Column(Integer, nullable=False) - + # Universign transaction_id = Column(String(100), unique=True, index=True, nullable=True) signer_url = Column(String(500), nullable=True) - + # Signataire email_signataire = Column(String(255), nullable=False, index=True) nom_signataire = Column(String(255), nullable=False) - + # Statut - statut = Column(SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True) + statut = Column( + SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True + ) date_envoi = Column(DateTime, default=datetime.now) date_signature = Column(DateTime, nullable=True) date_refus = Column(DateTime, nullable=True) - + # Relances est_relance = Column(Boolean, default=False) nb_relances = Column(Integer, default=0) - + # Métadonnées raison_refus = Column(Text, nullable=True) ip_signature = Column(String(45), nullable=True) @@ -119,27 +138,28 @@ class WorkflowLog(Base): Journal des transformations de documents (Devis → Commande → Facture) Permet la traçabilité du workflow commercial """ + __tablename__ = "workflow_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Documents document_source = Column(String(100), nullable=False, index=True) type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc. - + document_cible = Column(String(100), nullable=False, index=True) type_cible = Column(Integer, nullable=False) - + # Métadonnées de transformation nb_lignes = Column(Integer, nullable=True) montant_ht = Column(Float, nullable=True) montant_ttc = Column(Float, nullable=True) - + # Tracking date_transformation = Column(DateTime, default=datetime.now, nullable=False) utilisateur = Column(String(100), nullable=True) - + # Résultat succes = Column(Boolean, default=True) erreur = Column(Text, nullable=True) @@ -154,18 +174,21 @@ class CacheMetadata(Base): Métadonnées sur le cache Sage (clients, articles) Permet le monitoring du cache géré par la gateway Windows """ + __tablename__ = "cache_metadata" - + id = Column(Integer, primary_key=True, autoincrement=True) - + # Type de cache - cache_type = Column(String(50), unique=True, nullable=False) # 'clients' ou 'articles' - + cache_type = Column( + String(50), unique=True, nullable=False + ) # 'clients' ou 'articles' + # Statistiques last_refresh = Column(DateTime, default=datetime.now) item_count = Column(Integer, default=0) refresh_duration_ms = Column(Float, nullable=True) - + # Santé last_error = Column(Text, nullable=True) error_count = Column(Integer, default=0) @@ -179,66 +202,72 @@ class AuditLog(Base): Journal d'audit pour la sécurité et la conformité Trace toutes les actions importantes dans l'API """ + __tablename__ = "audit_logs" - + id = Column(Integer, primary_key=True, autoincrement=True) - + # Action - action = Column(String(100), nullable=False, index=True) # 'CREATE_DEVIS', 'SEND_EMAIL', etc. + action = Column( + String(100), nullable=False, index=True + ) # 'CREATE_DEVIS', 'SEND_EMAIL', etc. ressource_type = Column(String(50), nullable=True) # 'devis', 'facture', etc. ressource_id = Column(String(100), nullable=True, index=True) - + # Utilisateur (si authentification ajoutée plus tard) utilisateur = Column(String(100), nullable=True) ip_address = Column(String(45), nullable=True) - + # Résultat succes = Column(Boolean, default=True) details = Column(Text, nullable=True) # JSON stringifié erreur = Column(Text, nullable=True) - + # Timestamp date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) def __repr__(self): return f"" - + + # Ajouter ces modèles à la fin de database/models.py + class User(Base): """ Utilisateurs de l'API avec validation email """ + __tablename__ = "users" - + id = Column(String(36), primary_key=True) email = Column(String(255), unique=True, nullable=False, index=True) hashed_password = Column(String(255), nullable=False) - + # Profil nom = Column(String(100), nullable=False) prenom = Column(String(100), nullable=False) role = Column(String(50), default="user") # user, admin, commercial - + # Validation email is_verified = Column(Boolean, default=False) verification_token = Column(String(255), nullable=True, unique=True, index=True) verification_token_expires = Column(DateTime, nullable=True) - + # Sécurité is_active = Column(Boolean, default=True) failed_login_attempts = Column(Integer, default=0) locked_until = Column(DateTime, nullable=True) - + # Mot de passe oublié reset_token = Column(String(255), nullable=True, unique=True, index=True) reset_token_expires = Column(DateTime, nullable=True) - + # Timestamps created_at = Column(DateTime, default=datetime.now, nullable=False) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) last_login = Column(DateTime, nullable=True) - + def __repr__(self): return f"" @@ -247,24 +276,25 @@ class RefreshToken(Base): """ Tokens de rafraîchissement JWT """ + __tablename__ = "refresh_tokens" - + id = Column(String(36), primary_key=True) user_id = Column(String(36), nullable=False, index=True) token_hash = Column(String(255), nullable=False, unique=True, index=True) - + # Métadonnées device_info = Column(String(500), nullable=True) ip_address = Column(String(45), nullable=True) - + # Expiration expires_at = Column(DateTime, nullable=False) created_at = Column(DateTime, default=datetime.now, nullable=False) - + # Révocation is_revoked = Column(Boolean, default=False) revoked_at = Column(DateTime, nullable=True) - + def __repr__(self): return f"" @@ -273,18 +303,19 @@ class LoginAttempt(Base): """ Journal des tentatives de connexion (détection bruteforce) """ + __tablename__ = "login_attempts" - + id = Column(Integer, primary_key=True, autoincrement=True) - + email = Column(String(255), nullable=False, index=True) ip_address = Column(String(45), nullable=False, index=True) user_agent = Column(String(500), nullable=True) - + success = Column(Boolean, default=False) failure_reason = Column(String(255), nullable=True) - + timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True) - + def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/email_queue.py b/email_queue.py index beda62e..53d65af 100644 --- a/email_queue.py +++ b/email_queue.py @@ -25,67 +25,65 @@ class EmailQueue: """ Queue d'emails avec workers threadés et retry automatique """ - + def __init__(self): self.queue = queue.Queue() self.workers = [] self.running = False self.session_factory = None self.sage_client = None - + def start(self, num_workers: int = 3): """Démarre les workers""" if self.running: logger.warning("Queue déjà démarrée") return - + self.running = True for i in range(num_workers): worker = threading.Thread( - target=self._worker, - name=f"EmailWorker-{i}", - daemon=True + target=self._worker, name=f"EmailWorker-{i}", daemon=True ) worker.start() self.workers.append(worker) - + logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)") - + def stop(self): """Arrête les workers proprement""" logger.info("🛑 Arrêt de la queue email...") self.running = False - + # Attendre que la queue soit vide (max 30s) try: self.queue.join() logger.info("✅ Queue email arrêtée proprement") except: logger.warning("⚠️ Timeout lors de l'arrêt de la queue") - + def enqueue(self, email_log_id: str): """Ajoute un email dans la queue""" self.queue.put(email_log_id) logger.debug(f"📨 Email {email_log_id} ajouté à la queue") - + def _worker(self): """Worker qui traite les emails dans un thread""" # Créer une event loop pour ce thread loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - + try: while self.running: try: # Récupérer un email de la queue (timeout 1s) email_log_id = self.queue.get(timeout=1) - + # Traiter l'email loop.run_until_complete(self._process_email(email_log_id)) - + # Marquer comme traité self.queue.task_done() - + except queue.Empty: continue except Exception as e: @@ -96,144 +94,147 @@ class EmailQueue: pass finally: loop.close() - + async def _process_email(self, email_log_id: str): """Traite un email avec retry automatique""" from database import EmailLog, StatutEmail from sqlalchemy import select - + if not self.session_factory: logger.error("❌ session_factory non configuré") return - + async with self.session_factory() as session: # Charger l'email log result = await session.execute( select(EmailLog).where(EmailLog.id == email_log_id) ) email_log = result.scalar_one_or_none() - + if not email_log: logger.error(f"❌ Email log {email_log_id} introuvable") return - + # Marquer comme en cours email_log.statut = StatutEmail.EN_COURS email_log.nb_tentatives += 1 await session.commit() - + try: # Envoi avec retry automatique await self._send_with_retry(email_log) - + # Succès email_log.statut = StatutEmail.ENVOYE email_log.date_envoi = datetime.now() email_log.derniere_erreur = None logger.info(f"✅ Email envoyé: {email_log.destinataire}") - + except Exception as e: # Échec email_log.statut = StatutEmail.ERREUR email_log.derniere_erreur = str(e)[:1000] # Limiter la taille - + # Programmer un retry si < max attempts if email_log.nb_tentatives < settings.max_retry_attempts: - delay = settings.retry_delay_seconds * (2 ** (email_log.nb_tentatives - 1)) + delay = settings.retry_delay_seconds * ( + 2 ** (email_log.nb_tentatives - 1) + ) email_log.prochain_retry = datetime.now() + timedelta(seconds=delay) - + # Programmer le retry timer = threading.Timer(delay, self.enqueue, args=[email_log_id]) timer.daemon = True timer.start() - - logger.warning(f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}") + + logger.warning( + f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}" + ) else: logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}") - + await session.commit() - + @retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=4, max=10) + stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10) ) async def _send_with_retry(self, email_log): """Envoi SMTP avec retry Tenacity + génération PDF""" # Préparer le message msg = MIMEMultipart() - msg['From'] = settings.smtp_from - msg['To'] = email_log.destinataire - msg['Subject'] = email_log.sujet - + msg["From"] = settings.smtp_from + msg["To"] = email_log.destinataire + msg["Subject"] = email_log.sujet + # Corps HTML - msg.attach(MIMEText(email_log.corps_html, 'html')) - + msg.attach(MIMEText(email_log.corps_html, "html")) + # 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs if email_log.document_ids: - document_ids = email_log.document_ids.split(',') + document_ids = email_log.document_ids.split(",") type_doc = email_log.type_document - + for doc_id in document_ids: doc_id = doc_id.strip() if not doc_id: continue - + try: # Générer PDF (appel bloquant dans thread séparé) pdf_bytes = await asyncio.to_thread( - self._generate_pdf, - doc_id, - type_doc + self._generate_pdf, doc_id, type_doc ) - + if pdf_bytes: # Attacher PDF part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf") - part['Content-Disposition'] = f'attachment; filename="{doc_id}.pdf"' + part["Content-Disposition"] = ( + f'attachment; filename="{doc_id}.pdf"' + ) msg.attach(part) logger.info(f"📎 PDF attaché: {doc_id}.pdf") - + except Exception as e: logger.error(f"❌ Erreur génération PDF {doc_id}: {e}") # Continuer avec les autres PDFs - + # Envoi SMTP (bloquant mais dans thread séparé) await asyncio.to_thread(self._send_smtp, msg) - + def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: """ Génération PDF via ReportLab + sage_client - + ⚠️ Cette méthode est appelée depuis un thread worker """ from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from reportlab.lib.units import cm from io import BytesIO - + if not self.sage_client: logger.error("❌ sage_client non configuré") raise Exception("sage_client non disponible") - + # 📡 Récupérer document depuis gateway Windows via HTTP try: doc = self.sage_client.lire_document(doc_id, type_doc) except Exception as e: logger.error(f"❌ Erreur récupération document {doc_id}: {e}") raise Exception(f"Document {doc_id} inaccessible") - + if not doc: raise Exception(f"Document {doc_id} introuvable") - + # 📄 Créer PDF avec ReportLab buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 - + # === EN-TÊTE === pdf.setFont("Helvetica-Bold", 20) - pdf.drawString(2*cm, height - 3*cm, f"Document N° {doc_id}") - + pdf.drawString(2 * cm, height - 3 * cm, f"Document N° {doc_id}") + # Type de document type_labels = { 0: "DEVIS", @@ -241,101 +242,105 @@ class EmailQueue: 2: "BON DE RETOUR", 3: "COMMANDE", 4: "PRÉPARATION", - 5: "FACTURE" + 5: "FACTURE", } type_label = type_labels.get(type_doc, "DOCUMENT") - + pdf.setFont("Helvetica", 12) - pdf.drawString(2*cm, height - 4*cm, f"Type: {type_label}") - + pdf.drawString(2 * cm, height - 4 * cm, f"Type: {type_label}") + # === INFORMATIONS CLIENT === - y = height - 5*cm + y = height - 5 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(2*cm, y, "CLIENT") - - y -= 0.8*cm + pdf.drawString(2 * cm, y, "CLIENT") + + y -= 0.8 * cm pdf.setFont("Helvetica", 11) - pdf.drawString(2*cm, y, f"Code: {doc.get('client_code', '')}") - y -= 0.6*cm - pdf.drawString(2*cm, y, f"Nom: {doc.get('client_intitule', '')}") - y -= 0.6*cm - pdf.drawString(2*cm, y, f"Date: {doc.get('date', '')}") - + pdf.drawString(2 * cm, y, f"Code: {doc.get('client_code', '')}") + y -= 0.6 * cm + pdf.drawString(2 * cm, y, f"Nom: {doc.get('client_intitule', '')}") + y -= 0.6 * cm + pdf.drawString(2 * cm, y, f"Date: {doc.get('date', '')}") + # === LIGNES === - y -= 1.5*cm + y -= 1.5 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(2*cm, y, "ARTICLES") - - y -= 1*cm + pdf.drawString(2 * cm, y, "ARTICLES") + + y -= 1 * cm pdf.setFont("Helvetica-Bold", 10) - pdf.drawString(2*cm, y, "Désignation") - pdf.drawString(10*cm, y, "Qté") - pdf.drawString(12*cm, y, "Prix Unit.") - pdf.drawString(15*cm, y, "Total HT") - - y -= 0.5*cm - pdf.line(2*cm, y, width - 2*cm, y) - - y -= 0.7*cm + pdf.drawString(2 * cm, y, "Désignation") + pdf.drawString(10 * cm, y, "Qté") + pdf.drawString(12 * cm, y, "Prix Unit.") + pdf.drawString(15 * cm, y, "Total HT") + + y -= 0.5 * cm + pdf.line(2 * cm, y, width - 2 * cm, y) + + y -= 0.7 * cm pdf.setFont("Helvetica", 9) - - for ligne in doc.get('lignes', []): + + for ligne in doc.get("lignes", []): # Nouvelle page si nécessaire - if y < 3*cm: + if y < 3 * cm: pdf.showPage() - y = height - 3*cm + y = height - 3 * cm pdf.setFont("Helvetica", 9) - - designation = ligne.get('designation', '')[:50] - pdf.drawString(2*cm, y, designation) - pdf.drawString(10*cm, y, str(ligne.get('quantite', 0))) - pdf.drawString(12*cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€") - pdf.drawString(15*cm, y, f"{ligne.get('montant_ht', 0):.2f}€") - y -= 0.6*cm - + + designation = ligne.get("designation", "")[:50] + pdf.drawString(2 * cm, y, designation) + pdf.drawString(10 * cm, y, str(ligne.get("quantite", 0))) + pdf.drawString(12 * cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€") + pdf.drawString(15 * cm, y, f"{ligne.get('montant_ht', 0):.2f}€") + y -= 0.6 * cm + # === TOTAUX === - y -= 1*cm - pdf.line(12*cm, y, width - 2*cm, y) - - y -= 0.8*cm + y -= 1 * cm + pdf.line(12 * cm, y, width - 2 * cm, y) + + y -= 0.8 * cm pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(12*cm, y, "Total HT:") - pdf.drawString(15*cm, y, f"{doc.get('total_ht', 0):.2f}€") - - y -= 0.6*cm - pdf.drawString(12*cm, y, "TVA (20%):") - tva = doc.get('total_ttc', 0) - doc.get('total_ht', 0) - pdf.drawString(15*cm, y, f"{tva:.2f}€") - - y -= 0.6*cm + pdf.drawString(12 * cm, y, "Total HT:") + pdf.drawString(15 * cm, y, f"{doc.get('total_ht', 0):.2f}€") + + y -= 0.6 * cm + pdf.drawString(12 * cm, y, "TVA (20%):") + tva = doc.get("total_ttc", 0) - doc.get("total_ht", 0) + pdf.drawString(15 * cm, y, f"{tva:.2f}€") + + y -= 0.6 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(12*cm, y, "Total TTC:") - pdf.drawString(15*cm, y, f"{doc.get('total_ttc', 0):.2f}€") - + pdf.drawString(12 * cm, y, "Total TTC:") + pdf.drawString(15 * cm, y, f"{doc.get('total_ttc', 0):.2f}€") + # === PIED DE PAGE === pdf.setFont("Helvetica", 8) - pdf.drawString(2*cm, 2*cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}") - pdf.drawString(2*cm, 1.5*cm, "Sage 100c - API Dataven") - + pdf.drawString( + 2 * cm, 2 * cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}" + ) + pdf.drawString(2 * cm, 1.5 * cm, "Sage 100c - API Dataven") + # Finaliser pdf.save() buffer.seek(0) - + logger.info(f"✅ PDF généré: {doc_id}.pdf") return buffer.read() - + def _send_smtp(self, msg): """Envoi SMTP bloquant (appelé depuis asyncio.to_thread)""" try: - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server: + with smtplib.SMTP( + settings.smtp_host, settings.smtp_port, timeout=30 + ) as server: if settings.smtp_use_tls: server.starttls() - + if settings.smtp_user and settings.smtp_password: server.login(settings.smtp_user, settings.smtp_password) - + server.send_message(msg) - + except smtplib.SMTPException as e: raise Exception(f"Erreur SMTP: {str(e)}") except Exception as e: @@ -343,4 +348,4 @@ class EmailQueue: # Instance globale -email_queue = EmailQueue() \ No newline at end of file +email_queue = EmailQueue() diff --git a/init_db.py b/init_db.py index b59d822..7f5c174 100644 --- a/init_db.py +++ b/init_db.py @@ -23,35 +23,35 @@ logger = logging.getLogger(__name__) async def main(): """Crée toutes les tables dans sage_dataven.db""" - - print("\n" + "="*60) + + print("\n" + "=" * 60) print("🚀 Initialisation de la base de données Sage Dataven") - print("="*60 + "\n") - + print("=" * 60 + "\n") + try: # Créer les tables await init_db() - + print("\n✅ Base de données créée avec succès!") print(f"📍 Fichier: sage_dataven.db") - + print("\n📊 Tables créées:") print(" ├─ email_logs (Journalisation emails)") print(" ├─ signature_logs (Suivi signatures Universign)") print(" ├─ workflow_logs (Transformations documents)") print(" ├─ cache_metadata (Métadonnées cache)") print(" └─ audit_logs (Journal d'audit)") - + print("\n📝 Prochaines étapes:") print(" 1. Configurer le fichier .env avec vos credentials") print(" 2. Lancer la gateway Windows sur la machine Sage") print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000") print(" 4. Ou avec Docker: docker-compose up -d") print(" 5. Tester: http://votre-vps:8000/docs") - - print("\n" + "="*60 + "\n") + + print("\n" + "=" * 60 + "\n") return True - + except Exception as e: print(f"\n❌ Erreur lors de l'initialisation: {e}") logger.exception("Détails de l'erreur:") @@ -60,4 +60,4 @@ async def main(): if __name__ == "__main__": result = asyncio.run(main()) - sys.exit(0 if result else 1) \ No newline at end of file + sys.exit(0 if result else 1) diff --git a/routes/auth.py b/routes/auth.py index 771cb38..3d682e0 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -16,7 +16,7 @@ from security.auth import ( decode_token, generate_verification_token, generate_reset_token, - hash_token + hash_token, ) from services.email_service import AuthEmailService from core.dependencies import get_current_user @@ -29,6 +29,7 @@ router = APIRouter(prefix="/auth", tags=["Authentication"]) # === MODÈLES PYDANTIC === + class RegisterRequest(BaseModel): email: EmailStr password: str = Field(..., min_length=8) @@ -71,13 +72,14 @@ class ResendVerificationRequest(BaseModel): # === UTILITAIRES === + async def log_login_attempt( session: AsyncSession, email: str, ip: str, user_agent: str, success: bool, - failure_reason: Optional[str] = None + failure_reason: Optional[str] = None, ): """Enregistre une tentative de connexion""" attempt = LoginAttempt( @@ -86,76 +88,72 @@ async def log_login_attempt( user_agent=user_agent, success=success, failure_reason=failure_reason, - timestamp=datetime.now() + timestamp=datetime.now(), ) session.add(attempt) await session.commit() -async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[bool, str]: +async def check_rate_limit( + session: AsyncSession, email: str, ip: str +) -> tuple[bool, str]: """ Vérifie si l'utilisateur/IP est rate limité - + Returns: (is_allowed, error_message) """ # Vérifier les tentatives échouées des 15 dernières minutes time_window = datetime.now() - timedelta(minutes=15) - + result = await session.execute( - select(LoginAttempt) - .where( + select(LoginAttempt).where( LoginAttempt.email == email, LoginAttempt.success == False, - LoginAttempt.timestamp >= time_window + LoginAttempt.timestamp >= time_window, ) ) failed_attempts = result.scalars().all() - + if len(failed_attempts) >= 5: return False, "Trop de tentatives échouées. Réessayez dans 15 minutes." - + return True, "" # === ENDPOINTS === + @router.post("/register", status_code=status.HTTP_201_CREATED) async def register( data: RegisterRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 📝 Inscription d'un nouvel utilisateur - + - Valide le mot de passe - Crée le compte (non vérifié) - Envoie email de vérification """ # Vérifier si l'email existe déjà - result = await session.execute( - select(User).where(User.email == data.email) - ) + result = await session.execute(select(User).where(User.email == data.email)) existing_user = result.scalar_one_or_none() - + if existing_user: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cet email est déjà utilisé" + status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà utilisé" ) - + # Valider le mot de passe is_valid, error_msg = validate_password_strength(data.password) if not is_valid: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=error_msg - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) + # Générer token de vérification verification_token = generate_verification_token() - + # Créer l'utilisateur new_user = User( id=str(uuid.uuid4()), @@ -166,80 +164,72 @@ async def register( is_verified=False, verification_token=verification_token, verification_token_expires=datetime.now() + timedelta(hours=24), - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(new_user) await session.commit() - + # Envoyer email de vérification - base_url = str(request.base_url).rstrip('/') + base_url = str(request.base_url).rstrip("/") email_sent = AuthEmailService.send_verification_email( - data.email, - verification_token, - base_url + data.email, verification_token, base_url ) - + if not email_sent: logger.warning(f"Échec envoi email vérification pour {data.email}") - + logger.info(f"✅ Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})") - + return { "success": True, "message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.", "user_id": new_user.id, - "email": data.email + "email": data.email, } @router.get("/verify-email") -async def verify_email_get( - token: str, - session: AsyncSession = Depends(get_session) -): +async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)): """ ✅ Vérification de l'email via lien cliquable (GET) Utilisé quand l'utilisateur clique sur le lien dans l'email """ - result = await session.execute( - select(User).where(User.verification_token == token) - ) + result = await session.execute(select(User).where(User.verification_token == token)) user = result.scalar_one_or_none() - + if not user: return { "success": False, - "message": "Token de vérification invalide ou déjà utilisé." + "message": "Token de vérification invalide ou déjà utilisé.", } - + # Vérifier l'expiration if user.verification_token_expires < datetime.now(): return { "success": False, "message": "Token expiré. Veuillez demander un nouvel email de vérification.", - "expired": True + "expired": True, } - + # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None await session.commit() - + logger.info(f"✅ Email vérifié: {user.email}") - + return { "success": True, "message": "✅ Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", - "email": user.email + "email": user.email, } @router.post("/verify-email") async def verify_email_post( - data: VerifyEmailRequest, - session: AsyncSession = Depends(get_session) + data: VerifyEmailRequest, session: AsyncSession = Depends(get_session) ): """ ✅ Vérification de l'email via API (POST) @@ -249,31 +239,31 @@ async def verify_email_post( select(User).where(User.verification_token == data.token) ) user = result.scalar_one_or_none() - + if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token de vérification invalide" + detail="Token de vérification invalide", ) - + # Vérifier l'expiration if user.verification_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token expiré. Demandez un nouvel email de vérification." + detail="Token expiré. Demandez un nouvel email de vérification.", ) - + # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None await session.commit() - + logger.info(f"✅ Email vérifié: {user.email}") - + return { "success": True, - "message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter." + "message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", } @@ -281,135 +271,134 @@ async def verify_email_post( async def resend_verification( data: ResendVerificationRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 🔄 Renvoyer l'email de vérification """ - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + if not user: # Ne pas révéler si l'utilisateur existe return { "success": True, - "message": "Si cet email existe, un nouveau lien de vérification a été envoyé." + "message": "Si cet email existe, un nouveau lien de vérification a été envoyé.", } - + if user.is_verified: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Ce compte est déjà vérifié" + status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est déjà vérifié" ) - + # Générer nouveau token verification_token = generate_verification_token() user.verification_token = verification_token user.verification_token_expires = datetime.now() + timedelta(hours=24) await session.commit() - + # Envoyer email - base_url = str(request.base_url).rstrip('/') - AuthEmailService.send_verification_email( - user.email, - verification_token, - base_url - ) - - return { - "success": True, - "message": "Un nouveau lien de vérification a été envoyé." - } + base_url = str(request.base_url).rstrip("/") + AuthEmailService.send_verification_email(user.email, verification_token, base_url) + + return {"success": True, "message": "Un nouveau lien de vérification a été envoyé."} @router.post("/login", response_model=TokenResponse) async def login( - data: LoginRequest, - request: Request, - session: AsyncSession = Depends(get_session) + data: LoginRequest, request: Request, session: AsyncSession = Depends(get_session) ): """ 🔐 Connexion utilisateur - + Retourne access_token (30min) et refresh_token (7 jours) """ ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent", "unknown") - + # Rate limiting is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip) if not is_allowed: raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=error_msg + status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg ) - + # Charger l'utilisateur - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + # Vérifications if not user or not verify_password(data.password, user.hashed_password): - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Identifiants incorrects") - + await log_login_attempt( + session, + data.email.lower(), + ip, + user_agent, + False, + "Identifiants incorrects", + ) + # Incrémenter compteur échecs if user: user.failed_login_attempts += 1 - + # Verrouiller après 5 échecs if user.failed_login_attempts >= 5: user.locked_until = datetime.now() + timedelta(minutes=15) await session.commit() raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes." + detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes.", ) - + await session.commit() - + raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Email ou mot de passe incorrect" + detail="Email ou mot de passe incorrect", ) - + # Vérifier statut compte if not user.is_active: - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte désactivé") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte désactivé" + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Compte désactivé" ) - + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" + ) + if not user.is_verified: - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Email non vérifié") + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Email non vérifié" + ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception." + detail="Email non vérifié. Consultez votre boîte de réception.", ) - + # Vérifier verrouillage if user.locked_until and user.locked_until > datetime.now(): - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte verrouillé") + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Compte verrouillé" + ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé" + detail="Compte temporairement verrouillé", ) - + # ✅ CONNEXION RÉUSSIE - + # Réinitialiser compteur échecs user.failed_login_attempts = 0 user.locked_until = None user.last_login = datetime.now() - + # Créer tokens - access_token = create_access_token({"sub": user.id, "email": user.email, "role": user.role}) + access_token = create_access_token( + {"sub": user.id, "email": user.email, "role": user.role} + ) refresh_token_jwt = create_refresh_token(user.id) - + # Stocker refresh token en DB (hashé) refresh_token_record = RefreshToken( id=str(uuid.uuid4()), @@ -418,28 +407,27 @@ async def login( device_info=user_agent[:500], ip_address=ip, expires_at=datetime.now() + timedelta(days=7), - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(refresh_token_record) await session.commit() - + # Logger succès await log_login_attempt(session, data.email.lower(), ip, user_agent, True) - + logger.info(f"✅ Connexion réussie: {user.email}") - + return TokenResponse( access_token=access_token, refresh_token=refresh_token_jwt, - expires_in=86400 # 30 minutes + expires_in=86400, # 30 minutes ) @router.post("/refresh", response_model=TokenResponse) async def refresh_access_token( - data: RefreshTokenRequest, - session: AsyncSession = Depends(get_session) + data: RefreshTokenRequest, session: AsyncSession = Depends(get_session) ): """ 🔄 Renouvellement du access_token via refresh_token @@ -448,61 +436,55 @@ async def refresh_access_token( payload = decode_token(data.refresh_token) if not payload or payload.get("type") != "refresh": raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token invalide" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token invalide" ) - + user_id = payload.get("sub") token_hash = hash_token(data.refresh_token) - + # Vérifier en DB result = await session.execute( select(RefreshToken).where( RefreshToken.user_id == user_id, RefreshToken.token_hash == token_hash, - RefreshToken.is_revoked == False + RefreshToken.is_revoked == False, ) ) token_record = result.scalar_one_or_none() - + if not token_record: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token révoqué ou introuvable" + detail="Refresh token révoqué ou introuvable", ) - + # Vérifier expiration if token_record.expires_at < datetime.now(): raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token expiré" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré" ) - + # Charger utilisateur - result = await session.execute( - select(User).where(User.id == user_id) - ) + result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() - + if not user or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Utilisateur introuvable ou désactivé" + detail="Utilisateur introuvable ou désactivé", ) - + # Générer nouveau access token - new_access_token = create_access_token({ - "sub": user.id, - "email": user.email, - "role": user.role - }) - + new_access_token = create_access_token( + {"sub": user.id, "email": user.email, "role": user.role} + ) + logger.info(f"🔄 Token rafraîchi: {user.email}") - + return TokenResponse( access_token=new_access_token, refresh_token=data.refresh_token, # Refresh token reste le même - expires_in=86400 + expires_in=86400, ) @@ -510,79 +492,71 @@ async def refresh_access_token( async def forgot_password( data: ForgotPasswordRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 🔑 Demande de réinitialisation de mot de passe """ - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + # Ne pas révéler si l'utilisateur existe if not user: return { "success": True, - "message": "Si cet email existe, un lien de réinitialisation a été envoyé." + "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", } - + # Générer token de reset reset_token = generate_reset_token() user.reset_token = reset_token user.reset_token_expires = datetime.now() + timedelta(hours=1) await session.commit() - + # Envoyer email - frontend_url = settings.frontend_url if hasattr(settings, 'frontend_url') else str(request.base_url).rstrip('/') - AuthEmailService.send_password_reset_email( - user.email, - reset_token, - frontend_url + frontend_url = ( + settings.frontend_url + if hasattr(settings, "frontend_url") + else str(request.base_url).rstrip("/") ) - + AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url) + logger.info(f"📧 Reset password demandé: {user.email}") - + return { "success": True, - "message": "Si cet email existe, un lien de réinitialisation a été envoyé." + "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", } @router.post("/reset-password") async def reset_password( - data: ResetPasswordRequest, - session: AsyncSession = Depends(get_session) + data: ResetPasswordRequest, session: AsyncSession = Depends(get_session) ): """ 🔐 Réinitialisation du mot de passe avec token """ - result = await session.execute( - select(User).where(User.reset_token == data.token) - ) + result = await session.execute(select(User).where(User.reset_token == data.token)) user = result.scalar_one_or_none() - + if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token de réinitialisation invalide" + detail="Token de réinitialisation invalide", ) - + # Vérifier expiration if user.reset_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token expiré. Demandez un nouveau lien de réinitialisation." + detail="Token expiré. Demandez un nouveau lien de réinitialisation.", ) - + # Valider nouveau mot de passe is_valid, error_msg = validate_password_strength(data.new_password) if not is_valid: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=error_msg - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) + # Mettre à jour user.hashed_password = hash_password(data.new_password) user.reset_token = None @@ -590,15 +564,15 @@ async def reset_password( user.failed_login_attempts = 0 user.locked_until = None await session.commit() - + # Envoyer notification AuthEmailService.send_password_changed_notification(user.email) - + logger.info(f"🔐 Mot de passe réinitialisé: {user.email}") - + return { "success": True, - "message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter." + "message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter.", } @@ -606,32 +580,28 @@ async def reset_password( async def logout( data: RefreshTokenRequest, session: AsyncSession = Depends(get_session), - user: User = Depends(get_current_user) + user: User = Depends(get_current_user), ): """ 🚪 Déconnexion (révocation du refresh token) """ token_hash = hash_token(data.refresh_token) - + result = await session.execute( select(RefreshToken).where( - RefreshToken.user_id == user.id, - RefreshToken.token_hash == token_hash + RefreshToken.user_id == user.id, RefreshToken.token_hash == token_hash ) ) token_record = result.scalar_one_or_none() - + if token_record: token_record.is_revoked = True token_record.revoked_at = datetime.now() await session.commit() - + logger.info(f"👋 Déconnexion: {user.email}") - - return { - "success": True, - "message": "Déconnexion réussie" - } + + return {"success": True, "message": "Déconnexion réussie"} @router.get("/me") @@ -647,5 +617,5 @@ async def get_current_user_info(user: User = Depends(get_current_user)): "role": user.role, "is_verified": user.is_verified, "created_at": user.created_at.isoformat(), - "last_login": user.last_login.isoformat() if user.last_login else None - } \ No newline at end of file + "last_login": user.last_login.isoformat() if user.last_login else None, + } diff --git a/security/auth.py b/security/auth.py index 9c5009d..7fc182c 100644 --- a/security/auth.py +++ b/security/auth.py @@ -45,24 +45,20 @@ def hash_token(token: str) -> str: def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str: """ Crée un JWT access token - + Args: data: Payload (doit contenir 'sub' = user_id) expires_delta: Durée de validité personnalisée """ to_encode = data.copy() - + if expires_delta: expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - - to_encode.update({ - "exp": expire, - "iat": datetime.utcnow(), - "type": "access" - }) - + + to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "access"}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt @@ -70,20 +66,20 @@ def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) - def create_refresh_token(user_id: str) -> str: """ Crée un refresh token (JWT long terme) - + Returns: Token JWT non hashé (à hasher avant stockage DB) """ expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) - + to_encode = { "sub": user_id, "exp": expire, "iat": datetime.utcnow(), "type": "refresh", - "jti": secrets.token_urlsafe(16) # Unique ID + "jti": secrets.token_urlsafe(16), # Unique ID } - + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt @@ -91,7 +87,7 @@ def create_refresh_token(user_id: str) -> str: def decode_token(token: str) -> Optional[Dict]: """ Décode et valide un JWT - + Returns: Payload si valide, None sinon """ @@ -108,24 +104,24 @@ def decode_token(token: str) -> Optional[Dict]: def validate_password_strength(password: str) -> tuple[bool, str]: """ Valide la robustesse d'un mot de passe - + Returns: (is_valid, error_message) """ if len(password) < 8: return False, "Le mot de passe doit contenir au moins 8 caractères" - + if not any(c.isupper() for c in password): return False, "Le mot de passe doit contenir au moins une majuscule" - + if not any(c.islower() for c in password): return False, "Le mot de passe doit contenir au moins une minuscule" - + if not any(c.isdigit() for c in password): return False, "Le mot de passe doit contenir au moins un chiffre" - + special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?" if not any(c in special_chars for c in password): return False, "Le mot de passe doit contenir au moins un caractère spécial" - - return True, "" \ No newline at end of file + + return True, "" diff --git a/services/email_service.py b/services/email_service.py index 44152df..7bb7661 100644 --- a/services/email_service.py +++ b/services/email_service.py @@ -9,46 +9,48 @@ logger = logging.getLogger(__name__) class AuthEmailService: """Service d'envoi d'emails pour l'authentification""" - + @staticmethod def _send_email(to: str, subject: str, html_body: str) -> bool: """Envoi SMTP générique""" try: msg = MIMEMultipart() - msg['From'] = settings.smtp_from - msg['To'] = to - msg['Subject'] = subject - - msg.attach(MIMEText(html_body, 'html')) - - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server: + msg["From"] = settings.smtp_from + msg["To"] = to + msg["Subject"] = subject + + msg.attach(MIMEText(html_body, "html")) + + with smtplib.SMTP( + settings.smtp_host, settings.smtp_port, timeout=30 + ) as server: if settings.smtp_use_tls: server.starttls() - + if settings.smtp_user and settings.smtp_password: server.login(settings.smtp_user, settings.smtp_password) - + server.send_message(msg) - + logger.info(f"✅ Email envoyé: {subject} → {to}") return True - + except Exception as e: logger.error(f"❌ Erreur envoi email: {e}") return False - + @staticmethod def send_verification_email(email: str, token: str, base_url: str) -> bool: """ Envoie l'email de vérification avec lien de confirmation - + Args: email: Email du destinataire token: Token de vérification base_url: URL de base de l'API (ex: https://api.votredomaine.com) """ verification_link = f"{base_url}/auth/verify-email?token={token}" - + html_body = f""" @@ -103,25 +105,23 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "🔐 Vérifiez votre adresse email - Sage Dataven", - html_body + email, "🔐 Vérifiez votre adresse email - Sage Dataven", html_body ) - + @staticmethod def send_password_reset_email(email: str, token: str, base_url: str) -> bool: """ Envoie l'email de réinitialisation de mot de passe - + Args: email: Email du destinataire token: Token de reset base_url: URL de base du frontend """ reset_link = f"{base_url}/reset?token={token}" - + html_body = f""" @@ -176,13 +176,11 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "🔐 Réinitialisation de votre mot de passe - Sage Dataven", - html_body + email, "🔐 Réinitialisation de votre mot de passe - Sage Dataven", html_body ) - + @staticmethod def send_password_changed_notification(email: str) -> bool: """Notification après changement de mot de passe réussi""" @@ -218,9 +216,7 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "✅ Votre mot de passe a été modifié - Sage Dataven", - html_body - ) \ No newline at end of file + email, "✅ Votre mot de passe a été modifié - Sage Dataven", html_body + )