feat(auth): politique de complexite du mot de passe et flux de reinitialisation

Remplace la regle de longueur seule (12 caracteres) par une exigence de
composition (8 caracteres minimum, majuscule, minuscule, chiffre, caractere
special), non documentee dans les exigences officielles du projet, par une
regle explicite partagee entre le backend (validateur Pydantic) et le
frontend.

Ajoute un flux "mot de passe oublie" en libre-service, absent jusqu'ici :
jeton a usage unique hache en base (meme principe que les refresh tokens),
expirant a 15 minutes, envoye par email via un service SMTP (aiosmtplib,
Mailpit en dev), avec limitation de debit dediee et reponse generique pour
eviter l'enumeration des comptes.

Closes #87
This commit is contained in:
Johan LEROY
2026-09-17 10:53:58 +02:00
parent 3692d486c6
commit 9161b74874
48 changed files with 1914 additions and 25 deletions
+30 -1
View File
@@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings, get_settings
from app.core.hashing import Argon2Hasher, build_hasher
from app.core.mailer import Mailer, SmtpConfig
from app.core.principal import Principal
from app.core.roles import AccountKind, Role, has_at_least
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
@@ -24,13 +25,15 @@ from app.db.session import get_session
from app.repositories.alert import AlertRepository
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
from app.repositories.password_reset_token import PasswordResetTokenRepository
from app.repositories.reading import ReadingRepository
from app.repositories.recommendation import RecommendationRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository
from app.services.alert import AlertService
from app.services.auth import AuthService, LoginPolicy
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
from app.services.recommendation import RecommendationService
from app.services.sensor import SensorService
from app.services.site import SiteService
@@ -97,11 +100,27 @@ def get_client_ip(request: Request, settings: SettingsDep) -> str | None:
return request.client.host if request.client else None
def get_mailer(settings: SettingsDep) -> Mailer:
return Mailer(
SmtpConfig(
host=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=(
settings.smtp_password.get_secret_value() if settings.smtp_password else None
),
use_tls=settings.smtp_use_tls,
from_address=settings.smtp_from_address,
)
)
def get_auth_service(
session: SessionDep,
settings: SettingsDep,
hasher: Annotated[Argon2Hasher, Depends(get_hasher)],
token_policy: Annotated[TokenPolicy, Depends(get_token_policy)],
mailer: Annotated[Mailer, Depends(get_mailer)],
) -> AuthService:
return AuthService(
users=UserRepository(session),
@@ -118,6 +137,16 @@ def get_auth_service(
max_failures_per_identifier=settings.login_max_failures_per_identifier,
),
refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds),
reset_tokens=PasswordResetTokenRepository(session),
reset_attempts=PasswordResetAttemptRepository(session),
reset_policy=PasswordResetPolicy(
window_seconds=settings.password_reset_window_seconds,
max_requests_per_identifier=settings.password_reset_max_requests_per_identifier,
max_requests_per_ip=settings.password_reset_max_requests_per_ip,
token_ttl=timedelta(seconds=settings.password_reset_ttl_seconds),
frontend_reset_url=settings.frontend_reset_password_url,
),
mailer=mailer,
)
+13
View File
@@ -156,3 +156,16 @@ REPONSE_ORIGINE_REFUSEE: Final[Reponses] = {
"description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).",
},
}
REPONSE_LIMITE: Final[Reponses] = {
429: {
"model": ErrorResponse,
"description": "Trop de demandes sur cette fenêtre glissante.",
"headers": {
"Retry-After": {
"description": "Secondes à attendre avant une nouvelle tentative.",
"schema": {"type": "integer"},
}
},
},
}
+83
View File
@@ -12,6 +12,7 @@ from app.api.deps import (
require_trusted_origin,
)
from app.api.openapi import (
REPONSE_LIMITE,
REPONSE_ORIGINE_REFUSEE,
REPONSE_VALIDATION,
REPONSES_AUTHENTIFIEES,
@@ -21,15 +22,18 @@ from app.api.openapi import (
from app.core.cookies import RefreshCookie, cookie_name
from app.core.logging import get_logger
from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest,
PasswordChangeRequest,
PrincipalResponse,
ResetPasswordRequest,
TokenResponse,
)
from app.schemas.errors import ErrorResponse
from app.services.auth import (
AuthenticatedSession,
InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
RateLimitedError,
SessionRejectedError,
)
@@ -39,6 +43,7 @@ logger = get_logger(__name__)
DETAIL_IDENTIFIANTS = "Identifiants invalides"
DETAIL_SESSION = "Session invalide"
DETAIL_LIEN_RESET = "Lien invalide ou expiré"
REPONSES_LOGIN: Reponses = {
**REPONSE_VALIDATION,
@@ -85,6 +90,20 @@ REPONSES_MOT_DE_PASSE: Reponses = {
},
}
REPONSES_FORGOT_PASSWORD: Reponses = {
**REPONSE_VALIDATION,
**REPONSE_LIMITE,
}
REPONSES_RESET_PASSWORD: Reponses = {
**REPONSE_VALIDATION,
**REPONSE_ORIGINE_REFUSEE,
400: {
"model": ErrorResponse,
"description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).",
},
}
def repond(
response: Response, settings: SettingsDep, session: AuthenticatedSession
@@ -267,3 +286,67 @@ async def change_password(
logger.info("auth.password_changed user_id=%s", principal.id)
return repond(response, settings, session)
@router.post(
"/forgot-password",
status_code=status.HTTP_202_ACCEPTED,
summary="Demande un lien de réinitialisation par email",
responses=REPONSES_FORGOT_PASSWORD,
)
async def forgot_password(
payload: ForgotPasswordRequest,
request: Request,
response: Response,
service: AuthServiceDep,
client_ip: str | None = Depends(get_client_ip),
) -> None:
response.headers["Cache-Control"] = "no-store"
try:
await service.request_password_reset(
email=payload.email,
client_ip=client_ip,
user_agent=request.headers.get("user-agent"),
)
except RateLimitedError as erreur:
logger.warning("auth.password_reset.rate_limited ip=%s", client_ip)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Trop de demandes, réessayez plus tard",
headers={"Retry-After": str(erreur.retry_after)},
) from erreur
@router.post(
"/reset-password",
response_model=TokenResponse,
summary="Choisit un nouveau mot de passe depuis un lien reçu par email",
dependencies=[Depends(require_trusted_origin)],
responses=REPONSES_RESET_PASSWORD,
)
async def reset_password(
payload: ResetPasswordRequest,
request: Request,
response: Response,
settings: SettingsDep,
service: AuthServiceDep,
client_ip: str | None = Depends(get_client_ip),
) -> TokenResponse:
response.headers["Cache-Control"] = "no-store"
try:
session = await service.confirm_password_reset(
token=payload.token,
new_password=payload.new_password,
client_ip=client_ip,
user_agent=request.headers.get("user-agent"),
)
except InvalidOrExpiredResetTokenError as erreur:
logger.warning("auth.password_reset.invalid_token ip=%s", client_ip)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=DETAIL_LIEN_RESET
) from erreur
logger.info("auth.password_reset.success user_id=%s", session.principal.id)
return repond(response, settings, session)
+27 -4
View File
@@ -9,6 +9,7 @@ import argparse
import asyncio
import json
import secrets
import string
import sys
from getpass import getpass
from pathlib import Path
@@ -22,9 +23,10 @@ from app.core.roles import Role
from app.db.session import get_session_factory
from app.main import create_app
from app.repositories.user import UserRepository
from app.schemas.auth import PASSWORD_MIN_LENGTH, valide_complexite
LONGUEUR_MOT_DE_PASSE_GENERE = 24
LONGUEUR_MINIMALE = 12
CARACTERES_SPECIAUX = "!@#$%^&*()-_=+[]{};:,.?"
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
@@ -111,15 +113,36 @@ def build_parser() -> argparse.ArgumentParser:
return parser
def genere_mot_de_passe() -> str:
tirage = secrets.SystemRandom()
classes = [
string.ascii_uppercase,
string.ascii_lowercase,
string.digits,
CARACTERES_SPECIAUX,
]
reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes)
caracteres = [tirage.choice(classe) for classe in classes]
caracteres += [tirage.choice("".join(classes)) for _ in range(reste)]
tirage.shuffle(caracteres)
return "".join(caracteres)
def read_password(*, generate: bool) -> str:
if generate:
mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE)
mot_de_passe = genere_mot_de_passe()
print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}")
return mot_de_passe
mot_de_passe = getpass("Mot de passe : ")
if len(mot_de_passe) < LONGUEUR_MINIMALE:
raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères")
if len(mot_de_passe) < PASSWORD_MIN_LENGTH:
raise SystemExit(
f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères"
)
try:
valide_complexite(mot_de_passe)
except ValueError as erreur:
raise SystemExit(str(erreur)) from erreur
if mot_de_passe != getpass("Confirmation : "):
raise SystemExit("Les deux saisies diffèrent")
return mot_de_passe
+13
View File
@@ -54,6 +54,19 @@ class Settings(BaseSettings):
login_max_failures_per_ip: int = Field(default=20, ge=1)
login_max_failures_per_identifier: int = Field(default=50, ge=1)
password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600)
password_reset_window_seconds: int = Field(default=900, ge=60)
password_reset_max_requests_per_identifier: int = Field(default=3, ge=1)
password_reset_max_requests_per_ip: int = Field(default=10, ge=1)
smtp_host: str = "localhost"
smtp_port: int = Field(default=587, ge=1, le=65535)
smtp_username: str | None = None
smtp_password: SecretStr | None = None
smtp_use_tls: bool = False
smtp_from_address: str = "no-reply@enervision.fr"
frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105
trust_proxy_headers: bool = False
expose_api_docs: bool | None = None
metrics_token: SecretStr | None = None
+48
View File
@@ -0,0 +1,48 @@
# Piège : l'URL de réinitialisation porte le jeton en clair. Ne jamais la journaliser :
# `send_password_reset_email()` ne logue que le destinataire, jamais `reset_url`.
from dataclasses import dataclass
from email.message import EmailMessage
import aiosmtplib
from app.core.logging import get_logger
logger = get_logger(__name__)
@dataclass(frozen=True, slots=True)
class SmtpConfig:
host: str
port: int
username: str | None
password: str | None
use_tls: bool
from_address: str
class Mailer:
def __init__(self, config: SmtpConfig) -> None:
self._config = config
async def send_password_reset_email(self, *, to: str, reset_url: str) -> None:
message = EmailMessage()
message["From"] = self._config.from_address
message["To"] = to
message["Subject"] = "Réinitialisation de votre mot de passe EnerVision"
message.set_content(
"Une réinitialisation de mot de passe a été demandée pour ce compte.\n\n"
f"Ouvrez ce lien dans les 15 minutes pour choisir un nouveau mot de passe : "
f"{reset_url}\n\n"
"Si vous n'êtes pas à l'origine de cette demande, ignorez cet email."
)
_, message_recu = await aiosmtplib.send(
message,
hostname=self._config.host,
port=self._config.port,
username=self._config.username,
password=self._config.password,
use_tls=self._config.use_tls,
)
logger.info("mailer.password_reset_sent to=%s smtp_response=%s", to, message_recu)
+4
View File
@@ -4,6 +4,8 @@
from app.models.audit_log import AuditLog
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
from app.models.login_attempt import LoginAttempt
from app.models.password_reset_attempt import PasswordResetAttempt
from app.models.password_reset_token import PasswordResetToken
from app.models.refresh_token import RefreshToken
from app.models.user import AppUser
@@ -13,6 +15,8 @@ __all__ = [
"AuditLog",
"Dataset",
"LoginAttempt",
"PasswordResetAttempt",
"PasswordResetToken",
"Prediction",
"Reading",
"Recommendation",
+2
View File
@@ -29,6 +29,8 @@ class AuditAction(StrEnum):
COMPTE_ACTIVE = "user.enabled"
COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin"
COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed"
MOT_DE_PASSE_OUBLIE_DEMANDE = "auth.password_reset_requested"
MOT_DE_PASSE_REINITIALISE_PAR_SOI = "auth.password_reset_self_service"
REFRESH_REUTILISE = "auth.refresh_reuse_detected"
SESSIONS_REVOQUEES = "auth.all_sessions_revoked"
LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled"
@@ -0,0 +1,27 @@
# Pourquoi : même séparation que `login_attempt` par rapport à `audit_log` : ce compteur est
# piloté par l'attaquant (une campagne de demandes) et se purge, l'audit log est en ajout seul.
# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 apprendrait
# qu'un compte existe.
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Identity, Index, String, func
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PasswordResetAttempt(Base):
__tablename__ = "password_reset_attempt"
__table_args__ = (
Index("ix_password_reset_attempt_email_date", "email_tried", "occurred_at"),
Index("ix_password_reset_attempt_ip_date", "client_ip", "occurred_at"),
)
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
email_tried: Mapped[str] = mapped_column(String(320), nullable=False)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
@@ -0,0 +1,40 @@
# Pourquoi : même schéma que `refresh_token` (chaîne opaque, jamais un JWT) pour la même
# raison : un jeton de réinitialisation doit être révocable d'un coup, et un JWT ne figure
# dans aucune ligne à invalider.
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, Text, func
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PasswordResetToken(Base):
__tablename__ = "password_reset_token"
__table_args__ = (
Index("ix_password_reset_token_user", "user_id"),
Index(
"ix_password_reset_token_vivants",
"user_id",
postgresql_where="consumed_at is null",
),
)
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()
)
user_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, unique=True)
issued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -0,0 +1,42 @@
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.password_reset_attempt import PasswordResetAttempt
@dataclass(frozen=True, slots=True)
class ResetRequestCounts:
per_identifier: int
per_ip: int
class PasswordResetAttemptRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def record(self, *, email: str, client_ip: str | None) -> None:
self._session.add(
PasswordResetAttempt(email_tried=email.strip().lower(), client_ip=client_ip)
)
async def count_recent(
self, *, email: str, client_ip: str | None, window_seconds: int
) -> ResetRequestCounts:
identifiant = email.strip().lower()
meme_email = PasswordResetAttempt.email_tried == identifiant
meme_ip = PasswordResetAttempt.client_ip == client_ip
requete = select(
func.count().filter(meme_email),
func.count().filter(meme_ip),
).where(
PasswordResetAttempt.occurred_at
> datetime.now(UTC) - timedelta(seconds=window_seconds),
meme_email | meme_ip,
)
par_identifiant, par_ip = (await self._session.execute(requete)).one()
return ResetRequestCounts(per_identifier=par_identifiant, per_ip=par_ip)
@@ -0,0 +1,68 @@
# Piège : `consume()` est une seule instruction, sur le modèle de `claim_for_rotation()` du
# jeton de rafraîchissement. Un SELECT puis un UPDATE laisseraient une fenêtre où deux
# soumissions concurrentes du même lien réussiraient toutes les deux.
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
from sqlalchemy import func, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.password_reset_token import PasswordResetToken
@dataclass(frozen=True, slots=True)
class ConsumedResetToken:
id: UUID
user_id: UUID
class PasswordResetTokenRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def create(
self,
*,
user_id: UUID,
token_hash: bytes,
expires_at: datetime,
client_ip: str | None,
user_agent: str | None,
) -> PasswordResetToken:
jeton = PasswordResetToken(
user_id=user_id,
token_hash=token_hash,
expires_at=expires_at,
client_ip=client_ip,
user_agent=user_agent,
)
self._session.add(jeton)
await self._session.flush()
return jeton
async def consume(self, token_hash: bytes) -> ConsumedResetToken | None:
requete = (
update(PasswordResetToken)
.where(
PasswordResetToken.token_hash == token_hash,
PasswordResetToken.consumed_at.is_(None),
PasswordResetToken.expires_at > func.clock_timestamp(),
)
.values(consumed_at=func.clock_timestamp())
.returning(PasswordResetToken.id, PasswordResetToken.user_id)
)
ligne = (await self._session.execute(requete)).one_or_none()
if ligne is None:
return None
return ConsumedResetToken(id=ligne.id, user_id=ligne.user_id)
async def invalidate_all_for_user(self, user_id: UUID) -> int:
resultat = await self._session.execute(
update(PasswordResetToken)
.where(PasswordResetToken.user_id == user_id, PasswordResetToken.consumed_at.is_(None))
.values(consumed_at=func.clock_timestamp())
.returning(PasswordResetToken.id)
)
return len(resultat.all())
+43 -2
View File
@@ -1,17 +1,39 @@
# Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix
# mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur.
import re
from typing import Literal, Self
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
PASSWORD_MIN_LENGTH = 12
PASSWORD_MIN_LENGTH = 8
PASSWORD_MAX_LENGTH = 128
_MAJUSCULE = re.compile(r"[A-ZÀ-Ý]")
_MINUSCULE = re.compile(r"[a-zà-ÿ]")
_CHIFFRE = re.compile(r"\d")
_SPECIAL = re.compile(r"[^\w\s]")
def valide_complexite(mot_de_passe: str) -> str:
manquants = [
nom
for nom, motif in (
("une majuscule", _MAJUSCULE),
("une minuscule", _MINUSCULE),
("un chiffre", _CHIFFRE),
("un caractère spécial", _SPECIAL),
)
if not motif.search(mot_de_passe)
]
if manquants:
raise ValueError(f"Le mot de passe doit contenir au moins {', '.join(manquants)}")
return mot_de_passe
class LoginRequest(BaseModel):
email: EmailStr
@@ -22,6 +44,25 @@ class PasswordChangeRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH)
new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH)
@field_validator("new_password")
@classmethod
def _new_password_est_complexe(cls, valeur: str) -> str:
return valide_complexite(valeur)
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str = Field(min_length=1)
new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH)
@field_validator("new_password")
@classmethod
def _new_password_est_complexe(cls, valeur: str) -> str:
return valide_complexite(valeur)
class PrincipalResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
+110
View File
@@ -15,6 +15,7 @@ from typing import NoReturn, Protocol
from uuid import UUID, uuid4
from app.core.hashing import Argon2Hasher
from app.core.mailer import Mailer
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.core.security import (
@@ -28,6 +29,8 @@ from app.models.login_attempt import LoginOutcome
from app.models.refresh_token import RevocationReason
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
from app.repositories.password_reset_token import PasswordResetTokenRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.user import UserRepository
@@ -54,6 +57,10 @@ class RateLimitedError(AuthError):
self.retry_after = retry_after
class InvalidOrExpiredResetTokenError(AuthError):
pass
@dataclass(frozen=True, slots=True)
class LoginPolicy:
window_seconds: int
@@ -62,6 +69,15 @@ class LoginPolicy:
max_failures_per_identifier: int
@dataclass(frozen=True, slots=True)
class PasswordResetPolicy:
window_seconds: int
max_requests_per_identifier: int
max_requests_per_ip: int
token_ttl: timedelta
frontend_reset_url: str
@dataclass(frozen=True, slots=True)
class AuthenticatedSession:
principal: Principal
@@ -83,6 +99,10 @@ class AuthService:
token_policy: TokenPolicy,
login_policy: LoginPolicy,
refresh_ttl: timedelta,
reset_tokens: PasswordResetTokenRepository,
reset_attempts: PasswordResetAttemptRepository,
reset_policy: PasswordResetPolicy,
mailer: Mailer,
) -> None:
self._users = users
self._attempts = attempts
@@ -93,6 +113,10 @@ class AuthService:
self._token_policy = token_policy
self._login_policy = login_policy
self._refresh_ttl = refresh_ttl
self._reset_tokens = reset_tokens
self._reset_attempts = reset_attempts
self._reset_policy = reset_policy
self._mailer = mailer
async def authenticate(
self, *, email: str, password: str, client_ip: str | None, user_agent: str | None
@@ -200,6 +224,75 @@ class AuthService:
rafraichi = await self._users.get_by_id(principal.id)
return self._session(self._en_principal(rafraichi or compte), secret)
async def request_password_reset(
self, *, email: str, client_ip: str | None, user_agent: str | None
) -> None:
await self._refuse_si_limite_reset(email=email, client_ip=client_ip)
compte = await self._users.get_by_email(email)
# Piège : le hachage factice équilibre le temps de réponse sur un compte inconnu, comme
# `authenticate()`. La réponse et sa forme restent identiques dans tous les cas : compte
# inconnu, compte inactif, ou email envoyé avec succès.
if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value:
await self._hasher.verify_dummy()
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._transaction.commit()
return
await self._reset_tokens.invalidate_all_for_user(compte.id)
secret = generate_refresh_secret()
await self._reset_tokens.create(
user_id=compte.id,
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + self._reset_policy.token_ttl,
client_ip=client_ip,
user_agent=user_agent,
)
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._audit.record(
action=AuditAction.MOT_DE_PASSE_OUBLIE_DEMANDE,
actor_label=compte.email,
target_type="app_user",
target_id=str(compte.id),
client_ip=client_ip,
user_agent=user_agent,
)
await self._transaction.commit()
lien = f"{self._reset_policy.frontend_reset_url}?token={secret}"
await self._mailer.send_password_reset_email(to=compte.email, reset_url=lien)
async def confirm_password_reset(
self, *, token: str, new_password: str, client_ip: str | None, user_agent: str | None
) -> AuthenticatedSession:
revendique = await self._reset_tokens.consume(fingerprint_refresh(token))
if revendique is None:
raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
await self._users.update_password(
revendique.user_id, await self._hasher.hash(new_password), must_change_password=False
)
revoquees = await self._refresh.revoke_all_for_user(
revendique.user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE
)
secret = await self._ouvre_une_famille(
user_id=revendique.user_id, client_ip=client_ip, user_agent=user_agent
)
await self._audit.record(
action=AuditAction.MOT_DE_PASSE_REINITIALISE_PAR_SOI,
target_type="app_user",
target_id=str(revendique.user_id),
client_ip=client_ip,
user_agent=user_agent,
detail={"sessions_revoquees": revoquees},
)
await self._transaction.commit()
compte = await self._users.get_by_id(revendique.user_id)
if compte is None:
raise SessionRejectedError("Compte introuvable")
return self._session(self._en_principal(compte), secret)
async def logout_all(self, principal: Principal) -> int:
revoquees = await self._refresh.revoke_all_for_user(
principal.id, RevocationReason.DECONNEXION
@@ -307,6 +400,23 @@ class AuthService:
await self._transaction.commit()
raise RateLimitedError(politique.window_seconds)
async def _refuse_si_limite_reset(self, *, email: str, client_ip: str | None) -> None:
politique = self._reset_policy
compteurs = await self._reset_attempts.count_recent(
email=email, client_ip=client_ip, window_seconds=politique.window_seconds
)
depasse = (
compteurs.per_identifier >= politique.max_requests_per_identifier
or compteurs.per_ip >= politique.max_requests_per_ip
)
if not depasse:
return
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._transaction.commit()
raise RateLimitedError(politique.window_seconds)
async def _echoue(
self,
email: str,