feat: implement comprehensive user authentication including registration, login, email verification, password reset, and token management.
This commit is contained in:
parent
e95e550044
commit
4434f0716f
11 changed files with 504 additions and 511 deletions
|
|
@ -12,7 +12,7 @@ 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
|
||||
|
|
@ -51,9 +51,7 @@ async def get_current_user(
|
|||
)
|
||||
|
||||
# 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:
|
||||
|
|
@ -66,21 +64,20 @@ async def get_current_user(
|
|||
# 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
|
||||
|
|
@ -88,7 +85,7 @@ async def get_current_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
|
||||
|
|
@ -112,11 +109,12 @@ def require_role(*allowed_roles: str):
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ async def create_admin():
|
|||
|
||||
# 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
|
||||
|
||||
|
|
@ -45,7 +45,9 @@ async def create_admin():
|
|||
|
||||
# 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:
|
||||
|
|
@ -61,9 +63,7 @@ async def create_admin():
|
|||
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:
|
||||
|
|
@ -80,7 +80,7 @@ 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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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,23 +29,28 @@ 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
|
||||
|
|
@ -79,6 +95,7 @@ class SignatureLog(Base):
|
|||
Journal des demandes de signature Universign
|
||||
Permet le suivi du workflow de signature
|
||||
"""
|
||||
|
||||
__tablename__ = "signature_logs"
|
||||
|
||||
# Identifiant
|
||||
|
|
@ -97,7 +114,9 @@ class SignatureLog(Base):
|
|||
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)
|
||||
|
|
@ -119,6 +138,7 @@ class WorkflowLog(Base):
|
|||
Journal des transformations de documents (Devis → Commande → Facture)
|
||||
Permet la traçabilité du workflow commercial
|
||||
"""
|
||||
|
||||
__tablename__ = "workflow_logs"
|
||||
|
||||
# Identifiant
|
||||
|
|
@ -154,12 +174,15 @@ 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)
|
||||
|
|
@ -179,12 +202,15 @@ 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)
|
||||
|
||||
|
|
@ -203,12 +229,15 @@ class AuditLog(Base):
|
|||
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)
|
||||
|
|
@ -247,6 +276,7 @@ class RefreshToken(Base):
|
|||
"""
|
||||
Tokens de rafraîchissement JWT
|
||||
"""
|
||||
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
|
|
@ -273,6 +303,7 @@ class LoginAttempt(Base):
|
|||
"""
|
||||
Journal des tentatives de connexion (détection bruteforce)
|
||||
"""
|
||||
|
||||
__tablename__ = "login_attempts"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
|
|
|||
|
|
@ -42,9 +42,7 @@ class EmailQueue:
|
|||
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)
|
||||
|
|
@ -139,7 +137,9 @@ class EmailQueue:
|
|||
|
||||
# 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
|
||||
|
|
@ -147,30 +147,31 @@ class EmailQueue:
|
|||
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:
|
||||
|
|
@ -181,15 +182,15 @@ class EmailQueue:
|
|||
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")
|
||||
|
||||
|
|
@ -241,7 +242,7 @@ class EmailQueue:
|
|||
2: "BON DE RETOUR",
|
||||
3: "COMMANDE",
|
||||
4: "PRÉPARATION",
|
||||
5: "FACTURE"
|
||||
5: "FACTURE",
|
||||
}
|
||||
type_label = type_labels.get(type_doc, "DOCUMENT")
|
||||
|
||||
|
|
@ -279,16 +280,16 @@ class EmailQueue:
|
|||
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:
|
||||
pdf.showPage()
|
||||
y = height - 3 * cm
|
||||
pdf.setFont("Helvetica", 9)
|
||||
|
||||
designation = ligne.get('designation', '')[:50]
|
||||
designation = ligne.get("designation", "")[:50]
|
||||
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(15 * cm, y, f"{ligne.get('montant_ht', 0):.2f}€")
|
||||
y -= 0.6 * cm
|
||||
|
|
@ -304,7 +305,7 @@ class EmailQueue:
|
|||
|
||||
y -= 0.6 * cm
|
||||
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}€")
|
||||
|
||||
y -= 0.6 * cm
|
||||
|
|
@ -314,7 +315,9 @@ class EmailQueue:
|
|||
|
||||
# === 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, 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
|
||||
|
|
@ -327,7 +330,9 @@ class EmailQueue:
|
|||
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()
|
||||
|
||||
|
|
|
|||
220
routes/auth.py
220
routes/auth.py
|
|
@ -16,7 +16,7 @@ from security.auth import (
|
|||
decode_token,
|
||||
generate_verification_token,
|
||||
generate_reset_token,
|
||||
hash_token
|
||||
hash_token,
|
||||
)
|
||||
from services.email_service import AuthEmailService
|
||||
from core.dependencies import get_current_user
|
||||
|
|
@ -29,6 +29,7 @@ router = APIRouter(prefix="/auth", tags=["Authentication"])
|
|||
|
||||
# === MODÈLES PYDANTIC ===
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8)
|
||||
|
|
@ -71,13 +72,14 @@ class ResendVerificationRequest(BaseModel):
|
|||
|
||||
# === UTILITAIRES ===
|
||||
|
||||
|
||||
async def log_login_attempt(
|
||||
session: AsyncSession,
|
||||
email: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
success: bool,
|
||||
failure_reason: Optional[str] = None
|
||||
failure_reason: Optional[str] = None,
|
||||
):
|
||||
"""Enregistre une tentative de connexion"""
|
||||
attempt = LoginAttempt(
|
||||
|
|
@ -86,13 +88,15 @@ 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é
|
||||
|
||||
|
|
@ -103,11 +107,10 @@ async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[
|
|||
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()
|
||||
|
|
@ -120,11 +123,12 @@ async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[
|
|||
|
||||
# === 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
|
||||
|
|
@ -134,24 +138,18 @@ async def register(
|
|||
- 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()
|
||||
|
|
@ -166,18 +164,16 @@ 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:
|
||||
|
|
@ -189,28 +185,23 @@ async def register(
|
|||
"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
|
||||
|
|
@ -218,7 +209,7 @@ async def verify_email_get(
|
|||
return {
|
||||
"success": False,
|
||||
"message": "Token expiré. Veuillez demander un nouvel email de vérification.",
|
||||
"expired": True
|
||||
"expired": True,
|
||||
}
|
||||
|
||||
# Activer le compte
|
||||
|
|
@ -232,14 +223,13 @@ async def verify_email_get(
|
|||
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)
|
||||
|
|
@ -253,14 +243,14 @@ async def verify_email_post(
|
|||
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
|
||||
|
|
@ -273,7 +263,7 @@ async def verify_email_post(
|
|||
|
||||
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,27 +271,24 @@ 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
|
||||
|
|
@ -311,24 +298,15 @@ async def resend_verification(
|
|||
await session.commit()
|
||||
|
||||
# Envoyer email
|
||||
base_url = str(request.base_url).rstrip('/')
|
||||
AuthEmailService.send_verification_email(
|
||||
user.email,
|
||||
verification_token,
|
||||
base_url
|
||||
)
|
||||
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é."
|
||||
}
|
||||
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
|
||||
|
|
@ -342,19 +320,23 @@ async def login(
|
|||
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:
|
||||
|
|
@ -366,37 +348,42 @@ async def login(
|
|||
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é")
|
||||
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é"
|
||||
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
|
||||
|
|
@ -407,7 +394,9 @@ async def login(
|
|||
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é)
|
||||
|
|
@ -418,7 +407,7 @@ 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)
|
||||
|
|
@ -432,14 +421,13 @@ async def login(
|
|||
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,8 +436,7 @@ 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")
|
||||
|
|
@ -460,7 +447,7 @@ async def refresh_access_token(
|
|||
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()
|
||||
|
|
@ -468,41 +455,36 @@ async def refresh_access_token(
|
|||
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,21 +492,19 @@ 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
|
||||
|
|
@ -534,54 +514,48 @@ async def forgot_password(
|
|||
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)
|
||||
|
|
@ -598,7 +572,7 @@ async def reset_password(
|
|||
|
||||
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,7 +580,7 @@ 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)
|
||||
|
|
@ -615,8 +589,7 @@ async def logout(
|
|||
|
||||
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()
|
||||
|
|
@ -628,10 +601,7 @@ async def logout(
|
|||
|
||||
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,
|
||||
}
|
||||
|
|
@ -57,11 +57,7 @@ def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -
|
|||
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
|
||||
|
|
@ -81,7 +77,7 @@ def create_refresh_token(user_id: str) -> str:
|
|||
"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)
|
||||
|
|
|
|||
|
|
@ -15,13 +15,15 @@ class AuthEmailService:
|
|||
"""Envoi SMTP générique"""
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = settings.smtp_from
|
||||
msg['To'] = to
|
||||
msg['Subject'] = subject
|
||||
msg["From"] = settings.smtp_from
|
||||
msg["To"] = to
|
||||
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:
|
||||
server.starttls()
|
||||
|
||||
|
|
@ -105,9 +107,7 @@ class AuthEmailService:
|
|||
"""
|
||||
|
||||
return AuthEmailService._send_email(
|
||||
email,
|
||||
"🔐 Vérifiez votre adresse email - Sage Dataven",
|
||||
html_body
|
||||
email, "🔐 Vérifiez votre adresse email - Sage Dataven", html_body
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -178,9 +178,7 @@ class AuthEmailService:
|
|||
"""
|
||||
|
||||
return AuthEmailService._send_email(
|
||||
email,
|
||||
"🔐 Réinitialisation de votre mot de passe - Sage Dataven",
|
||||
html_body
|
||||
email, "🔐 Réinitialisation de votre mot de passe - Sage Dataven", html_body
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -220,7 +218,5 @@ class AuthEmailService:
|
|||
"""
|
||||
|
||||
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
|
||||
)
|
||||
Loading…
Reference in a new issue