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"),
}
)