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
+100
View File
@@ -11,6 +11,7 @@ from app.core.roles import AccountKind, Role
from app.services.auth import (
AuthenticatedSession,
InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
RateLimitedError,
SessionRejectedError,
)
@@ -36,6 +37,14 @@ class FauxService:
async def logout(self, **_: object) -> None:
return None
async def request_password_reset(self, **_: object) -> None:
if self._erreur is not None:
raise self._erreur
return None
async def confirm_password_reset(self, **_: object) -> AuthenticatedSession:
return await self.authenticate()
async def authenticate(self, **_: object) -> AuthenticatedSession:
if self._erreur is not None:
raise self._erreur
@@ -206,3 +215,94 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin(
response = await client.post("/api/v1/auth/logout")
assert response.status_code != 403
async def test_forgot_password_answers_202_when_the_account_exists(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"}
)
assert response.status_code == 202
assert response.headers["cache-control"] == "no-store"
async def test_forgot_password_answers_202_identically_when_the_account_is_unknown(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/forgot-password", json={"email": "inconnu@enervision.fr"}
)
assert response.status_code == 202
async def test_forgot_password_returns_429_with_a_retry_after_when_the_rate_limit_is_reached(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
fake_auth_service[0] = RateLimitedError(900)
response = await client.post(
"/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"}
)
assert response.status_code == 429
assert response.headers["retry-after"] == "900"
async def test_forgot_password_rejects_a_malformed_email(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post("/api/v1/auth/forgot-password", json={"email": "pas-un-email"})
assert response.status_code == 422
async def test_reset_password_returns_the_token_and_the_cookie_on_success(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/reset-password",
json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"},
)
assert response.status_code == 200
assert response.cookies.get("ev_refresh") is not None
assert "refresh_secret" not in response.text
async def test_reset_password_rejects_an_invalid_or_expired_token(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
fake_auth_service[0] = InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
response = await client.post(
"/api/v1/auth/reset-password",
json={"token": "un-secret-perime", "new_password": "Un-nouveau-mot-de-passe1!"},
)
assert response.status_code == 400
async def test_reset_password_rejects_a_weak_password(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/reset-password",
json={"token": "un-secret-opaque", "new_password": "trop-simple"},
)
assert response.status_code == 422
async def test_reset_password_refuses_a_foreign_origin(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/reset-password",
json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"},
headers={"Origin": "https://malveillant.example"},
)
assert response.status_code == 403
@@ -18,6 +18,10 @@ ROUTES_PUBLIQUES = frozenset(
("POST", "/api/v1/auth/login"),
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
("POST", "/api/v1/auth/logout"),
("POST", "/api/v1/auth/forgot-password"),
# Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune
# authentification préalable ne s'applique, c'est la validité du jeton qui tranche.
("POST", "/api/v1/auth/reset-password"),
("GET", "/metrics"),
}
)
@@ -0,0 +1,114 @@
# Le premier test démontre l'atomicité de `consume()` : sur un double, deux soumissions
# concurrentes du même lien réussiraient toutes les deux.
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.roles import Role
from app.core.security import fingerprint_refresh, generate_refresh_secret
from app.repositories.password_reset_token import PasswordResetTokenRepository
from app.repositories.user import UserRepository
pytestmark = pytest.mark.integration
DUREE = timedelta(minutes=15)
async def un_compte(session: AsyncSession) -> uuid.UUID:
compte = await UserRepository(session).create(
email=f"reset-{uuid.uuid4().hex[:12]}@enervision.fr",
password_hash="$argon2id$x",
role=Role.LECTEUR,
)
return compte.id
async def un_jeton(
depot: PasswordResetTokenRepository, user_id: uuid.UUID, *, duree: timedelta = DUREE
) -> str:
secret = generate_refresh_secret()
await depot.create(
user_id=user_id,
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + duree,
client_ip="203.0.113.10",
user_agent="pytest",
)
return secret
async def test_consume_only_succeeds_once(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
secret = await un_jeton(depot, await un_compte(session))
premier = await depot.consume(fingerprint_refresh(secret))
second = await depot.consume(fingerprint_refresh(secret))
await session.rollback()
assert premier is not None
assert second is None
async def test_consume_refuses_an_expired_token(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1))
revendique = await depot.consume(fingerprint_refresh(secret))
await session.rollback()
assert revendique is None
async def test_consume_returns_nothing_for_an_unknown_fingerprint(
session: AsyncSession,
) -> None:
revendique = await PasswordResetTokenRepository(session).consume(
fingerprint_refresh(generate_refresh_secret())
)
assert revendique is None
async def test_invalidate_all_for_user_only_touches_living_tokens(
session: AsyncSession,
) -> None:
depot = PasswordResetTokenRepository(session)
compte = await un_compte(session)
await un_jeton(depot, compte)
await un_jeton(depot, compte)
invalides = await depot.invalidate_all_for_user(compte)
second_passage = await depot.invalidate_all_for_user(compte)
await session.rollback()
assert invalides == 2
assert second_passage == 0
async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
session: AsyncSession,
) -> None:
depot = PasswordResetTokenRepository(session)
compte = await un_compte(session)
secret = generate_refresh_secret()
await depot.create(
user_id=compte,
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + DUREE,
client_ip=None,
user_agent=None,
)
with pytest.raises(IntegrityError):
await depot.create(
user_id=compte,
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + DUREE,
client_ip=None,
user_agent=None,
)
await session.rollback()
+41
View File
@@ -0,0 +1,41 @@
import pytest
from pydantic import ValidationError
from app.schemas.auth import PasswordChangeRequest, valide_complexite
MOT_DE_PASSE_VALIDE = "Un-mot-de-passe1!"
def test_password_change_request_accepts_a_password_covering_the_four_classes() -> None:
requete = PasswordChangeRequest(
current_password="peu-importe", new_password=MOT_DE_PASSE_VALIDE
)
assert requete.new_password == MOT_DE_PASSE_VALIDE
@pytest.mark.parametrize(
"new_password",
[
"un-mot-de-passe1!",
"UN-MOT-DE-PASSE1!",
"Un-mot-de-passe!",
"Un mot de passe 1",
],
ids=["sans_majuscule", "sans_minuscule", "sans_chiffre", "sans_caractere_special"],
)
def test_password_change_request_rejects_a_password_missing_a_character_class(
new_password: str,
) -> None:
with pytest.raises(ValidationError):
PasswordChangeRequest(current_password="peu-importe", new_password=new_password)
def test_password_change_request_rejects_a_password_below_the_minimum_length() -> None:
with pytest.raises(ValidationError):
PasswordChangeRequest(current_password="peu-importe", new_password="Ab1!")
def test_valide_complexite_names_every_missing_class_in_the_error() -> None:
with pytest.raises(ValueError, match=r"majuscule.*chiffre|chiffre.*majuscule"):
valide_complexite("minuscules-seulement")
+157 -1
View File
@@ -16,11 +16,15 @@ from app.core.security import (
from app.models.login_attempt import LoginOutcome
from app.models.refresh_token import RevocationReason
from app.repositories.login_attempt import FailureCounts
from app.repositories.password_reset_attempt import ResetRequestCounts
from app.repositories.password_reset_token import ConsumedResetToken
from app.repositories.refresh_token import ClaimedToken
from app.services.auth import (
AuthService,
InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
LoginPolicy,
PasswordResetPolicy,
RateLimitedError,
SessionRejectedError,
)
@@ -37,6 +41,13 @@ POLITIQUE_CONNEXION = LoginPolicy(
max_failures_per_ip=20,
max_failures_per_identifier=50,
)
POLITIQUE_RESET = PasswordResetPolicy(
window_seconds=900,
max_requests_per_identifier=3,
max_requests_per_ip=10,
token_ttl=timedelta(minutes=15),
frontend_reset_url="http://localhost:4200/reset-password",
)
@dataclass
@@ -168,6 +179,43 @@ class FausseTransaction:
self.validations += 1
class FauxDepotJetonsReset:
def __init__(self, revendique: ConsumedResetToken | None = None) -> None:
self.revendique = revendique
self.crees: list[UUID] = []
self.invalidations: list[UUID] = []
async def create(self, *, user_id: UUID, **_: object) -> None:
self.crees.append(user_id)
async def consume(self, token_hash: bytes) -> ConsumedResetToken | None:
return self.revendique
async def invalidate_all_for_user(self, user_id: UUID) -> int:
self.invalidations.append(user_id)
return len(self.invalidations)
class FauxDepotTentativesReset:
def __init__(self, compteurs: ResetRequestCounts | None = None) -> None:
self.compteurs = compteurs or ResetRequestCounts(0, 0)
self.enregistrees: list[str] = []
async def count_recent(self, **_: object) -> ResetRequestCounts:
return self.compteurs
async def record(self, *, email: str, **_: object) -> None:
self.enregistrees.append(email)
class FauxMailer:
def __init__(self) -> None:
self.envois: list[tuple[str, str]] = []
async def send_password_reset_email(self, *, to: str, reset_url: str) -> None:
self.envois.append((to, reset_url))
@dataclass
class Attirail:
service: AuthService
@@ -176,6 +224,9 @@ class Attirail:
jetons: FauxDepotJetons
audit: FauxDepotAudit
hacheur: FauxHacheur
jetons_reset: FauxDepotJetonsReset
tentatives_reset: FauxDepotTentativesReset
mailer: FauxMailer
def fabrique_service(
@@ -184,12 +235,17 @@ def fabrique_service(
compteurs: FailureCounts | None = None,
hacheur: FauxHacheur | None = None,
jetons: FauxDepotJetons | None = None,
jetons_reset: FauxDepotJetonsReset | None = None,
compteurs_reset: ResetRequestCounts | None = None,
) -> Attirail:
comptes = FauxDepotComptes(compte)
tentatives = FauxDepotTentatives(compteurs)
depot_jetons = jetons or FauxDepotJetons()
audit = FauxDepotAudit()
hacheur = hacheur or FauxHacheur()
depot_jetons_reset = jetons_reset or FauxDepotJetonsReset()
tentatives_reset = FauxDepotTentativesReset(compteurs_reset)
mailer = FauxMailer()
service = AuthService(
users=comptes, # type: ignore[arg-type]
attempts=tentatives, # type: ignore[arg-type]
@@ -200,8 +256,22 @@ def fabrique_service(
token_policy=POLITIQUE_JETON,
login_policy=POLITIQUE_CONNEXION,
refresh_ttl=timedelta(days=7),
reset_tokens=depot_jetons_reset, # type: ignore[arg-type]
reset_attempts=tentatives_reset, # type: ignore[arg-type]
reset_policy=POLITIQUE_RESET,
mailer=mailer, # type: ignore[arg-type]
)
return Attirail(
service,
comptes,
tentatives,
depot_jetons,
audit,
hacheur,
depot_jetons_reset,
tentatives_reset,
mailer,
)
return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur)
async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object:
@@ -493,3 +563,89 @@ async def test_change_password_refuses_a_wrong_current_password() -> None:
assert attirail.jetons.revocations_par_compte == []
assert attirail.jetons.crees == []
async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None:
compte = FauxCompte()
attirail = fabrique_service(compte=compte)
await attirail.service.request_password_reset(
email=compte.email, client_ip="203.0.113.10", user_agent="pytest"
)
assert attirail.jetons_reset.invalidations == [compte.id]
assert attirail.jetons_reset.crees == [compte.id]
assert len(attirail.mailer.envois) == 1
assert attirail.mailer.envois[0][0] == compte.email
assert "auth.password_reset_requested" in attirail.audit.lignes[0][0]
async def test_request_password_reset_stays_silent_when_the_account_is_unknown() -> None:
attirail = fabrique_service(compte=None)
await attirail.service.request_password_reset(
email="inconnu@enervision.fr", client_ip="203.0.113.10", user_agent="pytest"
)
assert attirail.jetons_reset.crees == []
assert attirail.mailer.envois == []
assert attirail.hacheur.verifications == 1, "le hachage factice doit tout de même tourner"
async def test_request_password_reset_stays_silent_when_the_account_is_inactive() -> None:
compte = FauxCompte(is_active=False)
attirail = fabrique_service(compte=compte)
await attirail.service.request_password_reset(
email=compte.email, client_ip="203.0.113.10", user_agent="pytest"
)
assert attirail.jetons_reset.crees == []
assert attirail.mailer.envois == []
async def test_request_password_reset_raises_when_the_rate_limit_is_reached() -> None:
attirail = fabrique_service(compteurs_reset=ResetRequestCounts(per_identifier=3, per_ip=0))
with pytest.raises(RateLimitedError):
await attirail.service.request_password_reset(
email="operateur@enervision.fr", client_ip="203.0.113.10", user_agent="pytest"
)
assert attirail.mailer.envois == []
async def test_confirm_password_reset_revokes_every_session_then_reopens_the_current_one() -> None:
compte = FauxCompte()
jetons_reset = FauxDepotJetonsReset(
revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id)
)
attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset)
session = await attirail.service.confirm_password_reset(
token="un-secret-opaque",
new_password="Un-nouveau-mot-de-passe1!",
client_ip="203.0.113.10",
user_agent="pytest",
)
assert attirail.jetons.revocations_par_compte == [
(compte.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value)
]
assert len(attirail.jetons.crees) == 1
assert session.refresh_secret
assert "auth.password_reset_self_service" in attirail.audit.lignes[0][0]
async def test_confirm_password_reset_rejects_an_invalid_or_expired_token() -> None:
attirail = fabrique_service(jetons_reset=FauxDepotJetonsReset(revendique=None))
with pytest.raises(InvalidOrExpiredResetTokenError):
await attirail.service.confirm_password_reset(
token="un-secret-invalide",
new_password="Un-nouveau-mot-de-passe1!",
client_ip=None,
user_agent=None,
)
assert attirail.jetons.revocations_par_compte == []
+15 -4
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest
from app import cli
from app.schemas.auth import valide_complexite
def test_build_parser_reads_the_create_admin_arguments() -> None:
@@ -34,26 +35,36 @@ def test_read_password_generates_a_long_secret_when_asked(
assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE
assert mot_de_passe in capsys.readouterr().out
valide_complexite(mot_de_passe)
def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None:
saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"])
saisies = iter(["Un-mot-de-passe-valide1", "Un-mot-de-passe-valide1"])
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
assert cli.read_password(generate=False) == "un-mot-de-passe-valide"
assert cli.read_password(generate=False) == "Un-mot-de-passe-valide1"
def test_read_password_refuses_a_password_below_the_minimum_length(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cli, "getpass", lambda _: "court")
monkeypatch.setattr(cli, "getpass", lambda _: "Court1!")
with pytest.raises(SystemExit):
cli.read_password(generate=False)
def test_read_password_refuses_a_password_missing_a_character_class(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cli, "getpass", lambda _: "un-mot-de-passe-sans-majuscule-ni-chiffre")
with pytest.raises(SystemExit):
cli.read_password(generate=False)
def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None:
saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"])
saisies = iter(["Un-mot-de-passe-valide1", "Un-autre-mot-de-passe2"])
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
with pytest.raises(SystemExit):