feat: implement comprehensive user authentication including registration, login, email verification, password reset, and token management.

This commit is contained in:
Fanilo-Nantenaina 2025-12-08 18:03:47 +03:00
parent e95e550044
commit 4434f0716f
11 changed files with 504 additions and 511 deletions

View file

@ -6,8 +6,8 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
) )
# === JWT & Auth === # === JWT & Auth ===
jwt_secret: str jwt_secret: str
jwt_algorithm: str jwt_algorithm: str
access_token_expire_minutes: int access_token_expire_minutes: int

View file

@ -12,18 +12,18 @@ security = HTTPBearer()
async def get_current_user( async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
session: AsyncSession = Depends(get_session) session: AsyncSession = Depends(get_session),
) -> User: ) -> User:
""" """
Dépendance FastAPI pour extraire l'utilisateur du JWT Dépendance FastAPI pour extraire l'utilisateur du JWT
Usage dans un endpoint: Usage dans un endpoint:
@app.get("/protected") @app.get("/protected")
async def protected_route(user: User = Depends(get_current_user)): async def protected_route(user: User = Depends(get_current_user)):
return {"user_id": user.id} return {"user_id": user.id}
""" """
token = credentials.credentials token = credentials.credentials
# Décoder le token # Décoder le token
payload = decode_token(token) payload = decode_token(token)
if not payload: if not payload:
@ -32,7 +32,7 @@ async def get_current_user(
detail="Token invalide ou expiré", detail="Token invalide ou expiré",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Vérifier le type # Vérifier le type
if payload.get("type") != "access": if payload.get("type") != "access":
raise HTTPException( raise HTTPException(
@ -40,7 +40,7 @@ async def get_current_user(
detail="Type de token incorrect", detail="Type de token incorrect",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Extraire user_id # Extraire user_id
user_id: str = payload.get("sub") user_id: str = payload.get("sub")
if not user_id: if not user_id:
@ -49,46 +49,43 @@ async def get_current_user(
detail="Token malformé", detail="Token malformé",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Charger l'utilisateur # Charger l'utilisateur
result = await session.execute( result = await session.execute(select(User).where(User.id == user_id))
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Utilisateur introuvable", detail="Utilisateur introuvable",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Vérifications de sécurité # Vérifications de sécurité
if not user.is_active: if not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé"
detail="Compte désactivé"
) )
if not user.is_verified: if not user.is_verified:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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é!) # ✅ FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!)
if user.locked_until and user.locked_until > datetime.now(): if user.locked_until and user.locked_until > datetime.now():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 return user
async def get_current_user_optional( async def get_current_user_optional(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
session: AsyncSession = Depends(get_session) session: AsyncSession = Depends(get_session),
) -> Optional[User]: ) -> Optional[User]:
""" """
Version optionnelle - ne lève pas d'erreur si pas de token 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: if not credentials:
return None return None
try: try:
return await get_current_user(credentials, session) return await get_current_user(credentials, session)
except HTTPException: except HTTPException:
@ -106,18 +103,19 @@ async def get_current_user_optional(
def require_role(*allowed_roles: str): def require_role(*allowed_roles: str):
""" """
Décorateur pour restreindre l'accès par rôle Décorateur pour restreindre l'accès par rôle
Usage: Usage:
@app.get("/admin/users") @app.get("/admin/users")
async def list_users(user: User = Depends(require_role("admin"))): async def list_users(user: User = Depends(require_role("admin"))):
... ...
""" """
async def role_checker(user: User = Depends(get_current_user)) -> User: async def role_checker(user: User = Depends(get_current_user)) -> User:
if user.role not in allowed_roles: if user.role not in allowed_roles:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 user
return role_checker return role_checker

View file

@ -25,29 +25,31 @@ logger = logging.getLogger(__name__)
async def create_admin(): async def create_admin():
"""Crée un utilisateur admin""" """Crée un utilisateur admin"""
print("\n" + "="*60) print("\n" + "=" * 60)
print("🔐 Création d'un compte administrateur") print("🔐 Création d'un compte administrateur")
print("="*60 + "\n") print("=" * 60 + "\n")
# Saisie des informations # Saisie des informations
email = input("Email de l'admin: ").strip().lower() 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") print("❌ Email invalide")
return False return False
prenom = input("Prénom: ").strip() prenom = input("Prénom: ").strip()
nom = input("Nom: ").strip() nom = input("Nom: ").strip()
if not prenom or not nom: if not prenom or not nom:
print("❌ Prénom et nom requis") print("❌ Prénom et nom requis")
return False return False
# Mot de passe avec validation # Mot de passe avec validation
while True: 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) is_valid, error_msg = validate_password_strength(password)
if is_valid: if is_valid:
confirm = input("Confirmez le mot de passe: ") confirm = input("Confirmez le mot de passe: ")
if password == confirm: if password == confirm:
@ -56,20 +58,18 @@ async def create_admin():
print("❌ Les mots de passe ne correspondent pas\n") print("❌ Les mots de passe ne correspondent pas\n")
else: else:
print(f"{error_msg}\n") print(f"{error_msg}\n")
# Vérifier si l'email existe déjà # Vérifier si l'email existe déjà
async with async_session_factory() as session: async with async_session_factory() as session:
from sqlalchemy import select from sqlalchemy import select
result = await session.execute( result = await session.execute(select(User).where(User.email == email))
select(User).where(User.email == email)
)
existing = result.scalar_one_or_none() existing = result.scalar_one_or_none()
if existing: if existing:
print(f"\n❌ Un utilisateur avec l'email {email} existe déjà") print(f"\n❌ Un utilisateur avec l'email {email} existe déjà")
return False return False
# Créer l'admin # Créer l'admin
admin = User( admin = User(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@ -80,19 +80,19 @@ async def create_admin():
role="admin", role="admin",
is_verified=True, # Admin vérifié par défaut is_verified=True, # Admin vérifié par défaut
is_active=True, is_active=True,
created_at=datetime.now() created_at=datetime.now(),
) )
session.add(admin) session.add(admin)
await session.commit() await session.commit()
print("\n✅ Administrateur créé avec succès!") print("\n✅ Administrateur créé avec succès!")
print(f"📧 Email: {email}") print(f"📧 Email: {email}")
print(f"👤 Nom: {prenom} {nom}") print(f"👤 Nom: {prenom} {nom}")
print(f"🔑 Rôle: admin") print(f"🔑 Rôle: admin")
print(f"🆔 ID: {admin.id}") print(f"🆔 ID: {admin.id}")
print("\n💡 Vous pouvez maintenant vous connecter à l'API\n") print("\n💡 Vous pouvez maintenant vous connecter à l'API\n")
return True return True
@ -106,4 +106,4 @@ if __name__ == "__main__":
except Exception as e: except Exception as e:
print(f"\n❌ Erreur: {e}") print(f"\n❌ Erreur: {e}")
logger.exception("Détails:") logger.exception("Détails:")
sys.exit(1) sys.exit(1)

View file

@ -3,7 +3,7 @@ from database.db_config import (
async_session_factory, async_session_factory,
init_db, init_db,
get_session, get_session,
close_db close_db,
) )
from database.models import ( from database.models import (
@ -23,26 +23,23 @@ from database.models import (
__all__ = [ __all__ = [
# Config # Config
'engine', "engine",
'async_session_factory', "async_session_factory",
'init_db', "init_db",
'get_session', "get_session",
'close_db', "close_db",
# Models existants # Models existants
'Base', "Base",
'EmailLog', "EmailLog",
'SignatureLog', "SignatureLog",
'WorkflowLog', "WorkflowLog",
'CacheMetadata', "CacheMetadata",
'AuditLog', "AuditLog",
# Enums # Enums
'StatutEmail', "StatutEmail",
'StatutSignature', "StatutSignature",
# Modèles auth # Modèles auth
'User', "User",
'RefreshToken', "RefreshToken",
'LoginAttempt', "LoginAttempt",
] ]

View file

@ -32,10 +32,10 @@ async def init_db():
try: try:
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
logger.info("✅ Base de données initialisée avec succès") logger.info("✅ Base de données initialisée avec succès")
logger.info(f"📍 Fichier DB: {DATABASE_URL}") logger.info(f"📍 Fichier DB: {DATABASE_URL}")
except Exception as e: except Exception as e:
logger.error(f"❌ Erreur initialisation DB: {e}") logger.error(f"❌ Erreur initialisation DB: {e}")
raise raise
@ -53,4 +53,4 @@ async def get_session() -> AsyncSession:
async def close_db(): async def close_db():
"""Ferme proprement toutes les connexions""" """Ferme proprement toutes les connexions"""
await engine.dispose() await engine.dispose()
logger.info("🔌 Connexions DB fermées") logger.info("🔌 Connexions DB fermées")

View file

@ -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 sqlalchemy.ext.declarative import declarative_base
from datetime import datetime from datetime import datetime
import enum import enum
@ -9,8 +18,10 @@ Base = declarative_base()
# Enums # Enums
# ============================================================================ # ============================================================================
class StatutEmail(str, enum.Enum): class StatutEmail(str, enum.Enum):
"""Statuts possibles d'un email""" """Statuts possibles d'un email"""
EN_ATTENTE = "EN_ATTENTE" EN_ATTENTE = "EN_ATTENTE"
EN_COURS = "EN_COURS" EN_COURS = "EN_COURS"
ENVOYE = "ENVOYE" ENVOYE = "ENVOYE"
@ -18,54 +29,59 @@ class StatutEmail(str, enum.Enum):
ERREUR = "ERREUR" ERREUR = "ERREUR"
BOUNCE = "BOUNCE" BOUNCE = "BOUNCE"
class StatutSignature(str, enum.Enum): class StatutSignature(str, enum.Enum):
"""Statuts possibles d'une signature électronique""" """Statuts possibles d'une signature électronique"""
EN_ATTENTE = "EN_ATTENTE" EN_ATTENTE = "EN_ATTENTE"
ENVOYE = "ENVOYE" ENVOYE = "ENVOYE"
SIGNE = "SIGNE" SIGNE = "SIGNE"
REFUSE = "REFUSE" REFUSE = "REFUSE"
EXPIRE = "EXPIRE" EXPIRE = "EXPIRE"
# ============================================================================ # ============================================================================
# Tables # Tables
# ============================================================================ # ============================================================================
class EmailLog(Base): class EmailLog(Base):
""" """
Journal des emails envoyés via l'API Journal des emails envoyés via l'API
Permet le suivi et le retry automatique Permet le suivi et le retry automatique
""" """
__tablename__ = "email_logs" __tablename__ = "email_logs"
# Identifiant # Identifiant
id = Column(String(36), primary_key=True) id = Column(String(36), primary_key=True)
# Destinataires # Destinataires
destinataire = Column(String(255), nullable=False, index=True) destinataire = Column(String(255), nullable=False, index=True)
cc = Column(Text, nullable=True) # JSON stringifié cc = Column(Text, nullable=True) # JSON stringifié
cci = Column(Text, nullable=True) # JSON stringifié cci = Column(Text, nullable=True) # JSON stringifié
# Contenu # Contenu
sujet = Column(String(500), nullable=False) sujet = Column(String(500), nullable=False)
corps_html = Column(Text, nullable=False) corps_html = Column(Text, nullable=False)
# Documents attachés # Documents attachés
document_ids = Column(Text, nullable=True) # Séparés par virgules document_ids = Column(Text, nullable=True) # Séparés par virgules
type_document = Column(Integer, nullable=True) type_document = Column(Integer, nullable=True)
# Statut # Statut
statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True) statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True)
# Tracking temporel # Tracking temporel
date_creation = Column(DateTime, default=datetime.now, nullable=False) date_creation = Column(DateTime, default=datetime.now, nullable=False)
date_envoi = Column(DateTime, nullable=True) date_envoi = Column(DateTime, nullable=True)
date_ouverture = Column(DateTime, nullable=True) date_ouverture = Column(DateTime, nullable=True)
# Retry automatique # Retry automatique
nb_tentatives = Column(Integer, default=0) nb_tentatives = Column(Integer, default=0)
derniere_erreur = Column(Text, nullable=True) derniere_erreur = Column(Text, nullable=True)
prochain_retry = Column(DateTime, nullable=True) prochain_retry = Column(DateTime, nullable=True)
# Métadonnées # Métadonnées
ip_envoi = Column(String(45), nullable=True) ip_envoi = Column(String(45), nullable=True)
user_agent = Column(String(500), nullable=True) user_agent = Column(String(500), nullable=True)
@ -79,33 +95,36 @@ class SignatureLog(Base):
Journal des demandes de signature Universign Journal des demandes de signature Universign
Permet le suivi du workflow de signature Permet le suivi du workflow de signature
""" """
__tablename__ = "signature_logs" __tablename__ = "signature_logs"
# Identifiant # Identifiant
id = Column(String(36), primary_key=True) id = Column(String(36), primary_key=True)
# Document Sage associé # Document Sage associé
document_id = Column(String(100), nullable=False, index=True) document_id = Column(String(100), nullable=False, index=True)
type_document = Column(Integer, nullable=False) type_document = Column(Integer, nullable=False)
# Universign # Universign
transaction_id = Column(String(100), unique=True, index=True, nullable=True) transaction_id = Column(String(100), unique=True, index=True, nullable=True)
signer_url = Column(String(500), nullable=True) signer_url = Column(String(500), nullable=True)
# Signataire # Signataire
email_signataire = Column(String(255), nullable=False, index=True) email_signataire = Column(String(255), nullable=False, index=True)
nom_signataire = Column(String(255), nullable=False) nom_signataire = Column(String(255), nullable=False)
# Statut # 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_envoi = Column(DateTime, default=datetime.now)
date_signature = Column(DateTime, nullable=True) date_signature = Column(DateTime, nullable=True)
date_refus = Column(DateTime, nullable=True) date_refus = Column(DateTime, nullable=True)
# Relances # Relances
est_relance = Column(Boolean, default=False) est_relance = Column(Boolean, default=False)
nb_relances = Column(Integer, default=0) nb_relances = Column(Integer, default=0)
# Métadonnées # Métadonnées
raison_refus = Column(Text, nullable=True) raison_refus = Column(Text, nullable=True)
ip_signature = Column(String(45), nullable=True) ip_signature = Column(String(45), nullable=True)
@ -119,27 +138,28 @@ class WorkflowLog(Base):
Journal des transformations de documents (Devis Commande Facture) Journal des transformations de documents (Devis Commande Facture)
Permet la traçabilité du workflow commercial Permet la traçabilité du workflow commercial
""" """
__tablename__ = "workflow_logs" __tablename__ = "workflow_logs"
# Identifiant # Identifiant
id = Column(String(36), primary_key=True) id = Column(String(36), primary_key=True)
# Documents # Documents
document_source = Column(String(100), nullable=False, index=True) document_source = Column(String(100), nullable=False, index=True)
type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc. type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc.
document_cible = Column(String(100), nullable=False, index=True) document_cible = Column(String(100), nullable=False, index=True)
type_cible = Column(Integer, nullable=False) type_cible = Column(Integer, nullable=False)
# Métadonnées de transformation # Métadonnées de transformation
nb_lignes = Column(Integer, nullable=True) nb_lignes = Column(Integer, nullable=True)
montant_ht = Column(Float, nullable=True) montant_ht = Column(Float, nullable=True)
montant_ttc = Column(Float, nullable=True) montant_ttc = Column(Float, nullable=True)
# Tracking # Tracking
date_transformation = Column(DateTime, default=datetime.now, nullable=False) date_transformation = Column(DateTime, default=datetime.now, nullable=False)
utilisateur = Column(String(100), nullable=True) utilisateur = Column(String(100), nullable=True)
# Résultat # Résultat
succes = Column(Boolean, default=True) succes = Column(Boolean, default=True)
erreur = Column(Text, nullable=True) erreur = Column(Text, nullable=True)
@ -154,18 +174,21 @@ class CacheMetadata(Base):
Métadonnées sur le cache Sage (clients, articles) Métadonnées sur le cache Sage (clients, articles)
Permet le monitoring du cache géré par la gateway Windows Permet le monitoring du cache géré par la gateway Windows
""" """
__tablename__ = "cache_metadata" __tablename__ = "cache_metadata"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
# Type de cache # 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 # Statistiques
last_refresh = Column(DateTime, default=datetime.now) last_refresh = Column(DateTime, default=datetime.now)
item_count = Column(Integer, default=0) item_count = Column(Integer, default=0)
refresh_duration_ms = Column(Float, nullable=True) refresh_duration_ms = Column(Float, nullable=True)
# Santé # Santé
last_error = Column(Text, nullable=True) last_error = Column(Text, nullable=True)
error_count = Column(Integer, default=0) error_count = Column(Integer, default=0)
@ -179,66 +202,72 @@ class AuditLog(Base):
Journal d'audit pour la sécurité et la conformité Journal d'audit pour la sécurité et la conformité
Trace toutes les actions importantes dans l'API Trace toutes les actions importantes dans l'API
""" """
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
# Action # 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_type = Column(String(50), nullable=True) # 'devis', 'facture', etc.
ressource_id = Column(String(100), nullable=True, index=True) ressource_id = Column(String(100), nullable=True, index=True)
# Utilisateur (si authentification ajoutée plus tard) # Utilisateur (si authentification ajoutée plus tard)
utilisateur = Column(String(100), nullable=True) utilisateur = Column(String(100), nullable=True)
ip_address = Column(String(45), nullable=True) ip_address = Column(String(45), nullable=True)
# Résultat # Résultat
succes = Column(Boolean, default=True) succes = Column(Boolean, default=True)
details = Column(Text, nullable=True) # JSON stringifié details = Column(Text, nullable=True) # JSON stringifié
erreur = Column(Text, nullable=True) erreur = Column(Text, nullable=True)
# Timestamp # Timestamp
date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) date_action = Column(DateTime, default=datetime.now, nullable=False, index=True)
def __repr__(self): def __repr__(self):
return f"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>" return f"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>"
# Ajouter ces modèles à la fin de database/models.py # Ajouter ces modèles à la fin de database/models.py
class User(Base): class User(Base):
""" """
Utilisateurs de l'API avec validation email Utilisateurs de l'API avec validation email
""" """
__tablename__ = "users" __tablename__ = "users"
id = Column(String(36), primary_key=True) id = Column(String(36), primary_key=True)
email = Column(String(255), unique=True, nullable=False, index=True) email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False) hashed_password = Column(String(255), nullable=False)
# Profil # Profil
nom = Column(String(100), nullable=False) nom = Column(String(100), nullable=False)
prenom = Column(String(100), nullable=False) prenom = Column(String(100), nullable=False)
role = Column(String(50), default="user") # user, admin, commercial role = Column(String(50), default="user") # user, admin, commercial
# Validation email # Validation email
is_verified = Column(Boolean, default=False) is_verified = Column(Boolean, default=False)
verification_token = Column(String(255), nullable=True, unique=True, index=True) verification_token = Column(String(255), nullable=True, unique=True, index=True)
verification_token_expires = Column(DateTime, nullable=True) verification_token_expires = Column(DateTime, nullable=True)
# Sécurité # Sécurité
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
failed_login_attempts = Column(Integer, default=0) failed_login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True) locked_until = Column(DateTime, nullable=True)
# Mot de passe oublié # Mot de passe oublié
reset_token = Column(String(255), nullable=True, unique=True, index=True) reset_token = Column(String(255), nullable=True, unique=True, index=True)
reset_token_expires = Column(DateTime, nullable=True) reset_token_expires = Column(DateTime, nullable=True)
# Timestamps # Timestamps
created_at = Column(DateTime, default=datetime.now, nullable=False) created_at = Column(DateTime, default=datetime.now, nullable=False)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
last_login = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True)
def __repr__(self): def __repr__(self):
return f"<User {self.email} verified={self.is_verified}>" return f"<User {self.email} verified={self.is_verified}>"
@ -247,24 +276,25 @@ class RefreshToken(Base):
""" """
Tokens de rafraîchissement JWT Tokens de rafraîchissement JWT
""" """
__tablename__ = "refresh_tokens" __tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True) id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True) user_id = Column(String(36), nullable=False, index=True)
token_hash = Column(String(255), nullable=False, unique=True, index=True) token_hash = Column(String(255), nullable=False, unique=True, index=True)
# Métadonnées # Métadonnées
device_info = Column(String(500), nullable=True) device_info = Column(String(500), nullable=True)
ip_address = Column(String(45), nullable=True) ip_address = Column(String(45), nullable=True)
# Expiration # Expiration
expires_at = Column(DateTime, nullable=False) expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.now, nullable=False) created_at = Column(DateTime, default=datetime.now, nullable=False)
# Révocation # Révocation
is_revoked = Column(Boolean, default=False) is_revoked = Column(Boolean, default=False)
revoked_at = Column(DateTime, nullable=True) revoked_at = Column(DateTime, nullable=True)
def __repr__(self): def __repr__(self):
return f"<RefreshToken user={self.user_id} revoked={self.is_revoked}>" return f"<RefreshToken user={self.user_id} revoked={self.is_revoked}>"
@ -273,18 +303,19 @@ class LoginAttempt(Base):
""" """
Journal des tentatives de connexion (détection bruteforce) Journal des tentatives de connexion (détection bruteforce)
""" """
__tablename__ = "login_attempts" __tablename__ = "login_attempts"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(255), nullable=False, index=True) email = Column(String(255), nullable=False, index=True)
ip_address = Column(String(45), nullable=False, index=True) ip_address = Column(String(45), nullable=False, index=True)
user_agent = Column(String(500), nullable=True) user_agent = Column(String(500), nullable=True)
success = Column(Boolean, default=False) success = Column(Boolean, default=False)
failure_reason = Column(String(255), nullable=True) failure_reason = Column(String(255), nullable=True)
timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True) timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True)
def __repr__(self): def __repr__(self):
return f"<LoginAttempt {self.email} success={self.success}>" return f"<LoginAttempt {self.email} success={self.success}>"

View file

@ -25,67 +25,65 @@ class EmailQueue:
""" """
Queue d'emails avec workers threadés et retry automatique Queue d'emails avec workers threadés et retry automatique
""" """
def __init__(self): def __init__(self):
self.queue = queue.Queue() self.queue = queue.Queue()
self.workers = [] self.workers = []
self.running = False self.running = False
self.session_factory = None self.session_factory = None
self.sage_client = None self.sage_client = None
def start(self, num_workers: int = 3): def start(self, num_workers: int = 3):
"""Démarre les workers""" """Démarre les workers"""
if self.running: if self.running:
logger.warning("Queue déjà démarrée") logger.warning("Queue déjà démarrée")
return return
self.running = True self.running = True
for i in range(num_workers): for i in range(num_workers):
worker = threading.Thread( worker = threading.Thread(
target=self._worker, target=self._worker, name=f"EmailWorker-{i}", daemon=True
name=f"EmailWorker-{i}",
daemon=True
) )
worker.start() worker.start()
self.workers.append(worker) self.workers.append(worker)
logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)") logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)")
def stop(self): def stop(self):
"""Arrête les workers proprement""" """Arrête les workers proprement"""
logger.info("🛑 Arrêt de la queue email...") logger.info("🛑 Arrêt de la queue email...")
self.running = False self.running = False
# Attendre que la queue soit vide (max 30s) # Attendre que la queue soit vide (max 30s)
try: try:
self.queue.join() self.queue.join()
logger.info("✅ Queue email arrêtée proprement") logger.info("✅ Queue email arrêtée proprement")
except: except:
logger.warning("⚠️ Timeout lors de l'arrêt de la queue") logger.warning("⚠️ Timeout lors de l'arrêt de la queue")
def enqueue(self, email_log_id: str): def enqueue(self, email_log_id: str):
"""Ajoute un email dans la queue""" """Ajoute un email dans la queue"""
self.queue.put(email_log_id) self.queue.put(email_log_id)
logger.debug(f"📨 Email {email_log_id} ajouté à la queue") logger.debug(f"📨 Email {email_log_id} ajouté à la queue")
def _worker(self): def _worker(self):
"""Worker qui traite les emails dans un thread""" """Worker qui traite les emails dans un thread"""
# Créer une event loop pour ce thread # Créer une event loop pour ce thread
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: try:
while self.running: while self.running:
try: try:
# Récupérer un email de la queue (timeout 1s) # Récupérer un email de la queue (timeout 1s)
email_log_id = self.queue.get(timeout=1) email_log_id = self.queue.get(timeout=1)
# Traiter l'email # Traiter l'email
loop.run_until_complete(self._process_email(email_log_id)) loop.run_until_complete(self._process_email(email_log_id))
# Marquer comme traité # Marquer comme traité
self.queue.task_done() self.queue.task_done()
except queue.Empty: except queue.Empty:
continue continue
except Exception as e: except Exception as e:
@ -96,144 +94,147 @@ class EmailQueue:
pass pass
finally: finally:
loop.close() loop.close()
async def _process_email(self, email_log_id: str): async def _process_email(self, email_log_id: str):
"""Traite un email avec retry automatique""" """Traite un email avec retry automatique"""
from database import EmailLog, StatutEmail from database import EmailLog, StatutEmail
from sqlalchemy import select from sqlalchemy import select
if not self.session_factory: if not self.session_factory:
logger.error("❌ session_factory non configuré") logger.error("❌ session_factory non configuré")
return return
async with self.session_factory() as session: async with self.session_factory() as session:
# Charger l'email log # Charger l'email log
result = await session.execute( result = await session.execute(
select(EmailLog).where(EmailLog.id == email_log_id) select(EmailLog).where(EmailLog.id == email_log_id)
) )
email_log = result.scalar_one_or_none() email_log = result.scalar_one_or_none()
if not email_log: if not email_log:
logger.error(f"❌ Email log {email_log_id} introuvable") logger.error(f"❌ Email log {email_log_id} introuvable")
return return
# Marquer comme en cours # Marquer comme en cours
email_log.statut = StatutEmail.EN_COURS email_log.statut = StatutEmail.EN_COURS
email_log.nb_tentatives += 1 email_log.nb_tentatives += 1
await session.commit() await session.commit()
try: try:
# Envoi avec retry automatique # Envoi avec retry automatique
await self._send_with_retry(email_log) await self._send_with_retry(email_log)
# Succès # Succès
email_log.statut = StatutEmail.ENVOYE email_log.statut = StatutEmail.ENVOYE
email_log.date_envoi = datetime.now() email_log.date_envoi = datetime.now()
email_log.derniere_erreur = None email_log.derniere_erreur = None
logger.info(f"✅ Email envoyé: {email_log.destinataire}") logger.info(f"✅ Email envoyé: {email_log.destinataire}")
except Exception as e: except Exception as e:
# Échec # Échec
email_log.statut = StatutEmail.ERREUR email_log.statut = StatutEmail.ERREUR
email_log.derniere_erreur = str(e)[:1000] # Limiter la taille email_log.derniere_erreur = str(e)[:1000] # Limiter la taille
# Programmer un retry si < max attempts # Programmer un retry si < max attempts
if email_log.nb_tentatives < settings.max_retry_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) email_log.prochain_retry = datetime.now() + timedelta(seconds=delay)
# Programmer le retry # Programmer le retry
timer = threading.Timer(delay, self.enqueue, args=[email_log_id]) timer = threading.Timer(delay, self.enqueue, args=[email_log_id])
timer.daemon = True timer.daemon = True
timer.start() 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: else:
logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}") logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}")
await session.commit() await session.commit()
@retry( @retry(
stop=stop_after_attempt(3), stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10)
wait=wait_exponential(multiplier=1, min=4, max=10)
) )
async def _send_with_retry(self, email_log): async def _send_with_retry(self, email_log):
"""Envoi SMTP avec retry Tenacity + génération PDF""" """Envoi SMTP avec retry Tenacity + génération PDF"""
# Préparer le message # Préparer le message
msg = MIMEMultipart() msg = MIMEMultipart()
msg['From'] = settings.smtp_from msg["From"] = settings.smtp_from
msg['To'] = email_log.destinataire msg["To"] = email_log.destinataire
msg['Subject'] = email_log.sujet msg["Subject"] = email_log.sujet
# Corps HTML # 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 # 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs
if email_log.document_ids: 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 type_doc = email_log.type_document
for doc_id in document_ids: for doc_id in document_ids:
doc_id = doc_id.strip() doc_id = doc_id.strip()
if not doc_id: if not doc_id:
continue continue
try: try:
# Générer PDF (appel bloquant dans thread séparé) # Générer PDF (appel bloquant dans thread séparé)
pdf_bytes = await asyncio.to_thread( pdf_bytes = await asyncio.to_thread(
self._generate_pdf, self._generate_pdf, doc_id, type_doc
doc_id,
type_doc
) )
if pdf_bytes: if pdf_bytes:
# Attacher PDF # Attacher PDF
part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.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) msg.attach(part)
logger.info(f"📎 PDF attaché: {doc_id}.pdf") logger.info(f"📎 PDF attaché: {doc_id}.pdf")
except Exception as e: except Exception as e:
logger.error(f"❌ Erreur génération PDF {doc_id}: {e}") logger.error(f"❌ Erreur génération PDF {doc_id}: {e}")
# Continuer avec les autres PDFs # Continuer avec les autres PDFs
# Envoi SMTP (bloquant mais dans thread séparé) # Envoi SMTP (bloquant mais dans thread séparé)
await asyncio.to_thread(self._send_smtp, msg) await asyncio.to_thread(self._send_smtp, msg)
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
""" """
Génération PDF via ReportLab + sage_client Génération PDF via ReportLab + sage_client
Cette méthode est appelée depuis un thread worker Cette méthode est appelée depuis un thread worker
""" """
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from reportlab.lib.units import cm from reportlab.lib.units import cm
from io import BytesIO from io import BytesIO
if not self.sage_client: if not self.sage_client:
logger.error("❌ sage_client non configuré") logger.error("❌ sage_client non configuré")
raise Exception("sage_client non disponible") raise Exception("sage_client non disponible")
# 📡 Récupérer document depuis gateway Windows via HTTP # 📡 Récupérer document depuis gateway Windows via HTTP
try: try:
doc = self.sage_client.lire_document(doc_id, type_doc) doc = self.sage_client.lire_document(doc_id, type_doc)
except Exception as e: except Exception as e:
logger.error(f"❌ Erreur récupération document {doc_id}: {e}") logger.error(f"❌ Erreur récupération document {doc_id}: {e}")
raise Exception(f"Document {doc_id} inaccessible") raise Exception(f"Document {doc_id} inaccessible")
if not doc: if not doc:
raise Exception(f"Document {doc_id} introuvable") raise Exception(f"Document {doc_id} introuvable")
# 📄 Créer PDF avec ReportLab # 📄 Créer PDF avec ReportLab
buffer = BytesIO() buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4) pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4 width, height = A4
# === EN-TÊTE === # === EN-TÊTE ===
pdf.setFont("Helvetica-Bold", 20) 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 de document
type_labels = { type_labels = {
0: "DEVIS", 0: "DEVIS",
@ -241,101 +242,105 @@ class EmailQueue:
2: "BON DE RETOUR", 2: "BON DE RETOUR",
3: "COMMANDE", 3: "COMMANDE",
4: "PRÉPARATION", 4: "PRÉPARATION",
5: "FACTURE" 5: "FACTURE",
} }
type_label = type_labels.get(type_doc, "DOCUMENT") type_label = type_labels.get(type_doc, "DOCUMENT")
pdf.setFont("Helvetica", 12) 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 === # === INFORMATIONS CLIENT ===
y = height - 5*cm y = height - 5 * cm
pdf.setFont("Helvetica-Bold", 14) pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(2*cm, y, "CLIENT") pdf.drawString(2 * cm, y, "CLIENT")
y -= 0.8*cm y -= 0.8 * cm
pdf.setFont("Helvetica", 11) pdf.setFont("Helvetica", 11)
pdf.drawString(2*cm, y, f"Code: {doc.get('client_code', '')}") pdf.drawString(2 * cm, y, f"Code: {doc.get('client_code', '')}")
y -= 0.6*cm y -= 0.6 * cm
pdf.drawString(2*cm, y, f"Nom: {doc.get('client_intitule', '')}") pdf.drawString(2 * cm, y, f"Nom: {doc.get('client_intitule', '')}")
y -= 0.6*cm y -= 0.6 * cm
pdf.drawString(2*cm, y, f"Date: {doc.get('date', '')}") pdf.drawString(2 * cm, y, f"Date: {doc.get('date', '')}")
# === LIGNES === # === LIGNES ===
y -= 1.5*cm y -= 1.5 * cm
pdf.setFont("Helvetica-Bold", 14) pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(2*cm, y, "ARTICLES") pdf.drawString(2 * cm, y, "ARTICLES")
y -= 1*cm y -= 1 * cm
pdf.setFont("Helvetica-Bold", 10) pdf.setFont("Helvetica-Bold", 10)
pdf.drawString(2*cm, y, "Désignation") pdf.drawString(2 * cm, y, "Désignation")
pdf.drawString(10*cm, y, "Qté") pdf.drawString(10 * cm, y, "Qté")
pdf.drawString(12*cm, y, "Prix Unit.") pdf.drawString(12 * cm, y, "Prix Unit.")
pdf.drawString(15*cm, y, "Total HT") pdf.drawString(15 * cm, y, "Total HT")
y -= 0.5*cm y -= 0.5 * cm
pdf.line(2*cm, y, width - 2*cm, y) pdf.line(2 * cm, y, width - 2 * cm, y)
y -= 0.7*cm y -= 0.7 * cm
pdf.setFont("Helvetica", 9) pdf.setFont("Helvetica", 9)
for ligne in doc.get('lignes', []): for ligne in doc.get("lignes", []):
# Nouvelle page si nécessaire # Nouvelle page si nécessaire
if y < 3*cm: if y < 3 * cm:
pdf.showPage() pdf.showPage()
y = height - 3*cm y = height - 3 * cm
pdf.setFont("Helvetica", 9) pdf.setFont("Helvetica", 9)
designation = ligne.get('designation', '')[:50] designation = ligne.get("designation", "")[:50]
pdf.drawString(2*cm, y, designation) pdf.drawString(2 * cm, y, designation)
pdf.drawString(10*cm, y, str(ligne.get('quantite', 0))) pdf.drawString(10 * cm, y, str(ligne.get("quantite", 0)))
pdf.drawString(12*cm, y, f"{ligne.get('prix_unitaire', 0):.2f}") pdf.drawString(12 * cm, y, f"{ligne.get('prix_unitaire', 0):.2f}")
pdf.drawString(15*cm, y, f"{ligne.get('montant_ht', 0):.2f}") pdf.drawString(15 * cm, y, f"{ligne.get('montant_ht', 0):.2f}")
y -= 0.6*cm y -= 0.6 * cm
# === TOTAUX === # === TOTAUX ===
y -= 1*cm y -= 1 * cm
pdf.line(12*cm, y, width - 2*cm, y) pdf.line(12 * cm, y, width - 2 * cm, y)
y -= 0.8*cm y -= 0.8 * cm
pdf.setFont("Helvetica-Bold", 11) pdf.setFont("Helvetica-Bold", 11)
pdf.drawString(12*cm, y, "Total HT:") pdf.drawString(12 * cm, y, "Total HT:")
pdf.drawString(15*cm, y, f"{doc.get('total_ht', 0):.2f}") pdf.drawString(15 * cm, y, f"{doc.get('total_ht', 0):.2f}")
y -= 0.6*cm y -= 0.6 * cm
pdf.drawString(12*cm, y, "TVA (20%):") pdf.drawString(12 * cm, y, "TVA (20%):")
tva = doc.get('total_ttc', 0) - doc.get('total_ht', 0) tva = doc.get("total_ttc", 0) - doc.get("total_ht", 0)
pdf.drawString(15*cm, y, f"{tva:.2f}") pdf.drawString(15 * cm, y, f"{tva:.2f}")
y -= 0.6*cm y -= 0.6 * cm
pdf.setFont("Helvetica-Bold", 14) pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(12*cm, y, "Total TTC:") pdf.drawString(12 * cm, y, "Total TTC:")
pdf.drawString(15*cm, y, f"{doc.get('total_ttc', 0):.2f}") pdf.drawString(15 * cm, y, f"{doc.get('total_ttc', 0):.2f}")
# === PIED DE PAGE === # === PIED DE PAGE ===
pdf.setFont("Helvetica", 8) 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(
pdf.drawString(2*cm, 1.5*cm, "Sage 100c - API Dataven") 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 # Finaliser
pdf.save() pdf.save()
buffer.seek(0) buffer.seek(0)
logger.info(f"✅ PDF généré: {doc_id}.pdf") logger.info(f"✅ PDF généré: {doc_id}.pdf")
return buffer.read() return buffer.read()
def _send_smtp(self, msg): def _send_smtp(self, msg):
"""Envoi SMTP bloquant (appelé depuis asyncio.to_thread)""" """Envoi SMTP bloquant (appelé depuis asyncio.to_thread)"""
try: 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: if settings.smtp_use_tls:
server.starttls() server.starttls()
if settings.smtp_user and settings.smtp_password: if settings.smtp_user and settings.smtp_password:
server.login(settings.smtp_user, settings.smtp_password) server.login(settings.smtp_user, settings.smtp_password)
server.send_message(msg) server.send_message(msg)
except smtplib.SMTPException as e: except smtplib.SMTPException as e:
raise Exception(f"Erreur SMTP: {str(e)}") raise Exception(f"Erreur SMTP: {str(e)}")
except Exception as e: except Exception as e:
@ -343,4 +348,4 @@ class EmailQueue:
# Instance globale # Instance globale
email_queue = EmailQueue() email_queue = EmailQueue()

View file

@ -23,35 +23,35 @@ logger = logging.getLogger(__name__)
async def main(): async def main():
"""Crée toutes les tables dans sage_dataven.db""" """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("🚀 Initialisation de la base de données Sage Dataven")
print("="*60 + "\n") print("=" * 60 + "\n")
try: try:
# Créer les tables # Créer les tables
await init_db() await init_db()
print("\n✅ Base de données créée avec succès!") print("\n✅ Base de données créée avec succès!")
print(f"📍 Fichier: sage_dataven.db") print(f"📍 Fichier: sage_dataven.db")
print("\n📊 Tables créées:") print("\n📊 Tables créées:")
print(" ├─ email_logs (Journalisation emails)") print(" ├─ email_logs (Journalisation emails)")
print(" ├─ signature_logs (Suivi signatures Universign)") print(" ├─ signature_logs (Suivi signatures Universign)")
print(" ├─ workflow_logs (Transformations documents)") print(" ├─ workflow_logs (Transformations documents)")
print(" ├─ cache_metadata (Métadonnées cache)") print(" ├─ cache_metadata (Métadonnées cache)")
print(" └─ audit_logs (Journal d'audit)") print(" └─ audit_logs (Journal d'audit)")
print("\n📝 Prochaines étapes:") print("\n📝 Prochaines étapes:")
print(" 1. Configurer le fichier .env avec vos credentials") print(" 1. Configurer le fichier .env avec vos credentials")
print(" 2. Lancer la gateway Windows sur la machine Sage") 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(" 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(" 4. Ou avec Docker: docker-compose up -d")
print(" 5. Tester: http://votre-vps:8000/docs") print(" 5. Tester: http://votre-vps:8000/docs")
print("\n" + "="*60 + "\n") print("\n" + "=" * 60 + "\n")
return True return True
except Exception as e: except Exception as e:
print(f"\n❌ Erreur lors de l'initialisation: {e}") print(f"\n❌ Erreur lors de l'initialisation: {e}")
logger.exception("Détails de l'erreur:") logger.exception("Détails de l'erreur:")
@ -60,4 +60,4 @@ async def main():
if __name__ == "__main__": if __name__ == "__main__":
result = asyncio.run(main()) result = asyncio.run(main())
sys.exit(0 if result else 1) sys.exit(0 if result else 1)

View file

@ -16,7 +16,7 @@ from security.auth import (
decode_token, decode_token,
generate_verification_token, generate_verification_token,
generate_reset_token, generate_reset_token,
hash_token hash_token,
) )
from services.email_service import AuthEmailService from services.email_service import AuthEmailService
from core.dependencies import get_current_user from core.dependencies import get_current_user
@ -29,6 +29,7 @@ router = APIRouter(prefix="/auth", tags=["Authentication"])
# === MODÈLES PYDANTIC === # === MODÈLES PYDANTIC ===
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
password: str = Field(..., min_length=8) password: str = Field(..., min_length=8)
@ -71,13 +72,14 @@ class ResendVerificationRequest(BaseModel):
# === UTILITAIRES === # === UTILITAIRES ===
async def log_login_attempt( async def log_login_attempt(
session: AsyncSession, session: AsyncSession,
email: str, email: str,
ip: str, ip: str,
user_agent: str, user_agent: str,
success: bool, success: bool,
failure_reason: Optional[str] = None failure_reason: Optional[str] = None,
): ):
"""Enregistre une tentative de connexion""" """Enregistre une tentative de connexion"""
attempt = LoginAttempt( attempt = LoginAttempt(
@ -86,76 +88,72 @@ async def log_login_attempt(
user_agent=user_agent, user_agent=user_agent,
success=success, success=success,
failure_reason=failure_reason, failure_reason=failure_reason,
timestamp=datetime.now() timestamp=datetime.now(),
) )
session.add(attempt) session.add(attempt)
await session.commit() 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é Vérifie si l'utilisateur/IP est rate limité
Returns: Returns:
(is_allowed, error_message) (is_allowed, error_message)
""" """
# Vérifier les tentatives échouées des 15 dernières minutes # Vérifier les tentatives échouées des 15 dernières minutes
time_window = datetime.now() - timedelta(minutes=15) time_window = datetime.now() - timedelta(minutes=15)
result = await session.execute( result = await session.execute(
select(LoginAttempt) select(LoginAttempt).where(
.where(
LoginAttempt.email == email, LoginAttempt.email == email,
LoginAttempt.success == False, LoginAttempt.success == False,
LoginAttempt.timestamp >= time_window LoginAttempt.timestamp >= time_window,
) )
) )
failed_attempts = result.scalars().all() failed_attempts = result.scalars().all()
if len(failed_attempts) >= 5: if len(failed_attempts) >= 5:
return False, "Trop de tentatives échouées. Réessayez dans 15 minutes." return False, "Trop de tentatives échouées. Réessayez dans 15 minutes."
return True, "" return True, ""
# === ENDPOINTS === # === ENDPOINTS ===
@router.post("/register", status_code=status.HTTP_201_CREATED) @router.post("/register", status_code=status.HTTP_201_CREATED)
async def register( async def register(
data: RegisterRequest, data: RegisterRequest,
request: Request, request: Request,
session: AsyncSession = Depends(get_session) session: AsyncSession = Depends(get_session),
): ):
""" """
📝 Inscription d'un nouvel utilisateur 📝 Inscription d'un nouvel utilisateur
- Valide le mot de passe - Valide le mot de passe
- Crée le compte (non vérifié) - Crée le compte (non vérifié)
- Envoie email de vérification - Envoie email de vérification
""" """
# Vérifier si l'email existe déjà # Vérifier si l'email existe déjà
result = await session.execute( result = await session.execute(select(User).where(User.email == data.email))
select(User).where(User.email == data.email)
)
existing_user = result.scalar_one_or_none() existing_user = result.scalar_one_or_none()
if existing_user: if existing_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà utilisé"
detail="Cet email est déjà utilisé"
) )
# Valider le mot de passe # Valider le mot de passe
is_valid, error_msg = validate_password_strength(data.password) is_valid, error_msg = validate_password_strength(data.password)
if not is_valid: if not is_valid:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg)
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_msg
)
# Générer token de vérification # Générer token de vérification
verification_token = generate_verification_token() verification_token = generate_verification_token()
# Créer l'utilisateur # Créer l'utilisateur
new_user = User( new_user = User(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@ -166,80 +164,72 @@ async def register(
is_verified=False, is_verified=False,
verification_token=verification_token, verification_token=verification_token,
verification_token_expires=datetime.now() + timedelta(hours=24), verification_token_expires=datetime.now() + timedelta(hours=24),
created_at=datetime.now() created_at=datetime.now(),
) )
session.add(new_user) session.add(new_user)
await session.commit() await session.commit()
# Envoyer email de vérification # 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( email_sent = AuthEmailService.send_verification_email(
data.email, data.email, verification_token, base_url
verification_token,
base_url
) )
if not email_sent: if not email_sent:
logger.warning(f"Échec envoi email vérification pour {data.email}") logger.warning(f"Échec envoi email vérification pour {data.email}")
logger.info(f"✅ Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})") logger.info(f"✅ Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})")
return { return {
"success": True, "success": True,
"message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.", "message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.",
"user_id": new_user.id, "user_id": new_user.id,
"email": data.email "email": data.email,
} }
@router.get("/verify-email") @router.get("/verify-email")
async def verify_email_get( async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)):
token: str,
session: AsyncSession = Depends(get_session)
):
""" """
Vérification de l'email via lien cliquable (GET) Vérification de l'email via lien cliquable (GET)
Utilisé quand l'utilisateur clique sur le lien dans l'email Utilisé quand l'utilisateur clique sur le lien dans l'email
""" """
result = await session.execute( result = await session.execute(select(User).where(User.verification_token == token))
select(User).where(User.verification_token == token)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
return { return {
"success": False, "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 # Vérifier l'expiration
if user.verification_token_expires < datetime.now(): if user.verification_token_expires < datetime.now():
return { return {
"success": False, "success": False,
"message": "Token expiré. Veuillez demander un nouvel email de vérification.", "message": "Token expiré. Veuillez demander un nouvel email de vérification.",
"expired": True "expired": True,
} }
# Activer le compte # Activer le compte
user.is_verified = True user.is_verified = True
user.verification_token = None user.verification_token = None
user.verification_token_expires = None user.verification_token_expires = None
await session.commit() await session.commit()
logger.info(f"✅ Email vérifié: {user.email}") logger.info(f"✅ Email vérifié: {user.email}")
return { return {
"success": True, "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.",
"email": user.email "email": user.email,
} }
@router.post("/verify-email") @router.post("/verify-email")
async def verify_email_post( async def verify_email_post(
data: VerifyEmailRequest, data: VerifyEmailRequest, session: AsyncSession = Depends(get_session)
session: AsyncSession = Depends(get_session)
): ):
""" """
Vérification de l'email via API (POST) 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) select(User).where(User.verification_token == data.token)
) )
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Token de vérification invalide" detail="Token de vérification invalide",
) )
# Vérifier l'expiration # Vérifier l'expiration
if user.verification_token_expires < datetime.now(): if user.verification_token_expires < datetime.now():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Activer le compte
user.is_verified = True user.is_verified = True
user.verification_token = None user.verification_token = None
user.verification_token_expires = None user.verification_token_expires = None
await session.commit() await session.commit()
logger.info(f"✅ Email vérifié: {user.email}") logger.info(f"✅ Email vérifié: {user.email}")
return { return {
"success": True, "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( async def resend_verification(
data: ResendVerificationRequest, data: ResendVerificationRequest,
request: Request, request: Request,
session: AsyncSession = Depends(get_session) session: AsyncSession = Depends(get_session),
): ):
""" """
🔄 Renvoyer l'email de vérification 🔄 Renvoyer l'email de vérification
""" """
result = await session.execute( result = await session.execute(select(User).where(User.email == data.email.lower()))
select(User).where(User.email == data.email.lower())
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
# Ne pas révéler si l'utilisateur existe # Ne pas révéler si l'utilisateur existe
return { return {
"success": True, "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: if user.is_verified:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est déjà vérifié"
detail="Ce compte est déjà vérifié"
) )
# Générer nouveau token # Générer nouveau token
verification_token = generate_verification_token() verification_token = generate_verification_token()
user.verification_token = verification_token user.verification_token = verification_token
user.verification_token_expires = datetime.now() + timedelta(hours=24) user.verification_token_expires = datetime.now() + timedelta(hours=24)
await session.commit() await session.commit()
# Envoyer email # Envoyer email
base_url = str(request.base_url).rstrip('/') base_url = str(request.base_url).rstrip("/")
AuthEmailService.send_verification_email( AuthEmailService.send_verification_email(user.email, verification_token, base_url)
user.email,
verification_token, return {"success": True, "message": "Un nouveau lien de vérification a été envoyé."}
base_url
)
return {
"success": True,
"message": "Un nouveau lien de vérification a été envoyé."
}
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login( async def login(
data: LoginRequest, data: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)
request: Request,
session: AsyncSession = Depends(get_session)
): ):
""" """
🔐 Connexion utilisateur 🔐 Connexion utilisateur
Retourne access_token (30min) et refresh_token (7 jours) Retourne access_token (30min) et refresh_token (7 jours)
""" """
ip = request.client.host if request.client else "unknown" ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent", "unknown") user_agent = request.headers.get("user-agent", "unknown")
# Rate limiting # Rate limiting
is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip) is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip)
if not is_allowed: if not is_allowed:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg
detail=error_msg
) )
# Charger l'utilisateur # Charger l'utilisateur
result = await session.execute( result = await session.execute(select(User).where(User.email == data.email.lower()))
select(User).where(User.email == data.email.lower())
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
# Vérifications # Vérifications
if not user or not verify_password(data.password, user.hashed_password): 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 # Incrémenter compteur échecs
if user: if user:
user.failed_login_attempts += 1 user.failed_login_attempts += 1
# Verrouiller après 5 échecs # Verrouiller après 5 échecs
if user.failed_login_attempts >= 5: if user.failed_login_attempts >= 5:
user.locked_until = datetime.now() + timedelta(minutes=15) user.locked_until = datetime.now() + timedelta(minutes=15)
await session.commit() await session.commit()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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() await session.commit()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect" detail="Email ou mot de passe incorrect",
) )
# Vérifier statut compte # Vérifier statut compte
if not user.is_active: if not user.is_active:
await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte désactivé") await log_login_attempt(
raise HTTPException( session, data.email.lower(), ip, user_agent, False, "Compte désactivé"
status_code=status.HTTP_403_FORBIDDEN,
detail="Compte désactivé"
) )
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé"
)
if not user.is_verified: 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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 # Vérifier verrouillage
if user.locked_until and user.locked_until > datetime.now(): 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Compte temporairement verrouillé" detail="Compte temporairement verrouillé",
) )
# ✅ CONNEXION RÉUSSIE # ✅ CONNEXION RÉUSSIE
# Réinitialiser compteur échecs # Réinitialiser compteur échecs
user.failed_login_attempts = 0 user.failed_login_attempts = 0
user.locked_until = None user.locked_until = None
user.last_login = datetime.now() user.last_login = datetime.now()
# Créer tokens # 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) refresh_token_jwt = create_refresh_token(user.id)
# Stocker refresh token en DB (hashé) # Stocker refresh token en DB (hashé)
refresh_token_record = RefreshToken( refresh_token_record = RefreshToken(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@ -418,28 +407,27 @@ async def login(
device_info=user_agent[:500], device_info=user_agent[:500],
ip_address=ip, ip_address=ip,
expires_at=datetime.now() + timedelta(days=7), expires_at=datetime.now() + timedelta(days=7),
created_at=datetime.now() created_at=datetime.now(),
) )
session.add(refresh_token_record) session.add(refresh_token_record)
await session.commit() await session.commit()
# Logger succès # Logger succès
await log_login_attempt(session, data.email.lower(), ip, user_agent, True) await log_login_attempt(session, data.email.lower(), ip, user_agent, True)
logger.info(f"✅ Connexion réussie: {user.email}") logger.info(f"✅ Connexion réussie: {user.email}")
return TokenResponse( return TokenResponse(
access_token=access_token, access_token=access_token,
refresh_token=refresh_token_jwt, refresh_token=refresh_token_jwt,
expires_in=86400 # 30 minutes expires_in=86400, # 30 minutes
) )
@router.post("/refresh", response_model=TokenResponse) @router.post("/refresh", response_model=TokenResponse)
async def refresh_access_token( async def refresh_access_token(
data: RefreshTokenRequest, data: RefreshTokenRequest, session: AsyncSession = Depends(get_session)
session: AsyncSession = Depends(get_session)
): ):
""" """
🔄 Renouvellement du access_token via refresh_token 🔄 Renouvellement du access_token via refresh_token
@ -448,61 +436,55 @@ async def refresh_access_token(
payload = decode_token(data.refresh_token) payload = decode_token(data.refresh_token)
if not payload or payload.get("type") != "refresh": if not payload or payload.get("type") != "refresh":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token invalide"
detail="Refresh token invalide"
) )
user_id = payload.get("sub") user_id = payload.get("sub")
token_hash = hash_token(data.refresh_token) token_hash = hash_token(data.refresh_token)
# Vérifier en DB # Vérifier en DB
result = await session.execute( result = await session.execute(
select(RefreshToken).where( select(RefreshToken).where(
RefreshToken.user_id == user_id, RefreshToken.user_id == user_id,
RefreshToken.token_hash == token_hash, RefreshToken.token_hash == token_hash,
RefreshToken.is_revoked == False RefreshToken.is_revoked == False,
) )
) )
token_record = result.scalar_one_or_none() token_record = result.scalar_one_or_none()
if not token_record: if not token_record:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token révoqué ou introuvable" detail="Refresh token révoqué ou introuvable",
) )
# Vérifier expiration # Vérifier expiration
if token_record.expires_at < datetime.now(): if token_record.expires_at < datetime.now():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré"
detail="Refresh token expiré"
) )
# Charger utilisateur # Charger utilisateur
result = await session.execute( result = await session.execute(select(User).where(User.id == user_id))
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not user.is_active: if not user or not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Utilisateur introuvable ou désactivé" detail="Utilisateur introuvable ou désactivé",
) )
# Générer nouveau access token # Générer nouveau access token
new_access_token = create_access_token({ new_access_token = create_access_token(
"sub": user.id, {"sub": user.id, "email": user.email, "role": user.role}
"email": user.email, )
"role": user.role
})
logger.info(f"🔄 Token rafraîchi: {user.email}") logger.info(f"🔄 Token rafraîchi: {user.email}")
return TokenResponse( return TokenResponse(
access_token=new_access_token, access_token=new_access_token,
refresh_token=data.refresh_token, # Refresh token reste le même 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( async def forgot_password(
data: ForgotPasswordRequest, data: ForgotPasswordRequest,
request: Request, request: Request,
session: AsyncSession = Depends(get_session) session: AsyncSession = Depends(get_session),
): ):
""" """
🔑 Demande de réinitialisation de mot de passe 🔑 Demande de réinitialisation de mot de passe
""" """
result = await session.execute( result = await session.execute(select(User).where(User.email == data.email.lower()))
select(User).where(User.email == data.email.lower())
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
# Ne pas révéler si l'utilisateur existe # Ne pas révéler si l'utilisateur existe
if not user: if not user:
return { return {
"success": True, "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 # Générer token de reset
reset_token = generate_reset_token() reset_token = generate_reset_token()
user.reset_token = reset_token user.reset_token = reset_token
user.reset_token_expires = datetime.now() + timedelta(hours=1) user.reset_token_expires = datetime.now() + timedelta(hours=1)
await session.commit() await session.commit()
# Envoyer email # Envoyer email
frontend_url = settings.frontend_url if hasattr(settings, 'frontend_url') else str(request.base_url).rstrip('/') frontend_url = (
AuthEmailService.send_password_reset_email( settings.frontend_url
user.email, if hasattr(settings, "frontend_url")
reset_token, else str(request.base_url).rstrip("/")
frontend_url
) )
AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url)
logger.info(f"📧 Reset password demandé: {user.email}") logger.info(f"📧 Reset password demandé: {user.email}")
return { return {
"success": True, "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") @router.post("/reset-password")
async def reset_password( async def reset_password(
data: ResetPasswordRequest, data: ResetPasswordRequest, session: AsyncSession = Depends(get_session)
session: AsyncSession = Depends(get_session)
): ):
""" """
🔐 Réinitialisation du mot de passe avec token 🔐 Réinitialisation du mot de passe avec token
""" """
result = await session.execute( result = await session.execute(select(User).where(User.reset_token == data.token))
select(User).where(User.reset_token == data.token)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Token de réinitialisation invalide" detail="Token de réinitialisation invalide",
) )
# Vérifier expiration # Vérifier expiration
if user.reset_token_expires < datetime.now(): if user.reset_token_expires < datetime.now():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 # Valider nouveau mot de passe
is_valid, error_msg = validate_password_strength(data.new_password) is_valid, error_msg = validate_password_strength(data.new_password)
if not is_valid: if not is_valid:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg)
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_msg
)
# Mettre à jour # Mettre à jour
user.hashed_password = hash_password(data.new_password) user.hashed_password = hash_password(data.new_password)
user.reset_token = None user.reset_token = None
@ -590,15 +564,15 @@ async def reset_password(
user.failed_login_attempts = 0 user.failed_login_attempts = 0
user.locked_until = None user.locked_until = None
await session.commit() await session.commit()
# Envoyer notification # Envoyer notification
AuthEmailService.send_password_changed_notification(user.email) AuthEmailService.send_password_changed_notification(user.email)
logger.info(f"🔐 Mot de passe réinitialisé: {user.email}") logger.info(f"🔐 Mot de passe réinitialisé: {user.email}")
return { return {
"success": True, "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( async def logout(
data: RefreshTokenRequest, data: RefreshTokenRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user) user: User = Depends(get_current_user),
): ):
""" """
🚪 Déconnexion (révocation du refresh token) 🚪 Déconnexion (révocation du refresh token)
""" """
token_hash = hash_token(data.refresh_token) token_hash = hash_token(data.refresh_token)
result = await session.execute( result = await session.execute(
select(RefreshToken).where( select(RefreshToken).where(
RefreshToken.user_id == user.id, RefreshToken.user_id == user.id, RefreshToken.token_hash == token_hash
RefreshToken.token_hash == token_hash
) )
) )
token_record = result.scalar_one_or_none() token_record = result.scalar_one_or_none()
if token_record: if token_record:
token_record.is_revoked = True token_record.is_revoked = True
token_record.revoked_at = datetime.now() token_record.revoked_at = datetime.now()
await session.commit() await session.commit()
logger.info(f"👋 Déconnexion: {user.email}") logger.info(f"👋 Déconnexion: {user.email}")
return { return {"success": True, "message": "Déconnexion réussie"}
"success": True,
"message": "Déconnexion réussie"
}
@router.get("/me") @router.get("/me")
@ -647,5 +617,5 @@ async def get_current_user_info(user: User = Depends(get_current_user)):
"role": user.role, "role": user.role,
"is_verified": user.is_verified, "is_verified": user.is_verified,
"created_at": user.created_at.isoformat(), "created_at": user.created_at.isoformat(),
"last_login": user.last_login.isoformat() if user.last_login else None "last_login": user.last_login.isoformat() if user.last_login else None,
} }

View file

@ -45,24 +45,20 @@ def hash_token(token: str) -> str:
def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str:
""" """
Crée un JWT access token Crée un JWT access token
Args: Args:
data: Payload (doit contenir 'sub' = user_id) data: Payload (doit contenir 'sub' = user_id)
expires_delta: Durée de validité personnalisée expires_delta: Durée de validité personnalisée
""" """
to_encode = data.copy() to_encode = data.copy()
if expires_delta: if expires_delta:
expire = datetime.utcnow() + expires_delta expire = datetime.utcnow() + expires_delta
else: else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({ to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "access"})
"exp": expire,
"iat": datetime.utcnow(),
"type": "access"
})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt 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: def create_refresh_token(user_id: str) -> str:
""" """
Crée un refresh token (JWT long terme) Crée un refresh token (JWT long terme)
Returns: Returns:
Token JWT non hashé (à hasher avant stockage DB) Token JWT non hashé (à hasher avant stockage DB)
""" """
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = { to_encode = {
"sub": user_id, "sub": user_id,
"exp": expire, "exp": expire,
"iat": datetime.utcnow(), "iat": datetime.utcnow(),
"type": "refresh", "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) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt return encoded_jwt
@ -91,7 +87,7 @@ def create_refresh_token(user_id: str) -> str:
def decode_token(token: str) -> Optional[Dict]: def decode_token(token: str) -> Optional[Dict]:
""" """
Décode et valide un JWT Décode et valide un JWT
Returns: Returns:
Payload si valide, None sinon 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]: def validate_password_strength(password: str) -> tuple[bool, str]:
""" """
Valide la robustesse d'un mot de passe Valide la robustesse d'un mot de passe
Returns: Returns:
(is_valid, error_message) (is_valid, error_message)
""" """
if len(password) < 8: if len(password) < 8:
return False, "Le mot de passe doit contenir au moins 8 caractères" return False, "Le mot de passe doit contenir au moins 8 caractères"
if not any(c.isupper() for c in password): if not any(c.isupper() for c in password):
return False, "Le mot de passe doit contenir au moins une majuscule" return False, "Le mot de passe doit contenir au moins une majuscule"
if not any(c.islower() for c in password): if not any(c.islower() for c in password):
return False, "Le mot de passe doit contenir au moins une minuscule" return False, "Le mot de passe doit contenir au moins une minuscule"
if not any(c.isdigit() for c in password): if not any(c.isdigit() for c in password):
return False, "Le mot de passe doit contenir au moins un chiffre" return False, "Le mot de passe doit contenir au moins un chiffre"
special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?" special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
if not any(c in special_chars for c in password): 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 False, "Le mot de passe doit contenir au moins un caractère spécial"
return True, "" return True, ""

View file

@ -9,46 +9,48 @@ logger = logging.getLogger(__name__)
class AuthEmailService: class AuthEmailService:
"""Service d'envoi d'emails pour l'authentification""" """Service d'envoi d'emails pour l'authentification"""
@staticmethod @staticmethod
def _send_email(to: str, subject: str, html_body: str) -> bool: def _send_email(to: str, subject: str, html_body: str) -> bool:
"""Envoi SMTP générique""" """Envoi SMTP générique"""
try: try:
msg = MIMEMultipart() msg = MIMEMultipart()
msg['From'] = settings.smtp_from msg["From"] = settings.smtp_from
msg['To'] = to msg["To"] = to
msg['Subject'] = subject msg["Subject"] = subject
msg.attach(MIMEText(html_body, 'html')) msg.attach(MIMEText(html_body, "html"))
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: if settings.smtp_use_tls:
server.starttls() server.starttls()
if settings.smtp_user and settings.smtp_password: if settings.smtp_user and settings.smtp_password:
server.login(settings.smtp_user, settings.smtp_password) server.login(settings.smtp_user, settings.smtp_password)
server.send_message(msg) server.send_message(msg)
logger.info(f"✅ Email envoyé: {subject}{to}") logger.info(f"✅ Email envoyé: {subject}{to}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"❌ Erreur envoi email: {e}") logger.error(f"❌ Erreur envoi email: {e}")
return False return False
@staticmethod @staticmethod
def send_verification_email(email: str, token: str, base_url: str) -> bool: def send_verification_email(email: str, token: str, base_url: str) -> bool:
""" """
Envoie l'email de vérification avec lien de confirmation Envoie l'email de vérification avec lien de confirmation
Args: Args:
email: Email du destinataire email: Email du destinataire
token: Token de vérification token: Token de vérification
base_url: URL de base de l'API (ex: https://api.votredomaine.com) base_url: URL de base de l'API (ex: https://api.votredomaine.com)
""" """
verification_link = f"{base_url}/auth/verify-email?token={token}" verification_link = f"{base_url}/auth/verify-email?token={token}"
html_body = f""" html_body = f"""
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -103,25 +105,23 @@ class AuthEmailService:
</body> </body>
</html> </html>
""" """
return AuthEmailService._send_email( return AuthEmailService._send_email(
email, email, "🔐 Vérifiez votre adresse email - Sage Dataven", html_body
"🔐 Vérifiez votre adresse email - Sage Dataven",
html_body
) )
@staticmethod @staticmethod
def send_password_reset_email(email: str, token: str, base_url: str) -> bool: def send_password_reset_email(email: str, token: str, base_url: str) -> bool:
""" """
Envoie l'email de réinitialisation de mot de passe Envoie l'email de réinitialisation de mot de passe
Args: Args:
email: Email du destinataire email: Email du destinataire
token: Token de reset token: Token de reset
base_url: URL de base du frontend base_url: URL de base du frontend
""" """
reset_link = f"{base_url}/reset?token={token}" reset_link = f"{base_url}/reset?token={token}"
html_body = f""" html_body = f"""
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -176,13 +176,11 @@ class AuthEmailService:
</body> </body>
</html> </html>
""" """
return AuthEmailService._send_email( return AuthEmailService._send_email(
email, email, "🔐 Réinitialisation de votre mot de passe - Sage Dataven", html_body
"🔐 Réinitialisation de votre mot de passe - Sage Dataven",
html_body
) )
@staticmethod @staticmethod
def send_password_changed_notification(email: str) -> bool: def send_password_changed_notification(email: str) -> bool:
"""Notification après changement de mot de passe réussi""" """Notification après changement de mot de passe réussi"""
@ -218,9 +216,7 @@ class AuthEmailService:
</body> </body>
</html> </html>
""" """
return AuthEmailService._send_email( return AuthEmailService._send_email(
email, email, "✅ Votre mot de passe a été modifié - Sage Dataven", html_body
"✅ Votre mot de passe a été modifié - Sage Dataven", )
html_body
)