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(
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

View file

@ -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
return role_checker

View file

@ -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)
sys.exit(1)

View file

@ -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',
]
"User",
"RefreshToken",
"LoginAttempt",
]

View file

@ -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")
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 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"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>"
# 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"<User {self.email} verified={self.is_verified}>"
@ -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"<RefreshToken user={self.user_id} revoked={self.is_revoked}>"
@ -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"<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
"""
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()
email_queue = EmailQueue()

View file

@ -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)
sys.exit(0 if result else 1)

View file

@ -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
}
"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:
"""
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, ""
return True, ""

View file

@ -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"""
<!DOCTYPE html>
<html>
@ -103,25 +105,23 @@ class AuthEmailService:
</body>
</html>
"""
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"""
<!DOCTYPE html>
<html>
@ -176,13 +176,11 @@ class AuthEmailService:
</body>
</html>
"""
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:
</body>
</html>
"""
return AuthEmailService._send_email(
email,
"✅ Votre mot de passe a été modifié - Sage Dataven",
html_body
)
email, "✅ Votre mot de passe a été modifié - Sage Dataven", html_body
)