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

@ -7,7 +7,7 @@ class Settings(BaseSettings):
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,7 +12,7 @@ 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
@ -51,9 +51,7 @@ async def get_current_user(
) )
# 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:
@ -66,21 +64,20 @@ async def get_current_user(
# 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
@ -88,7 +85,7 @@ async def get_current_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
@ -112,11 +109,12 @@ def require_role(*allowed_roles: str):
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

View file

@ -26,13 +26,13 @@ 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
@ -45,7 +45,9 @@ async def create_admin():
# 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:
@ -61,9 +63,7 @@ async def create_admin():
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:
@ -80,7 +80,7 @@ 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)

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

@ -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,23 +29,28 @@ 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
@ -79,6 +95,7 @@ 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
@ -97,7 +114,9 @@ class SignatureLog(Base):
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)
@ -119,6 +138,7 @@ 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
@ -154,12 +174,15 @@ 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)
@ -179,12 +202,15 @@ 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)
@ -203,12 +229,15 @@ class AuditLog(Base):
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)
@ -247,6 +276,7 @@ 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)
@ -273,6 +303,7 @@ 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)

View file

@ -42,9 +42,7 @@ class EmailQueue:
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)
@ -139,7 +137,9 @@ class EmailQueue:
# 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
@ -147,30 +147,31 @@ class EmailQueue:
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:
@ -181,15 +182,15 @@ class EmailQueue:
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")
@ -232,7 +233,7 @@ class EmailQueue:
# === 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 = {
@ -241,81 +242,83 @@ 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()
@ -327,7 +330,9 @@ class EmailQueue:
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()

View file

@ -24,9 +24,9 @@ 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
@ -49,7 +49,7 @@ async def main():
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:

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,13 +88,15 @@ 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é
@ -103,11 +107,10 @@ async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[
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()
@ -120,11 +123,12 @@ async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[
# === 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
@ -134,24 +138,18 @@ async def register(
- 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()
@ -166,18 +164,16 @@ 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:
@ -189,28 +185,23 @@ async def register(
"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
@ -218,7 +209,7 @@ async def verify_email_get(
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
@ -232,14 +223,13 @@ async def verify_email_get(
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)
@ -253,14 +243,14 @@ async def verify_email_post(
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
@ -273,7 +263,7 @@ async def verify_email_post(
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,27 +271,24 @@ 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
@ -311,24 +298,15 @@ async def resend_verification(
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,
base_url
)
return { return {"success": True, "message": "Un nouveau lien de vérification a été envoyé."}
"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
@ -342,19 +320,23 @@ async def login(
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:
@ -366,37 +348,42 @@ async def login(
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(
session, data.email.lower(), ip, user_agent, False, "Compte désactivé"
)
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:
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
@ -407,7 +394,9 @@ async def login(
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é)
@ -418,7 +407,7 @@ 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)
@ -432,14 +421,13 @@ async def login(
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,8 +436,7 @@ 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")
@ -460,7 +447,7 @@ async def refresh_access_token(
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()
@ -468,41 +455,36 @@ async def refresh_access_token(
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,21 +492,19 @@ 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
@ -534,54 +514,48 @@ async def forgot_password(
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)
@ -598,7 +572,7 @@ async def reset_password(
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,7 +580,7 @@ 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)
@ -615,8 +589,7 @@ async def logout(
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()
@ -628,10 +601,7 @@ async def logout(
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

@ -57,11 +57,7 @@ def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -
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
@ -81,7 +77,7 @@ def create_refresh_token(user_id: str) -> str:
"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)

View file

@ -15,13 +15,15 @@ class AuthEmailService:
"""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()
@ -105,9 +107,7 @@ class AuthEmailService:
""" """
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
@ -178,9 +178,7 @@ class AuthEmailService:
""" """
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
@ -220,7 +218,5 @@ class AuthEmailService:
""" """
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
) )