From 9161b74874a13b0b17df9eca9679c2120362e683 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 10:53:58 +0200 Subject: [PATCH 1/8] 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 --- apps/backend/.env.example | 10 + apps/backend/README.md | 2 + ...c0adab96238c_jetons_de_reinitialisation.py | 96 ++++++++++ apps/backend/app/api/deps.py | 31 +++- apps/backend/app/api/openapi.py | 13 ++ apps/backend/app/api/v1/endpoints/auth.py | 83 +++++++++ apps/backend/app/cli.py | 31 +++- apps/backend/app/core/config.py | 13 ++ apps/backend/app/core/mailer.py | 48 +++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/audit_log.py | 2 + .../app/models/password_reset_attempt.py | 27 +++ .../app/models/password_reset_token.py | 40 ++++ .../repositories/password_reset_attempt.py | 42 +++++ .../app/repositories/password_reset_token.py | 68 +++++++ apps/backend/app/schemas/auth.py | 45 ++++- apps/backend/app/services/auth.py | 110 +++++++++++ apps/backend/openapi.json | 175 +++++++++++++++++- apps/backend/pyproject.toml | 1 + apps/backend/tests/api/test_auth.py | 100 ++++++++++ .../tests/api/test_route_protection.py | 4 + .../repositories/test_password_reset_token.py | 114 ++++++++++++ apps/backend/tests/schemas/__init__.py | 0 apps/backend/tests/schemas/test_auth.py | 41 ++++ apps/backend/tests/services/test_auth.py | 158 +++++++++++++++- apps/backend/tests/test_cli.py | 19 +- apps/backend/uv.lock | 11 ++ apps/frontend/src/app/app.routes.ts | 2 + .../src/app/core/services/auth.service.ts | 19 +- .../auth/change-password/change-password.html | 2 +- .../change-password/change-password.spec.ts | 17 +- .../auth/change-password/change-password.ts | 6 +- .../auth/forgot-password/forgot-password.html | 37 ++++ .../auth/forgot-password/forgot-password.scss | 104 +++++++++++ .../forgot-password/forgot-password.spec.ts | 75 ++++++++ .../auth/forgot-password/forgot-password.ts | 53 ++++++ .../src/app/features/auth/login/login.html | 2 + .../src/app/features/auth/login/login.scss | 10 + .../src/app/features/auth/login/login.spec.ts | 3 +- .../src/app/features/auth/login/login.ts | 4 +- .../auth/reset-password/reset-password.html | 30 +++ .../auth/reset-password/reset-password.scss | 104 +++++++++++ .../reset-password/reset-password.spec.ts | 74 ++++++++ .../auth/reset-password/reset-password.ts | 52 ++++++ .../src/app/shared/models/auth.model.ts | 9 + .../shared/validators/password.validator.ts | 15 ++ docker-compose.yml | 16 ++ .../31-contrat-authentification.md | 17 +- 48 files changed, 1914 insertions(+), 25 deletions(-) create mode 100644 apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py create mode 100644 apps/backend/app/core/mailer.py create mode 100644 apps/backend/app/models/password_reset_attempt.py create mode 100644 apps/backend/app/models/password_reset_token.py create mode 100644 apps/backend/app/repositories/password_reset_attempt.py create mode 100644 apps/backend/app/repositories/password_reset_token.py create mode 100644 apps/backend/tests/repositories/test_password_reset_token.py create mode 100644 apps/backend/tests/schemas/__init__.py create mode 100644 apps/backend/tests/schemas/test_auth.py create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.html create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.html create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.scss create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.ts create mode 100644 apps/frontend/src/app/shared/validators/password.validator.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index f36551e..8dff67f 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -8,3 +8,13 @@ APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision + +# Mot de passe oublié : lien à usage unique valable 15 minutes par défaut. +APP_FRONTEND_RESET_PASSWORD_URL=http://localhost:4200/reset-password + +# SMTP local de dev (Mailpit, cf. docker-compose.yml) : aucune authentification, aucun TLS. +# À remplacer par un vrai relais en staging/prod. +APP_SMTP_HOST=localhost +APP_SMTP_PORT=1025 +APP_SMTP_USE_TLS=false +APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr diff --git a/apps/backend/README.md b/apps/backend/README.md index 91f9608..6c48b3a 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -103,6 +103,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente | | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton | | `/api/v1/auth/password` | Change son propre mot de passe | jeton | +| `/api/v1/auth/forgot-password` | Demande un lien de réinitialisation par email | public | +| `/api/v1/auth/reset-password` | Choisit un nouveau mot de passe depuis ce lien | public | | `/api/v1/auth/me` | Décrit le compte connecté | jeton | | `/api/v1/users` | Liste et crée des comptes | `admin` | | `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` | diff --git a/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py new file mode 100644 index 0000000..7f75d21 --- /dev/null +++ b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py @@ -0,0 +1,96 @@ +"""jetons et tentatives de reinitialisation de mot de passe + +Revision ID: c0adab96238c +Revises: e6d2026091501 +Create Date: 2026-09-17 10:37:12.571314 + +Meme schema que `refresh_token` pour `password_reset_token` : seule l'empreinte SHA-256 du +jeton est stockee, jamais le jeton lui-meme, pour la meme raison (revocation en cascade, +aucune session utilisable dans un pg_dump qui fuiterait). + +`password_reset_attempt` vit hors de `audit_log`, comme `login_attempt`, car son volume est +pilote par l'attaquant : une campagne de demandes y ecrirait des lignes que l'audit, en ajout +seul, ne devrait jamais purger. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "c0adab96238c" +down_revision: str | Sequence[str] | None = "e6d2026091501" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +JETONS_VIVANTS = "consumed_at is null" + + +def upgrade() -> None: + op.create_table( + "password_reset_attempt", + sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("email_tried", sa.String(length=320), nullable=False), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.PrimaryKeyConstraint("id", name="pk_password_reset_attempt"), + ) + op.create_index( + "ix_password_reset_attempt_email_date", + "password_reset_attempt", + ["email_tried", "occurred_at"], + ) + op.create_index( + "ix_password_reset_attempt_ip_date", "password_reset_attempt", ["client_ip", "occurred_at"] + ) + + op.create_table( + "password_reset_token", + sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("token_hash", sa.LargeBinary(), nullable=False), + sa.Column( + "issued_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["app_user.id"], + name="fk_password_reset_token_user", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name="pk_password_reset_token"), + sa.UniqueConstraint("token_hash", name="uq_password_reset_token_hash"), + ) + op.create_index("ix_password_reset_token_user", "password_reset_token", ["user_id"]) + op.create_index( + "ix_password_reset_token_vivants", + "password_reset_token", + ["user_id"], + postgresql_where=JETONS_VIVANTS, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_password_reset_token_vivants", + table_name="password_reset_token", + postgresql_where=JETONS_VIVANTS, + ) + op.drop_index("ix_password_reset_token_user", table_name="password_reset_token") + op.drop_table("password_reset_token") + op.drop_index("ix_password_reset_attempt_ip_date", table_name="password_reset_attempt") + op.drop_index("ix_password_reset_attempt_email_date", table_name="password_reset_attempt") + op.drop_table("password_reset_attempt") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 5b39098..f1aa8ae 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -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, ) diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 85b7775..a649705 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -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"}, + } + }, + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 32bf8b2..957775f 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -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) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index 37e94fd..fea510e 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -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 diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 6733b3a..e374709 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -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 diff --git a/apps/backend/app/core/mailer.py b/apps/backend/app/core/mailer.py new file mode 100644 index 0000000..5c09008 --- /dev/null +++ b/apps/backend/app/core/mailer.py @@ -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) diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 10a5ecb..167d7ce 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -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", diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py index 5775f5e..d389880 100644 --- a/apps/backend/app/models/audit_log.py +++ b/apps/backend/app/models/audit_log.py @@ -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" diff --git a/apps/backend/app/models/password_reset_attempt.py b/apps/backend/app/models/password_reset_attempt.py new file mode 100644 index 0000000..6d2a607 --- /dev/null +++ b/apps/backend/app/models/password_reset_attempt.py @@ -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) diff --git a/apps/backend/app/models/password_reset_token.py b/apps/backend/app/models/password_reset_token.py new file mode 100644 index 0000000..d67d310 --- /dev/null +++ b/apps/backend/app/models/password_reset_token.py @@ -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) diff --git a/apps/backend/app/repositories/password_reset_attempt.py b/apps/backend/app/repositories/password_reset_attempt.py new file mode 100644 index 0000000..ddc2f91 --- /dev/null +++ b/apps/backend/app/repositories/password_reset_attempt.py @@ -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) diff --git a/apps/backend/app/repositories/password_reset_token.py b/apps/backend/app/repositories/password_reset_token.py new file mode 100644 index 0000000..13a660e --- /dev/null +++ b/apps/backend/app/repositories/password_reset_token.py @@ -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()) diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py index 522b4c5..e6785be 100644 --- a/apps/backend/app/schemas/auth.py +++ b/apps/backend/app/schemas/auth.py @@ -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) diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 8baf857..02ff04b 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -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, diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 84f9c08..f142efd 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -424,6 +424,144 @@ ] } }, + "/api/v1/auth/forgot-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Demande un lien de réinitialisation par email", + "operationId": "forgot_password_api_v1_auth_forgot_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "429": { + "description": "Trop de demandes sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/reset-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Choisit un nouveau mot de passe depuis un lien reçu par email", + "operationId": "reset_password_api_v1_auth_reset_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/users": { "get": { "tags": [ @@ -1432,6 +1570,20 @@ ], "title": "FieldError" }, + "ForgotPasswordRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + } + }, + "type": "object", + "required": [ + "email" + ], + "title": "ForgotPasswordRequest" + }, "InternalErrorResponse": { "properties": { "detail": { @@ -1511,7 +1663,7 @@ "new_password": { "type": "string", "maxLength": 128, - "minLength": 12, + "minLength": 8, "title": "New Password" } }, @@ -1619,6 +1771,27 @@ ], "title": "RecommendationResponse" }, + "ResetPasswordRequest": { + "properties": { + "token": { + "type": "string", + "minLength": 1, + "title": "Token" + }, + "new_password": { + "type": "string", + "maxLength": 128, + "minLength": 8, + "title": "New Password" + } + }, + "type": "object", + "required": [ + "token", + "new_password" + ], + "title": "ResetPasswordRequest" + }, "Role": { "type": "string", "enum": [ diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 18bf979..2bfdef3 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pyjwt>=2.10", "argon2-cffi>=23.1", "anyio>=4.0", + "aiosmtplib>=5.1.3", ] [dependency-groups] diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py index 1d734da..44c25e6 100644 --- a/apps/backend/tests/api/test_auth.py +++ b/apps/backend/tests/api/test_auth.py @@ -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 diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index 9a04338..1080dce 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -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"), } ) diff --git a/apps/backend/tests/repositories/test_password_reset_token.py b/apps/backend/tests/repositories/test_password_reset_token.py new file mode 100644 index 0000000..e25518c --- /dev/null +++ b/apps/backend/tests/repositories/test_password_reset_token.py @@ -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() diff --git a/apps/backend/tests/schemas/__init__.py b/apps/backend/tests/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/schemas/test_auth.py b/apps/backend/tests/schemas/test_auth.py new file mode 100644 index 0000000..7982f56 --- /dev/null +++ b/apps/backend/tests/schemas/test_auth.py @@ -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") diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 9b8c42c..52cde71 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -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 == [] diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index 40b8317..7344bf7 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -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): diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index 7c2b8f4..39ec7ca 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = "==3.14.*" +[[package]] +name = "aiosmtplib" +version = "5.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/5c/9cabc5db6d607616e81ba6d8f1f231cd5a75955807a308c1090a59072d6d/aiosmtplib-5.1.3.tar.gz", hash = "sha256:ac2b418d3260ba62d9cfd0fe7359726e9dc009a4e8e8d9909fdfae332f522a7c", size = 77010, upload-time = "2026-09-08T02:11:20.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/0a/b56ab8163d54960337fdca475d3dfd56c8badf6172e79cf2ad00d5335dc1/aiosmtplib-5.1.3-py3-none-any.whl", hash = "sha256:f7d76ce3d4995a65a178c1f11e1bd1607706b921d00cb768e7a2c7f7ef5517a8", size = 30116, upload-time = "2026-09-08T02:11:19.352Z" }, +] + [[package]] name = "alembic" version = "1.20.0" @@ -306,6 +315,7 @@ name = "enervision-backend" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiosmtplib" }, { name = "alembic" }, { name = "anyio" }, { name = "argon2-cffi" }, @@ -332,6 +342,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiosmtplib", specifier = ">=5.1.3" }, { name = "alembic", specifier = ">=1.20.0" }, { name = "anyio", specifier = ">=4.0" }, { name = "argon2-cffi", specifier = ">=23.1" }, diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index b3e97d8..72e20f5 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -5,6 +5,8 @@ export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) }, { path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) }, + { path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) }, + { path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) }, { path: 'dashboard', canActivate: [authGuard], diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts index d27c1db..9aa477a 100644 --- a/apps/frontend/src/app/core/services/auth.service.ts +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -1,7 +1,14 @@ import { Service, signal, computed, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, tap, finalize, shareReplay } from 'rxjs'; -import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model'; +import { + ForgotPasswordRequest, + LoginRequest, + PasswordChangeRequest, + Principal, + ResetPasswordRequest, + TokenResponse, +} from '../../shared/models/auth.model'; import { environment } from '../../../environments/environment'; @Service() @@ -66,4 +73,14 @@ export class AuthService { me(): Observable { return this.http.get(`${environment.apiUrl}/auth/me`); } + + forgotPassword(payload: ForgotPasswordRequest): Observable { + return this.http.post(`${environment.apiUrl}/auth/forgot-password`, payload); + } + + resetPassword(payload: ResetPasswordRequest): Observable { + return this.http + .post(`${environment.apiUrl}/auth/reset-password`, payload, { withCredentials: true }) + .pipe(tap((response) => this.setSession(response))); + } } diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.html b/apps/frontend/src/app/features/auth/change-password/change-password.html index edf2146..d7b5039 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.html +++ b/apps/frontend/src/app/features/auth/change-password/change-password.html @@ -18,7 +18,7 @@ formControlName="new_password" autocomplete="new-password" /> - 12 à 128 caractères + {{ passwordHint }} @if (errorMessage()) {

{{ errorMessage() }}

diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 63e1872..0e72843 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -32,10 +32,19 @@ describe('ChangePassword', () => { expect(authMock.changePassword).not.toHaveBeenCalled(); }); + it('ne soumet pas si le mot de passe ne couvre pas les 4 classes de caractères', () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'old', new_password: 'longueur-suffisante-sans-majuscule-ni-chiffre' }); + + component.onSubmit(); + expect(authMock.changePassword).not.toHaveBeenCalled(); + }); + it('redirige vers /dashboard après un changement réussi', () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); @@ -46,7 +55,7 @@ describe('ChangePassword', () => { it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); authMock.changePassword.mockReturnValue(throwError(() => new Error('401'))); @@ -70,7 +79,7 @@ describe('ChangePassword', () => { it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); fixture.detectChanges(); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); @@ -81,7 +90,7 @@ describe('ChangePassword', () => { expect(authMock.changePassword).toHaveBeenCalledWith({ current_password: 'ancien-mot-de-passe', - new_password: 'un-nouveau-mot-de-passe-valide', + new_password: 'Un-nouveau-mot-de-passe1!', }); }); diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 507af14..528aea0 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -2,6 +2,7 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { AuthService } from '../../../core/services/auth.service'; +import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; @Component({ selector: 'app-change-password', @@ -17,10 +18,11 @@ export class ChangePassword { errorMessage = signal(null); isLoading = signal(false); + passwordHint = PASSWORD_HINT; form = this.fb.nonNullable.group({ current_password: ['', Validators.required], - new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]], + new_password: ['', passwordValidators], }); onSubmit(): void { @@ -34,7 +36,7 @@ export class ChangePassword { }, error: () => { this.isLoading.set(false); - this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).'); + this.errorMessage.set(`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`); }, }); } diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html new file mode 100644 index 0000000..2bd7ef9 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html @@ -0,0 +1,37 @@ +
+
+

Mot de passe oublié

+

Recevez un lien de réinitialisation par email

+ + @if (submitted()) { +

+ Si un compte existe pour cet email, un lien de réinitialisation vient d'être envoyé. + Il expire dans 15 minutes. +

+ } @else { + + + + @if (errorMessage()) { +

+ {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } +

+ } + + + } + + +
+
diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss @@ -0,0 +1,104 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-hint { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} + +.auth-success { + margin: 0.75rem 0 0; + color: #16a34a; + font-size: 0.85rem; +} + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts new file mode 100644 index 0000000..56f7764 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { ForgotPassword } from './forgot-password'; +import { AuthService } from '../../../core/services/auth.service'; + +describe('ForgotPassword', () => { + let authMock: { forgotPassword: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(async () => { + authMock = { forgotPassword: vi.fn() }; + routerMock = { navigate: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [ForgotPassword, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: {} }, + ], + }).compileComponents(); + }); + + it('ne soumet pas si le formulaire est invalide', () => { + const fixture = TestBed.createComponent(ForgotPassword); + fixture.componentInstance.onSubmit(); + expect(authMock.forgotPassword).not.toHaveBeenCalled(); + }); + + it('affiche le message générique après une soumission réussie', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'operateur@enervision.fr' }); + authMock.forgotPassword.mockReturnValue(of(undefined)); + + component.onSubmit(); + + expect(component.submitted()).toBe(true); + }); + + it('affiche le même message générique même quand le serveur répond une erreur autre que 429', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'inconnu@enervision.fr' }); + authMock.forgotPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))); + + component.onSubmit(); + + expect(component.submitted()).toBe(true); + }); + + it('affiche le délai à respecter quand le taux limite est atteint', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'operateur@enervision.fr' }); + authMock.forgotPassword.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 429, + headers: new HttpHeaders({ 'Retry-After': '900' }), + }) + ) + ); + + component.onSubmit(); + + expect(component.submitted()).toBe(false); + expect(component.retryAfterSeconds()).toBe(900); + }); +}); diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts new file mode 100644 index 0000000..6ceef5c --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts @@ -0,0 +1,53 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AuthService } from '../../../core/services/auth.service'; + +@Component({ + selector: 'app-forgot-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink], + templateUrl: './forgot-password.html', + styleUrl: './forgot-password.scss', +}) +export class ForgotPassword { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + + errorMessage = signal(null); + retryAfterSeconds = signal(null); + submitted = signal(false); + isLoading = signal(false); + + form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + }); + + onSubmit(): void { + if (this.form.invalid) return; + + this.isLoading.set(true); + this.errorMessage.set(null); + this.retryAfterSeconds.set(null); + + this.auth.forgotPassword(this.form.getRawValue()).subscribe({ + // Le message affiché ne dépend jamais du fait que le compte existe ou non : la réponse + // du serveur est déjà générique, l'écran doit l'être aussi. + next: () => { + this.isLoading.set(false); + this.submitted.set(true); + }, + error: (error: HttpErrorResponse) => { + this.isLoading.set(false); + if (error.status === 429) { + const retryAfter = error.headers.get('Retry-After'); + this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null); + this.errorMessage.set('Trop de demandes, réessayez plus tard.'); + return; + } + this.submitted.set(true); + }, + }); + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 0083bd2..3ee100b 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -32,5 +32,7 @@ + + diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss index cc415b8..45b28c0 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -79,3 +79,13 @@ color: #dc2626; font-size: 0.85rem; } + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index 3c9bac1..d39298d 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; @@ -20,6 +20,7 @@ describe('Login', () => { providers: [ { provide: AuthService, useValue: authMock }, { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: {} }, ], }).compileComponents(); }); diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 34b9ff2..871e7cc 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -1,13 +1,13 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, RouterLink], templateUrl: './login.html', styleUrl: './login.scss', }) diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.html b/apps/frontend/src/app/features/auth/reset-password/reset-password.html new file mode 100644 index 0000000..eed77a8 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.html @@ -0,0 +1,30 @@ +
+
+

Nouveau mot de passe

+ + @if (!hasToken) { +

Ce lien est incomplet. Redemandez un lien de réinitialisation.

+ } @else { +

Choisissez votre nouveau mot de passe

+ + + + {{ passwordHint }} + + @if (errorMessage()) { +

{{ errorMessage() }}

+ } + + + } + + +
+
diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.scss b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss @@ -0,0 +1,104 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-hint { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} + +.auth-success { + margin: 0.75rem 0 0; + color: #16a34a; + font-size: 0.85rem; +} + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts new file mode 100644 index 0000000..7e212cd --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts @@ -0,0 +1,74 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { ResetPassword } from './reset-password'; +import { AuthService } from '../../../core/services/auth.service'; + +function configure(token: string | null) { + return TestBed.configureTestingModule({ + imports: [ResetPassword, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: { resetPassword: vi.fn() } }, + { provide: Router, useValue: { navigate: vi.fn() } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: convertToParamMap(token ? { token } : {}) } }, + }, + ], + }).compileComponents(); +} + +describe('ResetPassword', () => { + it("signale un lien incomplet quand le jeton est absent de l'URL", async () => { + await configure(null); + const fixture = TestBed.createComponent(ResetPassword); + + expect(fixture.componentInstance.hasToken).toBe(false); + }); + + it('ne soumet pas si le mot de passe ne respecte pas la politique de complexité', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + component.form.setValue({ new_password: 'trop-simple' }); + + component.onSubmit(); + + expect(auth.resetPassword).not.toHaveBeenCalled(); + }); + + it('redirige vers /dashboard après une réinitialisation réussie', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(of({ principal: { role: 'operateur' } })); + + component.onSubmit(); + + expect(auth.resetPassword).toHaveBeenCalledWith({ + token: 'un-secret-opaque', + new_password: 'Un-nouveau-mot-de-passe1!', + }); + expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); + }); + + it('affiche un message dédié quand le lien est invalide ou expiré', async () => { + await configure('un-secret-perime'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 }))); + + component.onSubmit(); + + expect(component.errorMessage()).toContain('invalide'); + }); +}); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts new file mode 100644 index 0000000..6754fa7 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts @@ -0,0 +1,52 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AuthService } from '../../../core/services/auth.service'; +import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; + +@Component({ + selector: 'app-reset-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink], + templateUrl: './reset-password.html', + styleUrl: './reset-password.scss', +}) +export class ResetPassword { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + private router = inject(Router); + private route = inject(ActivatedRoute); + + private token = this.route.snapshot.queryParamMap.get('token') ?? ''; + + errorMessage = signal(null); + isLoading = signal(false); + passwordHint = PASSWORD_HINT; + hasToken = this.token.length > 0; + + form = this.fb.nonNullable.group({ + new_password: ['', passwordValidators], + }); + + onSubmit(): void { + if (this.form.invalid || !this.hasToken) return; + + this.isLoading.set(true); + this.errorMessage.set(null); + + this.auth.resetPassword({ token: this.token, new_password: this.form.getRawValue().new_password }).subscribe({ + next: () => { + this.router.navigate(['/dashboard']); + }, + error: (error: HttpErrorResponse) => { + this.isLoading.set(false); + if (error.status === 400) { + this.errorMessage.set('Ce lien est invalide, déjà utilisé, ou a expiré. Redemandez-en un.'); + return; + } + this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`); + }, + }); + } +} diff --git a/apps/frontend/src/app/shared/models/auth.model.ts b/apps/frontend/src/app/shared/models/auth.model.ts index 932572f..ebed0d5 100644 --- a/apps/frontend/src/app/shared/models/auth.model.ts +++ b/apps/frontend/src/app/shared/models/auth.model.ts @@ -10,6 +10,15 @@ export interface PasswordChangeRequest { new_password: string; } +export interface ForgotPasswordRequest { + email: string; +} + +export interface ResetPasswordRequest { + token: string; + new_password: string; +} + export interface Principal { id: string; email: string; diff --git a/apps/frontend/src/app/shared/validators/password.validator.ts b/apps/frontend/src/app/shared/validators/password.validator.ts new file mode 100644 index 0000000..fac1359 --- /dev/null +++ b/apps/frontend/src/app/shared/validators/password.validator.ts @@ -0,0 +1,15 @@ +import { Validators } from '@angular/forms'; + +export const PASSWORD_MIN_LENGTH = 8; +export const PASSWORD_MAX_LENGTH = 128; +export const PASSWORD_HINT = + '8 à 128 caractères, avec au moins 1 majuscule, 1 minuscule, 1 chiffre et 1 caractère spécial'; + +const PASSWORD_PATTERN = /^(?=.*[A-ZÀ-Ý])(?=.*[a-zà-ÿ])(?=.*\d)(?=.*[^\w\s]).*$/; + +export const passwordValidators = [ + Validators.required, + Validators.minLength(PASSWORD_MIN_LENGTH), + Validators.maxLength(PASSWORD_MAX_LENGTH), + Validators.pattern(PASSWORD_PATTERN), +]; diff --git a/docker-compose.yml b/docker-compose.yml index 3d0ea63..3f7f9ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,11 +27,22 @@ services: start_period: 40s restart: unless-stopped + # Piege : Mailpit ne relaie rien vers l'exterieur, il capture tout email envoye par le + # backend. Aucun acces reseau sortant n'est requis ; l'UI web (8025) sert a lire les emails. + mailpit: + image: axllent/mailpit + ports: + - "${MAILPIT_SMTP_PORT:-1025}:1025" + - "${MAILPIT_UI_PORT:-8025}:8025" + restart: unless-stopped + backend: build: ./apps/backend depends_on: db: condition: service_healthy + mailpit: + condition: service_started environment: APP_ENV: ${APP_ENV:-local} APP_DEBUG: ${APP_DEBUG:-false} @@ -39,6 +50,11 @@ services: APP_SECRET_KEY: ${APP_SECRET_KEY:?} APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password} + APP_SMTP_HOST: mailpit + APP_SMTP_PORT: "1025" + APP_SMTP_USE_TLS: "false" + APP_SMTP_FROM_ADDRESS: ${APP_SMTP_FROM_ADDRESS:-no-reply@enervision.fr} ports: - "${BACKEND_PORT:-8000}:8000" restart: unless-stopped diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md index 9c9fe66..981cd85 100644 --- a/docs/architecture/31-contrat-authentification.md +++ b/docs/architecture/31-contrat-authentification.md @@ -20,6 +20,8 @@ gérer : il suffit d'envoyer les requêtes avec `withCredentials`. | POST | `/api/v1/auth/logout` | cookie | `204` | | POST | `/api/v1/auth/logout-all` | jeton d'accès | `204` | | POST | `/api/v1/auth/password` | jeton d'accès | `200` `TokenResponse` | +| POST | `/api/v1/auth/forgot-password` | aucune | `202` (toujours, que le compte existe ou non) | +| POST | `/api/v1/auth/reset-password` | aucune (jeton dans le corps) | `200` `TokenResponse` | | GET | `/api/v1/auth/me` | jeton d'accès | `200` `PrincipalResponse` | | GET | `/api/v1/users` | jeton d'accès, `admin` | `200` `UserResponse[]` | | POST | `/api/v1/users` | jeton d'accès, `admin` | `201` `TemporaryPasswordResponse` | @@ -51,7 +53,17 @@ codes d'erreur ci-dessous reste la référence de comportement, le schéma celle } // POST /auth/password -{ "current_password": "...", "new_password": "..." } // 12 à 128 caractères +{ "current_password": "...", "new_password": "..." } // 8 à 128 caractères, au moins 1 majuscule, 1 minuscule, 1 chiffre, 1 caractère spécial + +// POST /auth/forgot-password +{ "email": "operateur@enervision.fr" } +// Répond toujours 202, sans corps, que le compte existe, soit inactif, ou soit inconnu. + +// POST /auth/reset-password +{ "token": "...", "new_password": "..." } // même règle de complexité que /auth/password +// Le jeton vient du lien reçu par email, valable 15 minutes, à usage unique. Répond +// TokenResponse au succès (l'appareil qui pose le nouveau mot de passe reste connecté), ou 400 +// si le jeton est invalide, déjà utilisé, ou expiré. ``` Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la réponse. @@ -70,6 +82,9 @@ Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la répon | `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter | | `403` sur `/auth/refresh`, `/logout`, `/logout-all`, `/password` | origine hors liste autorisée (voir « Origines autorisées ») | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | | `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée | +| `429` sur `/auth/forgot-password` | trop de demandes | afficher l'attente, l'en-tête `Retry-After` donne les secondes | +| `400` sur `/auth/reset-password` | lien invalide, déjà utilisé, ou expiré | inviter à redemander un lien depuis `/forgot-password` | +| `403` sur `/auth/reset-password` | origine hors liste autorisée | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | ## Les quatre règles qui comptent From 62932e57c37ee3d070ae1815827222bfb666371c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 11:42:44 +0200 Subject: [PATCH 2/8] style(backend): formatage ruff de cli.py --- apps/backend/app/cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index fea510e..06c5610 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -136,9 +136,7 @@ def read_password(*, generate: bool) -> str: mot_de_passe = getpass("Mot de passe : ") if len(mot_de_passe) < PASSWORD_MIN_LENGTH: - raise SystemExit( - f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères" - ) + 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: From 921da48eb1a4f7c890095987e848ff54db879010 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 14:35:02 +0200 Subject: [PATCH 3/8] fix(auth): corrige 4 failles de la revue de securite sur la PR #90 Anti-enumeration cassee sur /auth/forgot-password : l'envoi SMTP etait synchrone dans le chemin de reponse, donc un email existant prenait plus de temps qu'un email inconnu (et pouvait renvoyer 500 si le relais SMTP echouait, contre 202 sinon). L'envoi part desormais en BackgroundTasks, apres que la reponse 202 a ete envoyee au client, avec un try/except qui logue plutot que de laisser une exception SMTP remonter. confirm_password_reset() ne revalidait pas is_active/kind du compte avant de changer le mot de passe : un compte desactive dans les 15 minutes suivant l'emission du lien pouvait quand meme voir son mot de passe change et son must_change_password efface. Les plages [A-ZA-Y]/[a-za-y] de la regle de complexite incluaient par erreur x et / (U+00D7, U+00F7), donc un mot de passe sans aucune majuscule ou minuscule pouvait passer la validation. Le validateur frontend (JS, \w ASCII) et le validateur backend (Python, \w Unicode) divergeaient sur les caracteres accentues : un mot de passe comme "Securite1" passait cote front puis se faisait rejeter en 422 cote back. Les deux cotes utilisent maintenant le meme jeu explicite de caracteres speciaux (SPECIAL_CHARACTERS, partage aussi avec cli.py). --- apps/backend/app/api/v1/endpoints/auth.py | 4 +- apps/backend/app/cli.py | 5 +- apps/backend/app/schemas/auth.py | 14 +++-- apps/backend/app/services/auth.py | 31 +++++++++- apps/backend/tests/schemas/test_auth.py | 20 +++++++ apps/backend/tests/services/test_auth.py | 60 +++++++++++++++++-- .../validators/password.validator.spec.ts | 26 ++++++++ .../shared/validators/password.validator.ts | 12 +++- 8 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 apps/frontend/src/app/shared/validators/password.validator.spec.ts diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 957775f..975273d 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -2,7 +2,7 @@ # d'accès ne va jamais dans un cookie. C'est ce qui réduit la surface CSRF aux trois routes de # ce module : partout ailleurs, le navigateur n'attache rien de lui-même. -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status from app.api.deps import ( AuthServiceDep, @@ -299,6 +299,7 @@ async def forgot_password( request: Request, response: Response, service: AuthServiceDep, + background_tasks: BackgroundTasks, client_ip: str | None = Depends(get_client_ip), ) -> None: response.headers["Cache-Control"] = "no-store" @@ -308,6 +309,7 @@ async def forgot_password( email=payload.email, client_ip=client_ip, user_agent=request.headers.get("user-agent"), + background_tasks=background_tasks, ) except RateLimitedError as erreur: logger.warning("auth.password_reset.rate_limited ip=%s", client_ip) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index 06c5610..f713fa5 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -23,10 +23,9 @@ 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 +from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite LONGUEUR_MOT_DE_PASSE_GENERE = 24 -CARACTERES_SPECIAUX = "!@#$%^&*()-_=+[]{};:,.?" CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" @@ -119,7 +118,7 @@ def genere_mot_de_passe() -> str: string.ascii_uppercase, string.ascii_lowercase, string.digits, - CARACTERES_SPECIAUX, + SPECIAL_CHARACTERS, ] reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes) caracteres = [tirage.choice(classe) for classe in classes] diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py index e6785be..6ad50ef 100644 --- a/apps/backend/app/schemas/auth.py +++ b/apps/backend/app/schemas/auth.py @@ -1,5 +1,9 @@ # 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. +# Contrainte : `SPECIAL_CHARACTERS` doit rester identique à `password.validator.ts` côté +# frontend. `\w`/`\d` divergent entre Python (Unicode) et JavaScript (ASCII) : une classe +# explicite, plutôt qu'une négation, évite qu'un mot de passe soit accepté d'un côté et +# rejeté de l'autre (ex. "Sécurité1", où "é" comptait comme "spécial" pour Python seul). import re from typing import Literal, Self @@ -13,10 +17,12 @@ from app.core.roles import AccountKind, Role 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]") +SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.?" + +_MAJUSCULE = re.compile(r"[A-ZÀ-ÖØ-Þ]") +_MINUSCULE = re.compile(r"[a-zà-öø-þ]") +_CHIFFRE = re.compile(r"[0-9]") +_SPECIAL = re.compile(r"[" + re.escape(SPECIAL_CHARACTERS) + r"]") def valide_complexite(mot_de_passe: str) -> str: diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 02ff04b..de83231 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -14,7 +14,10 @@ from datetime import UTC, datetime, timedelta from typing import NoReturn, Protocol from uuid import UUID, uuid4 +from fastapi import BackgroundTasks + from app.core.hashing import Argon2Hasher +from app.core.logging import get_logger from app.core.mailer import Mailer from app.core.principal import Principal from app.core.roles import AccountKind, Role @@ -34,6 +37,8 @@ from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository +logger = get_logger(__name__) + class Transaction(Protocol): async def commit(self) -> None: ... @@ -225,14 +230,21 @@ class AuthService: 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 + self, + *, + email: str, + client_ip: str | None, + user_agent: str | None, + background_tasks: BackgroundTasks, ) -> 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. + # inconnu, compte inactif, ou email envoyé avec succès. L'envoi SMTP lui-même est différé + # en tâche de fond : le laisser dans le chemin de réponse rouvrirait le même oracle par le + # temps (aller-retour réseau) et par la forme (500 si le relais SMTP échoue, contre 202). 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) @@ -260,7 +272,13 @@ class AuthService: 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) + background_tasks.add_task(self._envoie_email_reset, compte.email, lien) + + async def _envoie_email_reset(self, email: str, reset_url: str) -> None: + try: + await self._mailer.send_password_reset_email(to=email, reset_url=reset_url) + except Exception: + logger.exception("auth.password_reset.mail_failed") async def confirm_password_reset( self, *, token: str, new_password: str, client_ip: str | None, user_agent: str | None @@ -269,6 +287,13 @@ class AuthService: if revendique is None: raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré") + # Piège : le jeton peut avoir été émis avant une désactivation du compte. Sans cette + # relecture, un lien encore valide (15 min) changerait quand même le mot de passe d'un + # compte désactivé, réutilisable dès sa réactivation. + compte = await self._users.get_by_id(revendique.user_id) + if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value: + raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré") + await self._users.update_password( revendique.user_id, await self._hasher.hash(new_password), must_change_password=False ) diff --git a/apps/backend/tests/schemas/test_auth.py b/apps/backend/tests/schemas/test_auth.py index 7982f56..956e1e4 100644 --- a/apps/backend/tests/schemas/test_auth.py +++ b/apps/backend/tests/schemas/test_auth.py @@ -39,3 +39,23 @@ def test_password_change_request_rejects_a_password_below_the_minimum_length() - 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") + + +def test_valide_complexite_accepts_an_accented_password() -> None: + assert valide_complexite("Sécurité1!") == "Sécurité1!" + + +@pytest.mark.parametrize("mot_de_passe", ["abcdefg1×", "abcdefg1÷"]) # noqa: RUF001 +def test_valide_complexite_rejects_a_password_without_uppercase_despite_times_or_divide( + mot_de_passe: str, +) -> None: + with pytest.raises(ValueError, match="majuscule"): + valide_complexite(mot_de_passe) + + +@pytest.mark.parametrize("mot_de_passe", ["ABCDEFG1×", "ABCDEFG1÷"]) # noqa: RUF001 +def test_valide_complexite_rejects_a_password_without_lowercase_despite_times_or_divide( + mot_de_passe: str, +) -> None: + with pytest.raises(ValueError, match="minuscule"): + valide_complexite(mot_de_passe) diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 52cde71..0c697b6 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -5,6 +5,7 @@ from typing import Any from uuid import UUID, uuid4 import pytest +from fastapi import BackgroundTasks from app.core.principal import Principal from app.core.roles import AccountKind, Role @@ -568,13 +569,16 @@ async def test_change_password_refuses_a_wrong_current_password() -> None: async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None: compte = FauxCompte() attirail = fabrique_service(compte=compte) + taches = BackgroundTasks() await attirail.service.request_password_reset( - email=compte.email, client_ip="203.0.113.10", user_agent="pytest" + email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches ) assert attirail.jetons_reset.invalidations == [compte.id] assert attirail.jetons_reset.crees == [compte.id] + assert attirail.mailer.envois == [], "l'envoi doit être différé, pas fait dans la réponse" + await taches() 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] @@ -582,10 +586,15 @@ async def test_request_password_reset_emails_a_link_when_the_account_exists() -> async def test_request_password_reset_stays_silent_when_the_account_is_unknown() -> None: attirail = fabrique_service(compte=None) + taches = BackgroundTasks() await attirail.service.request_password_reset( - email="inconnu@enervision.fr", client_ip="203.0.113.10", user_agent="pytest" + email="inconnu@enervision.fr", + client_ip="203.0.113.10", + user_agent="pytest", + background_tasks=taches, ) + await taches() assert attirail.jetons_reset.crees == [] assert attirail.mailer.envois == [] @@ -595,10 +604,12 @@ async def test_request_password_reset_stays_silent_when_the_account_is_unknown() async def test_request_password_reset_stays_silent_when_the_account_is_inactive() -> None: compte = FauxCompte(is_active=False) attirail = fabrique_service(compte=compte) + taches = BackgroundTasks() await attirail.service.request_password_reset( - email=compte.email, client_ip="203.0.113.10", user_agent="pytest" + email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches ) + await taches() assert attirail.jetons_reset.crees == [] assert attirail.mailer.envois == [] @@ -606,15 +617,37 @@ async def test_request_password_reset_stays_silent_when_the_account_is_inactive( 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)) + taches = BackgroundTasks() with pytest.raises(RateLimitedError): await attirail.service.request_password_reset( - email="operateur@enervision.fr", client_ip="203.0.113.10", user_agent="pytest" + email="operateur@enervision.fr", + client_ip="203.0.113.10", + user_agent="pytest", + background_tasks=taches, ) + await taches() assert attirail.mailer.envois == [] +async def test_request_password_reset_logs_instead_of_raising_when_the_mailer_fails() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + taches = BackgroundTasks() + + async def echoue(*, to: str, reset_url: str) -> None: + raise RuntimeError("relais SMTP indisponible") + + attirail.mailer.send_password_reset_email = echoue # type: ignore[method-assign] + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches + ) + + await taches() + + async def test_confirm_password_reset_revokes_every_session_then_reopens_the_current_one() -> None: compte = FauxCompte() jetons_reset = FauxDepotJetonsReset( @@ -637,6 +670,25 @@ async def test_confirm_password_reset_revokes_every_session_then_reopens_the_cur assert "auth.password_reset_self_service" in attirail.audit.lignes[0][0] +async def test_confirm_password_reset_rejects_a_token_for_an_account_disabled_since() -> None: + compte = FauxCompte(is_active=False) + jetons_reset = FauxDepotJetonsReset( + revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id) + ) + attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset) + + with pytest.raises(InvalidOrExpiredResetTokenError): + 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.comptes.mots_de_passe_changes == 0 + assert attirail.jetons.revocations_par_compte == [] + + async def test_confirm_password_reset_rejects_an_invalid_or_expired_token() -> None: attirail = fabrique_service(jetons_reset=FauxDepotJetonsReset(revendique=None)) diff --git a/apps/frontend/src/app/shared/validators/password.validator.spec.ts b/apps/frontend/src/app/shared/validators/password.validator.spec.ts new file mode 100644 index 0000000..455ee36 --- /dev/null +++ b/apps/frontend/src/app/shared/validators/password.validator.spec.ts @@ -0,0 +1,26 @@ +import { FormControl } from '@angular/forms'; +import { passwordValidators } from './password.validator'; + +function estValide(motDePasse: string): boolean { + return new FormControl(motDePasse, passwordValidators).valid; +} + +describe('passwordValidators', () => { + it('accepte un mot de passe couvrant les quatre classes', () => { + expect(estValide('Un-mot-de-passe1!')).toBe(true); + }); + + it('accepte un mot de passe accentué (alignement avec le backend, ex: "Sécurité1")', () => { + expect(estValide('Sécurité1!')).toBe(true); + }); + + it('refuse un mot de passe sans majuscule même avec un "×" ou un "÷"', () => { + expect(estValide('abcdefg1×')).toBe(false); + expect(estValide('abcdefg1÷')).toBe(false); + }); + + it('refuse un mot de passe sans minuscule même avec un "×" ou un "÷"', () => { + expect(estValide('ABCDEFG1×')).toBe(false); + expect(estValide('ABCDEFG1÷')).toBe(false); + }); +}); diff --git a/apps/frontend/src/app/shared/validators/password.validator.ts b/apps/frontend/src/app/shared/validators/password.validator.ts index fac1359..9f95863 100644 --- a/apps/frontend/src/app/shared/validators/password.validator.ts +++ b/apps/frontend/src/app/shared/validators/password.validator.ts @@ -1,3 +1,9 @@ +// Contrainte : `PASSWORD_PATTERN` doit rester identique au validateur Pydantic de +// `app/schemas/auth.py` côté backend (mêmes plages de majuscules/minuscules, excluant +// × et ÷, mêmes chiffres 0-9, même jeu de caractères spéciaux). `\w`/`\d` divergent entre +// JavaScript (ASCII) et Python (Unicode) : une négation aurait accepté ou rejeté un même +// mot de passe différemment d'un côté à l'autre (ex. "Sécurité1"). + import { Validators } from '@angular/forms'; export const PASSWORD_MIN_LENGTH = 8; @@ -5,7 +11,11 @@ export const PASSWORD_MAX_LENGTH = 128; export const PASSWORD_HINT = '8 à 128 caractères, avec au moins 1 majuscule, 1 minuscule, 1 chiffre et 1 caractère spécial'; -const PASSWORD_PATTERN = /^(?=.*[A-ZÀ-Ý])(?=.*[a-zà-ÿ])(?=.*\d)(?=.*[^\w\s]).*$/; +const SPECIAL_CHARACTERS = '!@#$%^&*()\\-_=+[\\]{};:,.?'; +const PASSWORD_PATTERN = new RegExp( + `^(?=.*[A-ZÀ-ÖØ-Þ])(?=.*[a-zà-öø-þ])` + + `(?=.*[0-9])(?=.*[${SPECIAL_CHARACTERS}]).*$`, +); export const passwordValidators = [ Validators.required, From 063092f2c72441515257a578c9c9e2f06aa9e57a Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 14:40:53 +0200 Subject: [PATCH 4/8] fix(frontend): le rafraichissement de session au demarrage ne doit pas ecraser un lien de reset Le refresh de session lance par provideAppInitializer echoue silencieusement sans cookie valide, mais l'intercepteur forcait quand meme un router.navigate(['/login']) sur le 401 resultant, ecrasant la navigation vers /reset-password?token=... venue de l'email. L'intercepteur ne redirige plus quand on est deja sur une route invitee (login, forgot-password, reset-password). --- .../interceptors/auth-interceptor.spec.ts | 18 ++++++++++++++- .../app/core/interceptors/auth-interceptor.ts | 22 ++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts index 8f74cd8..9064aaf 100644 --- a/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts @@ -41,7 +41,10 @@ describe('authInterceptor', () => { httpMock = TestBed.inject(HttpTestingController); }); - afterEach(() => httpMock.verify()); + afterEach(() => { + httpMock.verify(); + vi.restoreAllMocks(); + }); it('ajoute le header Authorization quand un token est disponible', () => { http.get('/api/v1/stats/summary').subscribe(); @@ -97,6 +100,19 @@ describe('authInterceptor', () => { expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); }); + it("ne redirige pas vers /login sur un 401 de /auth/refresh si on est déjà sur /reset-password", () => { + vi.spyOn(window, 'location', 'get').mockReturnValue({ + pathname: '/reset-password', + } as Location); + + http.post('/api/v1/auth/refresh', {}).subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/auth/refresh'); + req.flush({}, { status: 401, statusText: 'Unauthorized' }); + + expect(authMock.clearSession).toHaveBeenCalled(); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + it('rafraîchit puis rejoue la requête sur un 401 avec error="expired"', () => { authMock.refreshShared.mockReturnValue(of({ access_token: 'new-token' })); authMock.getAccessToken.mockReturnValueOnce('old-token').mockReturnValue('new-token'); diff --git a/apps/frontend/src/app/core/interceptors/auth-interceptor.ts b/apps/frontend/src/app/core/interceptors/auth-interceptor.ts index 46ba124..16f3047 100644 --- a/apps/frontend/src/app/core/interceptors/auth-interceptor.ts +++ b/apps/frontend/src/app/core/interceptors/auth-interceptor.ts @@ -11,6 +11,16 @@ function parseAuthError(response: HttpErrorResponse): string | null { return match ? match[1] : null; } +const ROUTES_INVITEES = ['/login', '/forgot-password', '/reset-password']; + +// Piège : le rafraîchissement de session lancé au démarrage de l'app (provideAppInitializer) +// échoue silencieusement sans cookie valide. `window.location.pathname` (pas `router.url`, +// pas encore fiable à ce stade) évite qu'un 401 de fond écrase la navigation vers le lien de +// reset reçu par email. +function surRouteInvitee(): boolean { + return ROUTES_INVITEES.some((chemin) => window.location.pathname.startsWith(chemin)); +} + export const authInterceptor: HttpInterceptorFn = (req, next) => { const auth = inject(AuthService); const router = inject(Router); @@ -43,7 +53,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { if (req.url.endsWith('/auth/refresh')) { auth.clearSession(); - router.navigate(['/login']); + if (!surRouteInvitee()) { + router.navigate(['/login']); + } return throwError(() => error); } @@ -51,7 +63,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { if (kind === 'invalid_token') { auth.clearSession(); - router.navigate(['/login']); + if (!surRouteInvitee()) { + router.navigate(['/login']); + } return throwError(() => error); } @@ -65,7 +79,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { }), catchError((refreshError) => { auth.clearSession(); - router.navigate(['/login']); + if (!surRouteInvitee()) { + router.navigate(['/login']); + } return throwError(() => refreshError); }) ); From 7674955637aea2b3b87cf17d09a0671408230a38 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 14:49:46 +0200 Subject: [PATCH 5/8] fix(frontend): un lien de reset absent ou expire renvoie vers login avec un message standard Avant, un token absent affichait un message inline sur /reset-password, et un token invalide/expire ne se voyait qu'apres soumission du formulaire. Les deux cas redirigent maintenant vers /login avec le motif "lien-expire", qui y affiche le message standard "Ce lien de reinitialisation est invalide ou a expire. Connectez-vous ou redemandez-en un." --- .../src/app/features/auth/login/login.spec.ts | 47 ++++++++++++++----- .../src/app/features/auth/login/login.ts | 10 +++- .../auth/reset-password/reset-password.html | 4 +- .../reset-password/reset-password.spec.ts | 27 ++++++++++- .../auth/reset-password/reset-password.ts | 17 +++++-- .../app/shared/models/auth-redirect-reason.ts | 3 ++ 6 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 apps/frontend/src/app/shared/models/auth-redirect-reason.ts diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index d39298d..d100e5e 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -1,28 +1,43 @@ import { TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; import { Login } from './login'; import { AuthService } from '../../../core/services/auth.service'; +import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; + +function configure(queryParams: Record = {}) { + const authMock = { login: vi.fn() }; + const routerMock = { navigate: vi.fn() }; + + return { + authMock, + routerMock, + testBed: TestBed.configureTestingModule({ + imports: [Login, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: convertToParamMap(queryParams) } }, + }, + ], + }), + }; +} describe('Login', () => { let authMock: { login: ReturnType }; let routerMock: { navigate: ReturnType }; beforeEach(async () => { - authMock = { login: vi.fn() }; - routerMock = { navigate: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [Login, ReactiveFormsModule], - providers: [ - { provide: AuthService, useValue: authMock }, - { provide: Router, useValue: routerMock }, - { provide: ActivatedRoute, useValue: {} }, - ], - }).compileComponents(); + const attirail = configure(); + authMock = attirail.authMock; + routerMock = attirail.routerMock; + await attirail.testBed.compileComponents(); }); it('ne soumet pas si le formulaire est invalide', () => { @@ -85,6 +100,14 @@ describe('Login', () => { expect(errorEl?.textContent).toContain('30s'); }); + it('affiche le message standard quand on arrive avec ?motif=lien-expire', async () => { + const attirail = configure({ motif: MOTIF_LIEN_RESET_INVALIDE }); + await attirail.testBed.compileComponents(); + const fixture = TestBed.createComponent(Login); + + expect(fixture.componentInstance.errorMessage()).toContain('expiré'); + }); + it('désactive le bouton tant que le formulaire est invalide', () => { const fixture = TestBed.createComponent(Login); fixture.detectChanges(); diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 871e7cc..f9bd085 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -1,8 +1,9 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; -import { Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; +import { MESSAGE_LIEN_RESET_INVALIDE, MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; @Component({ selector: 'app-login', @@ -15,8 +16,13 @@ export class Login { private fb = inject(FormBuilder); private auth = inject(AuthService); private router = inject(Router); + private route = inject(ActivatedRoute); - errorMessage = signal(null); + errorMessage = signal( + this.route.snapshot.queryParamMap.get('motif') === MOTIF_LIEN_RESET_INVALIDE + ? MESSAGE_LIEN_RESET_INVALIDE + : null, + ); retryAfterSeconds = signal(null); isLoading = signal(false); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.html b/apps/frontend/src/app/features/auth/reset-password/reset-password.html index eed77a8..57d202e 100644 --- a/apps/frontend/src/app/features/auth/reset-password/reset-password.html +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.html @@ -2,9 +2,7 @@

Nouveau mot de passe

- @if (!hasToken) { -

Ce lien est incomplet. Redemandez un lien de réinitialisation.

- } @else { + @if (hasToken) {

Choisissez votre nouveau mot de passe

diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts index 7e212cd..a14cb78 100644 --- a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts @@ -6,6 +6,7 @@ import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; import { ResetPassword } from './reset-password'; import { AuthService } from '../../../core/services/auth.service'; +import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; function configure(token: string | null) { return TestBed.configureTestingModule({ @@ -22,11 +23,17 @@ function configure(token: string | null) { } describe('ResetPassword', () => { - it("signale un lien incomplet quand le jeton est absent de l'URL", async () => { + it("redirige vers /login avec le motif standard quand le jeton est absent de l'URL", async () => { await configure(null); const fixture = TestBed.createComponent(ResetPassword); + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + + fixture.detectChanges(); expect(fixture.componentInstance.hasToken).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/login'], { + queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE }, + }); }); it('ne soumet pas si le mot de passe ne respecte pas la politique de complexité', async () => { @@ -59,16 +66,32 @@ describe('ResetPassword', () => { expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); }); - it('affiche un message dédié quand le lien est invalide ou expiré', async () => { + it('redirige vers /login avec le motif standard quand le lien est invalide ou expiré', async () => { await configure('un-secret-perime'); const fixture = TestBed.createComponent(ResetPassword); const component = fixture.componentInstance; const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 }))); component.onSubmit(); + expect(router.navigate).toHaveBeenCalledWith(['/login'], { + queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE }, + }); + }); + + it('affiche un message générique sur une erreur inattendue (pas 400)', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))); + + component.onSubmit(); + expect(component.errorMessage()).toContain('invalide'); }); }); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts index 6754fa7..3fa56fa 100644 --- a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts @@ -1,9 +1,10 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; +import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; @Component({ selector: 'app-reset-password', @@ -12,7 +13,7 @@ import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/pa templateUrl: './reset-password.html', styleUrl: './reset-password.scss', }) -export class ResetPassword { +export class ResetPassword implements OnInit { private fb = inject(FormBuilder); private auth = inject(AuthService); private router = inject(Router); @@ -29,6 +30,12 @@ export class ResetPassword { new_password: ['', passwordValidators], }); + ngOnInit(): void { + if (!this.hasToken) { + this.redirigeVersLoginLienInvalide(); + } + } + onSubmit(): void { if (this.form.invalid || !this.hasToken) return; @@ -42,11 +49,15 @@ export class ResetPassword { error: (error: HttpErrorResponse) => { this.isLoading.set(false); if (error.status === 400) { - this.errorMessage.set('Ce lien est invalide, déjà utilisé, ou a expiré. Redemandez-en un.'); + this.redirigeVersLoginLienInvalide(); return; } this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`); }, }); } + + private redirigeVersLoginLienInvalide(): void { + this.router.navigate(['/login'], { queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE } }); + } } diff --git a/apps/frontend/src/app/shared/models/auth-redirect-reason.ts b/apps/frontend/src/app/shared/models/auth-redirect-reason.ts new file mode 100644 index 0000000..7feb0de --- /dev/null +++ b/apps/frontend/src/app/shared/models/auth-redirect-reason.ts @@ -0,0 +1,3 @@ +export const MOTIF_LIEN_RESET_INVALIDE = 'lien-expire'; +export const MESSAGE_LIEN_RESET_INVALIDE = + 'Ce lien de réinitialisation est invalide ou a expiré. Connectez-vous ou redemandez-en un.'; From e381e0de098feb9ed9f951f1084564017d0168e2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 14:57:05 +0200 Subject: [PATCH 6/8] feat(frontend): checklist de complexite du mot de passe sur reset-password Remplace l'indice statique sous le champ nouveau mot de passe par une checklist qui coche chaque regle (longueur, majuscule, minuscule, chiffre, caractere special) au fur et a mesure de la saisie. Les regles individuelles (PASSWORD_REQUIREMENTS) sont exposees depuis le meme validateur que PASSWORD_PATTERN pour rester la seule source de verite. --- .../auth/reset-password/reset-password.html | 2 +- .../auth/reset-password/reset-password.ts | 6 ++- .../password-requirements.html | 8 ++++ .../password-requirements.scss | 29 ++++++++++++++ .../password-requirements.spec.ts | 39 +++++++++++++++++++ .../password-requirements.ts | 19 +++++++++ .../shared/validators/password.validator.ts | 15 +++++++ 7 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/app/shared/components/password-requirements/password-requirements.html create mode 100644 apps/frontend/src/app/shared/components/password-requirements/password-requirements.scss create mode 100644 apps/frontend/src/app/shared/components/password-requirements/password-requirements.spec.ts create mode 100644 apps/frontend/src/app/shared/components/password-requirements/password-requirements.ts diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.html b/apps/frontend/src/app/features/auth/reset-password/reset-password.html index 57d202e..acb0090 100644 --- a/apps/frontend/src/app/features/auth/reset-password/reset-password.html +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.html @@ -12,7 +12,7 @@ formControlName="new_password" autocomplete="new-password" /> - {{ passwordHint }} + @if (errorMessage()) {

{{ errorMessage() }}

diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts index 3fa56fa..b70975e 100644 --- a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts @@ -1,15 +1,17 @@ import { Component, OnInit, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; +import { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements'; import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; @Component({ selector: 'app-reset-password', standalone: true, - imports: [ReactiveFormsModule, RouterLink], + imports: [ReactiveFormsModule, RouterLink, PasswordRequirementsChecklist], templateUrl: './reset-password.html', styleUrl: './reset-password.scss', }) @@ -30,6 +32,8 @@ export class ResetPassword implements OnInit { new_password: ['', passwordValidators], }); + password = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' }); + ngOnInit(): void { if (!this.hasToken) { this.redirigeVersLoginLienInvalide(); diff --git a/apps/frontend/src/app/shared/components/password-requirements/password-requirements.html b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.html new file mode 100644 index 0000000..bc7ed54 --- /dev/null +++ b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.html @@ -0,0 +1,8 @@ +
    + @for (requirement of requirements(); track requirement.label) { +
  • + {{ requirement.met ? '✓' : '○' }} + {{ requirement.label }} +
  • + } +
diff --git a/apps/frontend/src/app/shared/components/password-requirements/password-requirements.scss b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.scss new file mode 100644 index 0000000..b5cb32b --- /dev/null +++ b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.scss @@ -0,0 +1,29 @@ +:host { + display: block; +} + +.password-requirements { + list-style: none; + margin: 0.25rem 0 0; + padding: 0; + font-size: 0.8rem; + line-height: 1.5; + + li { + display: flex; + align-items: center; + gap: 0.4rem; + } + + .password-requirements-icon { + font-weight: 700; + } + + .unmet { + color: #9ca3af; + } + + .met { + color: #16a34a; + } +} diff --git a/apps/frontend/src/app/shared/components/password-requirements/password-requirements.spec.ts b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.spec.ts new file mode 100644 index 0000000..23c4bbc --- /dev/null +++ b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import { PasswordRequirementsChecklist } from './password-requirements'; + +describe('PasswordRequirementsChecklist', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PasswordRequirementsChecklist], + }).compileComponents(); + }); + + it('ne coche aucune règle pour un mot de passe vide', () => { + const fixture = TestBed.createComponent(PasswordRequirementsChecklist); + fixture.componentRef.setInput('password', ''); + fixture.detectChanges(); + + expect(fixture.componentInstance.requirements().every((r) => !r.met)).toBe(true); + }); + + it('ne coche que les règles satisfaites pour un mot de passe partiel', () => { + const fixture = TestBed.createComponent(PasswordRequirementsChecklist); + fixture.componentRef.setInput('password', 'abcdefgh'); + fixture.detectChanges(); + + const parLabel = new Map(fixture.componentInstance.requirements().map((r) => [r.label, r.met])); + expect(parLabel.get('8 caractères minimum')).toBe(true); + expect(parLabel.get('1 minuscule')).toBe(true); + expect(parLabel.get('1 majuscule')).toBe(false); + expect(parLabel.get('1 chiffre')).toBe(false); + expect(parLabel.get('1 caractère spécial')).toBe(false); + }); + + it('coche toutes les règles pour un mot de passe conforme', () => { + const fixture = TestBed.createComponent(PasswordRequirementsChecklist); + fixture.componentRef.setInput('password', 'Un-nouveau-mot-de-passe1!'); + fixture.detectChanges(); + + expect(fixture.componentInstance.requirements().every((r) => r.met)).toBe(true); + }); +}); diff --git a/apps/frontend/src/app/shared/components/password-requirements/password-requirements.ts b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.ts new file mode 100644 index 0000000..36a4413 --- /dev/null +++ b/apps/frontend/src/app/shared/components/password-requirements/password-requirements.ts @@ -0,0 +1,19 @@ +import { Component, computed, input } from '@angular/core'; +import { PASSWORD_REQUIREMENTS } from '../../validators/password.validator'; + +@Component({ + selector: 'app-password-requirements', + standalone: true, + templateUrl: './password-requirements.html', + styleUrl: './password-requirements.scss', +}) +export class PasswordRequirementsChecklist { + password = input(''); + + requirements = computed(() => + PASSWORD_REQUIREMENTS.map((requirement) => ({ + label: requirement.label, + met: requirement.test(this.password()), + })), + ); +} diff --git a/apps/frontend/src/app/shared/validators/password.validator.ts b/apps/frontend/src/app/shared/validators/password.validator.ts index 9f95863..78d4066 100644 --- a/apps/frontend/src/app/shared/validators/password.validator.ts +++ b/apps/frontend/src/app/shared/validators/password.validator.ts @@ -23,3 +23,18 @@ export const passwordValidators = [ Validators.maxLength(PASSWORD_MAX_LENGTH), Validators.pattern(PASSWORD_PATTERN), ]; + +export interface PasswordRequirement { + label: string; + test: (value: string) => boolean; +} + +const SPECIAL_REGEX = new RegExp(`[${SPECIAL_CHARACTERS}]`); + +export const PASSWORD_REQUIREMENTS: PasswordRequirement[] = [ + { label: `${PASSWORD_MIN_LENGTH} caractères minimum`, test: (v) => v.length >= PASSWORD_MIN_LENGTH }, + { label: '1 majuscule', test: (v) => /[A-ZÀ-ÖØ-Þ]/.test(v) }, + { label: '1 minuscule', test: (v) => /[a-zà-öø-þ]/.test(v) }, + { label: '1 chiffre', test: (v) => /[0-9]/.test(v) }, + { label: '1 caractère spécial', test: (v) => SPECIAL_REGEX.test(v) }, +]; From d7f775f9f752ecee7b87f3700b0186804deccd7b Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 15:28:50 +0200 Subject: [PATCH 7/8] feat(auth): verifie le lien de reset des le chargement, sans le consommer Ajoute GET /auth/reset-password/validate (lecture seule, sans rate limit : le jeton est un secret de 256 bits non brute-forcable) pour que la page reset-password redirige immediatement vers /login si le lien est invalide ou expire, plutot que d'attendre la soumission du formulaire. La verification a la soumission (confirm_password_reset) reste la seule source de verite atomique. --- apps/backend/app/api/v1/endpoints/auth.py | 10 +++++ .../app/repositories/password_reset_token.py | 12 +++++- apps/backend/app/schemas/auth.py | 4 ++ apps/backend/app/services/auth.py | 7 ++++ apps/backend/tests/api/test_auth.py | 38 ++++++++++++++++++- .../repositories/test_password_reset_token.py | 28 ++++++++++++++ apps/backend/tests/services/test_auth.py | 16 +++++++- .../app/core/services/auth.service.spec.ts | 13 +++++++ .../src/app/core/services/auth.service.ts | 6 +++ .../auth/reset-password/reset-password.html | 6 ++- .../reset-password/reset-password.spec.ts | 34 ++++++++++++++++- .../auth/reset-password/reset-password.ts | 12 ++++++ 12 files changed, 181 insertions(+), 5 deletions(-) diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 975273d..548c0e2 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -27,6 +27,7 @@ from app.schemas.auth import ( PasswordChangeRequest, PrincipalResponse, ResetPasswordRequest, + ResetTokenValidationResponse, TokenResponse, ) from app.schemas.errors import ErrorResponse @@ -320,6 +321,15 @@ async def forgot_password( ) from erreur +@router.get( + "/reset-password/validate", + response_model=ResetTokenValidationResponse, + summary="Vérifie sans le consommer si un lien de réinitialisation est encore valide", +) +async def validate_reset_token(token: str, service: AuthServiceDep) -> ResetTokenValidationResponse: + return ResetTokenValidationResponse(valid=await service.is_reset_token_valid(token=token)) + + @router.post( "/reset-password", response_model=TokenResponse, diff --git a/apps/backend/app/repositories/password_reset_token.py b/apps/backend/app/repositories/password_reset_token.py index 13a660e..67eafbd 100644 --- a/apps/backend/app/repositories/password_reset_token.py +++ b/apps/backend/app/repositories/password_reset_token.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime from uuid import UUID -from sqlalchemy import func, update +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.models.password_reset_token import PasswordResetToken @@ -58,6 +58,16 @@ class PasswordResetTokenRepository: return None return ConsumedResetToken(id=ligne.id, user_id=ligne.user_id) + # Piège : simple SELECT, volontairement pas atomique avec la consommation. Sert seulement + # au feedback UX (jeton encore valide ?) ; `consume()` reste la seule source de vérité. + async def exists_valid(self, token_hash: bytes) -> bool: + requete = select(PasswordResetToken.id).where( + PasswordResetToken.token_hash == token_hash, + PasswordResetToken.consumed_at.is_(None), + PasswordResetToken.expires_at > func.clock_timestamp(), + ) + return (await self._session.execute(requete)).first() is not None + async def invalidate_all_for_user(self, user_id: UUID) -> int: resultat = await self._session.execute( update(PasswordResetToken) diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py index 6ad50ef..f345ab7 100644 --- a/apps/backend/app/schemas/auth.py +++ b/apps/backend/app/schemas/auth.py @@ -84,6 +84,10 @@ class PrincipalResponse(BaseModel): return cls.model_validate(principal) +class ResetTokenValidationResponse(BaseModel): + valid: bool + + class TokenResponse(BaseModel): access_token: str token_type: Literal["bearer"] = "bearer" # noqa: S105 diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index de83231..9fac1e1 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -280,6 +280,13 @@ class AuthService: except Exception: logger.exception("auth.password_reset.mail_failed") + # Piège : lecture seule, pas d'appel à `consume()`. Aucune limitation de débit n'est + # nécessaire ici : le jeton est un secret de 256 bits (`generate_refresh_secret`), donc + # non brute-forçable, et cette route n'apprend rien sur l'existence d'un compte ou d'un + # email, seulement si le lien déjà en main du visiteur est encore valide. + async def is_reset_token_valid(self, token: str) -> bool: + return await self._reset_tokens.exists_valid(fingerprint_refresh(token)) + async def confirm_password_reset( self, *, token: str, new_password: str, client_ip: str | None, user_agent: str | None ) -> AuthenticatedSession: diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py index 44c25e6..dd8256b 100644 --- a/apps/backend/tests/api/test_auth.py +++ b/apps/backend/tests/api/test_auth.py @@ -28,12 +28,16 @@ PRINCIPAL = Principal( class FauxService: - def __init__(self, erreur: Exception | None = None) -> None: + def __init__(self, erreur: Exception | None = None, *, jeton_valide: bool = True) -> None: self._erreur = erreur + self._jeton_valide = jeton_valide async def refresh(self, **_: object) -> AuthenticatedSession: return await self.authenticate() + async def is_reset_token_valid(self, **_: object) -> bool: + return self._jeton_valide + async def logout(self, **_: object) -> None: return None @@ -259,6 +263,38 @@ async def test_forgot_password_rejects_a_malformed_email( assert response.status_code == 422 +@pytest.fixture +def fake_auth_service_reset_validity(app: FastAPI) -> Iterator[list[bool]]: + programme = [True] + app.dependency_overrides[get_auth_service] = lambda: FauxService(jeton_valide=programme[0]) + yield programme + app.dependency_overrides.pop(get_auth_service, None) + + +async def test_validate_reset_token_reports_a_living_token( + fake_auth_service_reset_validity: list[bool], client: AsyncClient +) -> None: + response = await client.get( + "/api/v1/auth/reset-password/validate", params={"token": "un-secret-opaque"} + ) + + assert response.status_code == 200 + assert response.json() == {"valid": True} + + +async def test_validate_reset_token_reports_an_invalid_or_expired_token( + fake_auth_service_reset_validity: list[bool], client: AsyncClient +) -> None: + fake_auth_service_reset_validity[0] = False + + response = await client.get( + "/api/v1/auth/reset-password/validate", params={"token": "un-secret-perime"} + ) + + assert response.status_code == 200 + assert response.json() == {"valid": False} + + async def test_reset_password_returns_the_token_and_the_cookie_on_success( fake_auth_service: list[Exception | None], client: AsyncClient ) -> None: diff --git a/apps/backend/tests/repositories/test_password_reset_token.py b/apps/backend/tests/repositories/test_password_reset_token.py index e25518c..fe99800 100644 --- a/apps/backend/tests/repositories/test_password_reset_token.py +++ b/apps/backend/tests/repositories/test_password_reset_token.py @@ -89,6 +89,34 @@ async def test_invalidate_all_for_user_only_touches_living_tokens( assert second_passage == 0 +async def test_exists_valid_is_true_for_a_living_token(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session)) + + assert await depot.exists_valid(fingerprint_refresh(secret)) is True + + +async def test_exists_valid_is_false_for_an_expired_token(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1)) + + assert await depot.exists_valid(fingerprint_refresh(secret)) is False + + +async def test_exists_valid_is_false_once_the_token_is_consumed(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session)) + await depot.consume(fingerprint_refresh(secret)) + + assert await depot.exists_valid(fingerprint_refresh(secret)) is False + + +async def test_exists_valid_is_false_for_an_unknown_fingerprint(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + + assert await depot.exists_valid(fingerprint_refresh(generate_refresh_secret())) is False + + async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( session: AsyncSession, ) -> None: diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 0c697b6..50a906a 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -181,8 +181,11 @@ class FausseTransaction: class FauxDepotJetonsReset: - def __init__(self, revendique: ConsumedResetToken | None = None) -> None: + def __init__( + self, revendique: ConsumedResetToken | None = None, *, valide: bool = False + ) -> None: self.revendique = revendique + self.valide = valide self.crees: list[UUID] = [] self.invalidations: list[UUID] = [] @@ -192,6 +195,9 @@ class FauxDepotJetonsReset: async def consume(self, token_hash: bytes) -> ConsumedResetToken | None: return self.revendique + async def exists_valid(self, token_hash: bytes) -> bool: + return self.valide + async def invalidate_all_for_user(self, user_id: UUID) -> int: self.invalidations.append(user_id) return len(self.invalidations) @@ -670,6 +676,14 @@ async def test_confirm_password_reset_revokes_every_session_then_reopens_the_cur assert "auth.password_reset_self_service" in attirail.audit.lignes[0][0] +async def test_is_reset_token_valid_reflects_the_repository() -> None: + attirail_valide = fabrique_service(jetons_reset=FauxDepotJetonsReset(valide=True)) + attirail_invalide = fabrique_service(jetons_reset=FauxDepotJetonsReset(valide=False)) + + assert await attirail_valide.service.is_reset_token_valid("un-secret-opaque") is True + assert await attirail_invalide.service.is_reset_token_valid("un-secret-opaque") is False + + async def test_confirm_password_reset_rejects_a_token_for_an_account_disabled_since() -> None: compte = FauxCompte(is_active=False) jetons_reset = FauxDepotJetonsReset( diff --git a/apps/frontend/src/app/core/services/auth.service.spec.ts b/apps/frontend/src/app/core/services/auth.service.spec.ts index bff86c4..c51c8eb 100644 --- a/apps/frontend/src/app/core/services/auth.service.spec.ts +++ b/apps/frontend/src/app/core/services/auth.service.spec.ts @@ -83,4 +83,17 @@ describe('AuthService', () => { expect(result).toEqual(tokenResponse.principal); }); + + it('vérifie la validité du jeton de reset via GET /auth/reset-password/validate', () => { + let result: { valid: boolean } | undefined; + service.validateResetToken('un-secret-opaque').subscribe((r) => (result = r)); + + const req = httpMock.expectOne( + `${environment.apiUrl}/auth/reset-password/validate?token=un-secret-opaque` + ); + expect(req.request.method).toBe('GET'); + req.flush({ valid: true }); + + expect(result).toEqual({ valid: true }); + }); }); diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts index 9aa477a..c2d3e9c 100644 --- a/apps/frontend/src/app/core/services/auth.service.ts +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -83,4 +83,10 @@ export class AuthService { .post(`${environment.apiUrl}/auth/reset-password`, payload, { withCredentials: true }) .pipe(tap((response) => this.setSession(response))); } + + validateResetToken(token: string): Observable<{ valid: boolean }> { + return this.http.get<{ valid: boolean }>(`${environment.apiUrl}/auth/reset-password/validate`, { + params: { token }, + }); + } } diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.html b/apps/frontend/src/app/features/auth/reset-password/reset-password.html index acb0090..fad2f7e 100644 --- a/apps/frontend/src/app/features/auth/reset-password/reset-password.html +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.html @@ -2,7 +2,7 @@

Nouveau mot de passe

- @if (hasToken) { + @if (hasToken && !isCheckingToken()) {

Choisissez votre nouveau mot de passe

@@ -23,6 +23,10 @@ } + @if (hasToken && isCheckingToken()) { +

Vérification du lien...

+ } + diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts index a14cb78..38e30b5 100644 --- a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts @@ -12,7 +12,13 @@ function configure(token: string | null) { return TestBed.configureTestingModule({ imports: [ResetPassword, ReactiveFormsModule], providers: [ - { provide: AuthService, useValue: { resetPassword: vi.fn() } }, + { + provide: AuthService, + useValue: { + resetPassword: vi.fn(), + validateResetToken: vi.fn().mockReturnValue(of({ valid: true })), + }, + }, { provide: Router, useValue: { navigate: vi.fn() } }, { provide: ActivatedRoute, @@ -36,6 +42,32 @@ describe('ResetPassword', () => { }); }); + it('vérifie le jeton sans le consommer dès le chargement de la page', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const auth = TestBed.inject(AuthService) as unknown as { validateResetToken: ReturnType }; + + fixture.detectChanges(); + + expect(auth.validateResetToken).toHaveBeenCalledWith('un-secret-opaque'); + expect(fixture.componentInstance.isCheckingToken()).toBe(false); + }); + + it('redirige immédiatement vers /login si la vérification signale un jeton invalide', async () => { + await configure('un-secret-perime'); + TestBed.overrideProvider(AuthService, { + useValue: { resetPassword: vi.fn(), validateResetToken: vi.fn().mockReturnValue(of({ valid: false })) }, + }); + const fixture = TestBed.createComponent(ResetPassword); + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + + fixture.detectChanges(); + + expect(router.navigate).toHaveBeenCalledWith(['/login'], { + queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE }, + }); + }); + it('ne soumet pas si le mot de passe ne respecte pas la politique de complexité', async () => { await configure('un-secret-opaque'); const fixture = TestBed.createComponent(ResetPassword); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts index b70975e..64ad31e 100644 --- a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts @@ -33,11 +33,23 @@ export class ResetPassword implements OnInit { }); password = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' }); + isCheckingToken = signal(this.hasToken); ngOnInit(): void { if (!this.hasToken) { this.redirigeVersLoginLienInvalide(); + return; } + + this.auth.validateResetToken(this.token).subscribe({ + next: ({ valid }) => { + this.isCheckingToken.set(false); + if (!valid) { + this.redirigeVersLoginLienInvalide(); + } + }, + error: () => this.isCheckingToken.set(false), + }); } onSubmit(): void { From c741ffc827cd0f0c62d6ec57f77ba7c3315a9209 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 15:31:14 +0200 Subject: [PATCH 8/8] fix(auth): corrige la CI cassee par le nouvel endpoint de validation /auth/reset-password/validate manquait a la liste explicite des routes publiques (test_route_protection) et n'avait pas le modele de reponse 422 declare (openapi.json desynchronise du contrat genere). --- apps/backend/app/api/v1/endpoints/auth.py | 1 + apps/backend/openapi.json | 65 +++++++++++++++++++ .../tests/api/test_route_protection.py | 3 + 3 files changed, 69 insertions(+) diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 548c0e2..9fd374f 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -325,6 +325,7 @@ async def forgot_password( "/reset-password/validate", response_model=ResetTokenValidationResponse, summary="Vérifie sans le consommer si un lien de réinitialisation est encore valide", + responses=REPONSE_VALIDATION, ) async def validate_reset_token(token: str, service: AuthServiceDep) -> ResetTokenValidationResponse: return ResetTokenValidationResponse(valid=await service.is_reset_token_valid(token=token)) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 7a4fe8a..3f414fa 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -491,6 +491,58 @@ } } }, + "/api/v1/auth/reset-password/validate": { + "get": { + "tags": [ + "auth" + ], + "summary": "Vérifie sans le consommer si un lien de réinitialisation est encore valide", + "operationId": "validate_reset_token_api_v1_auth_reset_password_validate_get", + "parameters": [ + { + "name": "token", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetTokenValidationResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + } + } + }, "/api/v1/auth/reset-password": { "post": { "tags": [ @@ -2166,6 +2218,19 @@ ], "title": "ResetPasswordRequest" }, + "ResetTokenValidationResponse": { + "properties": { + "valid": { + "type": "boolean", + "title": "Valid" + } + }, + "type": "object", + "required": [ + "valid" + ], + "title": "ResetTokenValidationResponse" + }, "Role": { "type": "string", "enum": [ diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index 1080dce..734c9db 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -22,6 +22,9 @@ ROUTES_PUBLIQUES = frozenset( # 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"), + # Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un + # `Principal`. Le jeton est un secret de 256 bits, non brute-forçable. + ("GET", "/api/v1/auth/reset-password/validate"), ("GET", "/metrics"), } )