diff --git a/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py b/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py new file mode 100644 index 0000000..15fb453 --- /dev/null +++ b/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py @@ -0,0 +1,77 @@ +"""jetons de rafraichissement + +Revision ID: 821f71be74c0 +Revises: 517053a3c044 +Create Date: 2026-09-15 14:42:09.757949 + +Le jeton lui-meme n'est jamais stocke : seule son empreinte SHA-256 l'est, dans +`token_hash`. Un pg_dump qui fuiterait ne livrerait donc aucune session utilisable. + +L'index partiel `ix_refresh_token_vivants` sert la revocation en cascade et la +recherche des sessions actives, qui ne regardent jamais les lignes deja tournees. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "821f71be74c0" +down_revision: str | Sequence[str] | None = "517053a3c044" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +MOTIFS = "'logout', 'rotation', 'reuse_detected', 'password_change', 'admin'" +JETONS_VIVANTS = "revoked_at is null and rotated_at is null" + + +def upgrade() -> None: + op.create_table( + "refresh_token", + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column("family_id", sa.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("rotated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_reason", sa.Text(), nullable=True), + sa.Column("replaced_by", sa.UUID(), nullable=True), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.CheckConstraint( + f"revoked_reason is null or revoked_reason in ({MOTIFS})", + name="ck_refresh_token_revoked_reason", + ), + sa.ForeignKeyConstraint( + ["user_id"], ["app_user.id"], name="fk_refresh_token_user", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name="pk_refresh_token"), + sa.UniqueConstraint("token_hash", name="uq_refresh_token_hash"), + ) + op.create_index("ix_refresh_token_family", "refresh_token", ["family_id"]) + op.create_index("ix_refresh_token_user", "refresh_token", ["user_id"]) + op.create_index( + "ix_refresh_token_vivants", + "refresh_token", + ["user_id"], + postgresql_where=JETONS_VIVANTS, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_refresh_token_vivants", table_name="refresh_token", postgresql_where=JETONS_VIVANTS + ) + op.drop_index("ix_refresh_token_user", table_name="refresh_token") + op.drop_index("ix_refresh_token_family", table_name="refresh_token") + op.drop_table("refresh_token") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index a35d628..82dc357 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -23,6 +23,7 @@ from app.core.security import decode_access_token as decode_token from app.db.session import get_session from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy @@ -95,6 +96,7 @@ def get_auth_service( return AuthService( users=UserRepository(session), attempts=LoginAttemptRepository(session), + refresh_tokens=RefreshTokenRepository(session), audit=AuditLogRepository(session), hasher=hasher, transaction=session, @@ -105,6 +107,7 @@ def get_auth_service( max_failures_per_ip=settings.login_max_failures_per_ip, max_failures_per_identifier=settings.login_max_failures_per_identifier, ), + refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds), ) diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 0def0d4..4d57559 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -1,14 +1,59 @@ +# Piège : le jeton de rafraîchissement ne quitte jamais le cookie httpOnly, et le jeton +# 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 app.api.deps import AuthServiceDep, CurrentPrincipalDep, get_client_ip +from app.api.deps import ( + AuthServiceDep, + CurrentPrincipalDep, + SettingsDep, + get_client_ip, + require_trusted_origin, +) +from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse -from app.services.auth import InvalidCredentialsError, RateLimitedError +from app.services.auth import ( + AuthenticatedSession, + InvalidCredentialsError, + RateLimitedError, + SessionRejectedError, +) router = APIRouter() logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" +DETAIL_SESSION = "Session invalide" + + +def repond( + response: Response, settings: SettingsDep, session: AuthenticatedSession +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + response.set_cookie(**RefreshCookie.build(settings, session.refresh_secret).as_kwargs()) + return TokenResponse( + access_token=session.access_token, + expires_in=session.expires_in, + principal=PrincipalResponse.from_principal(session.principal), + ) + + +# Piège : une `HTTPException` construit sa propre réponse, donc tout en-tête posé sur la +# `Response` injectée est perdu. L'effacement du cookie doit voyager avec l'exception, +# sans quoi un navigateur garderait un cookie mort après une détection de réutilisation. +def entete_de_suppression(settings: SettingsDep) -> str: + temoin = Response() + temoin.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) + return temoin.headers["set-cookie"] + + +def lit_le_cookie(request: Request, settings: SettingsDep) -> str: + secret = request.cookies.get(cookie_name(settings)) + if not secret: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_SESSION) + return secret @router.post("/login", response_model=TokenResponse, summary="Ouvre une session") @@ -16,19 +61,16 @@ async def login( payload: LoginRequest, request: Request, response: Response, + settings: SettingsDep, service: AuthServiceDep, client_ip: str | None = Depends(get_client_ip), ) -> TokenResponse: - # Une réponse d'authentification ne doit jamais être conservée par un intermédiaire. response.headers["Cache-Control"] = "no-store" agent = request.headers.get("user-agent") try: session = await service.authenticate( - email=payload.email, - password=payload.password, - client_ip=client_ip, - user_agent=agent, + email=payload.email, password=payload.password, client_ip=client_ip, user_agent=agent ) except RateLimitedError as erreur: logger.warning("auth.rate_limited email=%s ip=%s", payload.email, client_ip) @@ -44,11 +86,76 @@ async def login( ) from erreur logger.info("auth.login.success user_id=%s ip=%s", session.principal.id, client_ip) - return TokenResponse( - access_token=session.access_token, - expires_in=session.expires_in, - principal=PrincipalResponse.from_principal(session.principal), - ) + return repond(response, settings, session) + + +@router.post( + "/refresh", + response_model=TokenResponse, + summary="Fait tourner la session", + dependencies=[Depends(require_trusted_origin)], +) +async def refresh( + 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.refresh( + secret=lit_le_cookie(request, settings), + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except SessionRejectedError as erreur: + logger.warning("auth.refresh.rejected ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=DETAIL_SESSION, + headers={ + "Set-Cookie": entete_de_suppression(settings), + "Cache-Control": "no-store", + }, + ) from erreur + + return repond(response, settings, session) + + +@router.post( + "/logout", + status_code=status.HTTP_204_NO_CONTENT, + summary="Ferme la session courante", + dependencies=[Depends(require_trusted_origin)], +) +async def logout( + request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep +) -> None: + response.headers["Cache-Control"] = "no-store" + secret = request.cookies.get(cookie_name(settings)) + if secret: + await service.logout(secret=secret) + response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) + + +@router.post( + "/logout-all", + status_code=status.HTTP_204_NO_CONTENT, + summary="Ferme toutes les sessions du compte", + dependencies=[Depends(require_trusted_origin)], +) +async def logout_all( + principal: CurrentPrincipalDep, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, +) -> None: + response.headers["Cache-Control"] = "no-store" + revoquees = await service.logout_all(principal) + logger.info("auth.logout_all user_id=%s sessions=%s", principal.id, revoquees) + response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) @router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") diff --git a/apps/backend/app/core/cookies.py b/apps/backend/app/core/cookies.py index f54dbff..1221085 100644 --- a/apps/backend/app/core/cookies.py +++ b/apps/backend/app/core/cookies.py @@ -47,6 +47,13 @@ class RefreshCookie: def as_kwargs(self) -> dict[str, Any]: return asdict(self) + def as_deletion_kwargs(self) -> dict[str, Any]: + # `Response.delete_cookie()` n'accepte ni `value` ni `max_age`, mais il exige le même + # nom, le même chemin et les mêmes attributs, sinon le navigateur garde le cookie. + arguments = asdict(self) + del arguments["value"], arguments["max_age"] + return arguments + def cookie_name(settings: Settings) -> str: if settings.cookies_are_secure: diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 222295d..9e65265 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -3,6 +3,7 @@ from app.models.audit_log import AuditLog from app.models.login_attempt import LoginAttempt +from app.models.refresh_token import RefreshToken from app.models.user import AppUser -__all__ = ["AppUser", "AuditLog", "LoginAttempt"] +__all__ = ["AppUser", "AuditLog", "LoginAttempt", "RefreshToken"] diff --git a/apps/backend/app/models/refresh_token.py b/apps/backend/app/models/refresh_token.py new file mode 100644 index 0000000..8153776 --- /dev/null +++ b/apps/backend/app/models/refresh_token.py @@ -0,0 +1,64 @@ +# Pourquoi : un jeton de rafraîchissement est une chaîne opaque, jamais un JWT. Il doit être +# révocable, donc cette ligne existe de toute façon ; le JWT n'ajouterait qu'un second chemin de +# signature. Surtout, la séparation devient structurelle : un JWT ne figure dans aucune ligne, +# une chaîne opaque échoue au décodage. Aucune confusion de type n'est possible. +# Piège : `expires_at` est absolu et hérité du prédécesseur à chaque rotation. S'il glissait, +# la promesse de sept jours serait fictive et une session active ne finirait jamais. + +import uuid +from datetime import datetime +from enum import StrEnum + +from sqlalchemy import CheckConstraint, 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 RevocationReason(StrEnum): + DECONNEXION = "logout" + ROTATION = "rotation" + REUTILISATION = "reuse_detected" + CHANGEMENT_MOT_DE_PASSE = "password_change" + ADMINISTRATION = "admin" + + +MOTIFS_AUTORISES = ", ".join(f"'{motif.value}'" for motif in RevocationReason) + + +class RefreshToken(Base): + __tablename__ = "refresh_token" + __table_args__ = ( + CheckConstraint( + f"revoked_reason is null or revoked_reason in ({MOTIFS_AUTORISES})", + name="ck_refresh_token_revoked_reason", + ), + Index("ix_refresh_token_family", "family_id"), + Index("ix_refresh_token_user", "user_id"), + Index( + "ix_refresh_token_vivants", + "user_id", + postgresql_where="revoked_at is null and rotated_at is null", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + family_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False) + 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) + rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + replaced_by: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=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/refresh_token.py b/apps/backend/app/repositories/refresh_token.py new file mode 100644 index 0000000..08d7980 --- /dev/null +++ b/apps/backend/app/repositories/refresh_token.py @@ -0,0 +1,106 @@ +# Piège : `claim_for_rotation()` est une seule instruction. Un SELECT puis un UPDATE +# laisseraient une fenêtre où deux onglets réussissent la même rotation. Zéro ligne retournée +# signifie donc, sans ambiguïté, que le jeton était déjà tourné, révoqué, expiré ou inconnu, et +# c'est `inspect()` qui départage ensuite ces cas. + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.refresh_token import RefreshToken, RevocationReason + + +@dataclass(frozen=True, slots=True) +class ClaimedToken: + id: UUID + family_id: UUID + user_id: UUID + expires_at: datetime + + +class RefreshTokenRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create( + self, + *, + user_id: UUID, + family_id: UUID, + token_hash: bytes, + expires_at: datetime, + client_ip: str | None, + user_agent: str | None, + ) -> RefreshToken: + jeton = RefreshToken( + user_id=user_id, + family_id=family_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 claim_for_rotation(self, token_hash: bytes) -> ClaimedToken | None: + requete = ( + update(RefreshToken) + .where( + RefreshToken.token_hash == token_hash, + RefreshToken.rotated_at.is_(None), + RefreshToken.revoked_at.is_(None), + RefreshToken.expires_at > func.clock_timestamp(), + ) + .values( + rotated_at=func.clock_timestamp(), + revoked_at=func.clock_timestamp(), + revoked_reason=RevocationReason.ROTATION.value, + ) + .returning( + RefreshToken.id, + RefreshToken.family_id, + RefreshToken.user_id, + RefreshToken.expires_at, + ) + ) + ligne = (await self._session.execute(requete)).one_or_none() + if ligne is None: + return None + return ClaimedToken( + id=ligne.id, + family_id=ligne.family_id, + user_id=ligne.user_id, + expires_at=ligne.expires_at, + ) + + async def inspect(self, token_hash: bytes) -> RefreshToken | None: + requete = select(RefreshToken).where(RefreshToken.token_hash == token_hash) + return (await self._session.execute(requete)).scalar_one_or_none() + + async def link_replacement(self, ancien_id: UUID, nouveau_id: UUID) -> None: + await self._session.execute( + update(RefreshToken).where(RefreshToken.id == ancien_id).values(replaced_by=nouveau_id) + ) + + async def revoke_family(self, family_id: UUID, reason: RevocationReason) -> int: + resultat = await self._session.execute( + update(RefreshToken) + .where(RefreshToken.family_id == family_id, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=func.clock_timestamp(), revoked_reason=reason.value) + .returning(RefreshToken.id) + ) + return len(resultat.all()) + + async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int: + resultat = await self._session.execute( + update(RefreshToken) + .where(RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=func.clock_timestamp(), revoked_reason=reason.value) + .returning(RefreshToken.id) + ) + return len(resultat.all()) diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 19a4e26..4e15dbd 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -5,19 +5,30 @@ # vérification réelle. Sans lui, l'écart de temps de réponse est un oracle d'existence. # Piège : la tentative échouée est validée en base AVANT que l'erreur ne soit levée. # `get_session()` ne valide pas de lui-même, donc la preuve disparaîtrait avec la transaction. +# Piège : dans `refresh()`, un jeton expiré ne révoque PAS la famille, un jeton déjà tourné si. +# La rotation ne protège de rien par elle-même : elle rend la réutilisation détectable, et +# c'est la détection qui termine le vol. from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from typing import NoReturn, Protocol -from uuid import UUID +from uuid import UUID, uuid4 from app.core.hashing import Argon2Hasher from app.core.principal import Principal from app.core.roles import AccountKind, Role -from app.core.security import TokenPolicy, encode_access_token -from app.models.audit_log import AuditAction +from app.core.security import ( + TokenPolicy, + encode_access_token, + fingerprint_refresh, + generate_refresh_secret, +) +from app.models.audit_log import AuditAction, AuditOutcome 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.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository @@ -33,6 +44,10 @@ class InvalidCredentialsError(AuthError): pass +class SessionRejectedError(AuthError): + pass + + class RateLimitedError(AuthError): def __init__(self, retry_after: int) -> None: super().__init__("Trop de tentatives") @@ -52,6 +67,7 @@ class AuthenticatedSession: principal: Principal access_token: str expires_in: int + refresh_secret: str class AuthService: @@ -60,19 +76,23 @@ class AuthService: *, users: UserRepository, attempts: LoginAttemptRepository, + refresh_tokens: RefreshTokenRepository, audit: AuditLogRepository, hasher: Argon2Hasher, transaction: Transaction, token_policy: TokenPolicy, login_policy: LoginPolicy, + refresh_ttl: timedelta, ) -> None: self._users = users self._attempts = attempts + self._refresh = refresh_tokens self._audit = audit self._hasher = hasher self._transaction = transaction self._token_policy = token_policy self._login_policy = login_policy + self._refresh_ttl = refresh_ttl async def authenticate( self, *, email: str, password: str, client_ip: str | None, user_agent: str | None @@ -101,19 +121,60 @@ class AuthService: await self._attempts.record( email=email, client_ip=client_ip, outcome=LoginOutcome.SUCCES, user_id=compte.id ) + secret = await self._ouvre_une_famille( + user_id=compte.id, client_ip=client_ip, user_agent=user_agent + ) await self._transaction.commit() - return self.issue_access_token( - Principal( - id=compte.id, - email=compte.email, - role=Role(compte.role), - kind=AccountKind(compte.kind), - must_change_password=compte.must_change_password, - ) - ) + return self._session(self._en_principal(compte), secret) - def issue_access_token(self, principal: Principal) -> AuthenticatedSession: + async def refresh( + self, *, secret: str, client_ip: str | None, user_agent: str | None + ) -> AuthenticatedSession: + empreinte = fingerprint_refresh(secret) + revendique = await self._refresh.claim_for_rotation(empreinte) + if revendique is None: + await self._traite_rotation_refusee(empreinte, client_ip, user_agent) + + compte = await self._users.get_by_id(revendique.user_id) + if compte is None or not compte.is_active: + await self._refresh.revoke_family(revendique.family_id, RevocationReason.ADMINISTRATION) + await self._transaction.commit() + raise SessionRejectedError("Session révoquée") + + nouveau_secret = generate_refresh_secret() + nouveau = await self._refresh.create( + user_id=revendique.user_id, + family_id=revendique.family_id, + token_hash=fingerprint_refresh(nouveau_secret), + expires_at=revendique.expires_at, + client_ip=client_ip, + user_agent=user_agent, + ) + await self._refresh.link_replacement(revendique.id, nouveau.id) + await self._transaction.commit() + + return self._session(self._en_principal(compte), nouveau_secret) + + async def logout(self, *, secret: str) -> None: + ligne = await self._refresh.inspect(fingerprint_refresh(secret)) + if ligne is not None: + await self._refresh.revoke_family(ligne.family_id, RevocationReason.DECONNEXION) + await self._transaction.commit() + + async def logout_all(self, principal: Principal) -> int: + revoquees = await self._refresh.revoke_all_for_user( + principal.id, RevocationReason.DECONNEXION + ) + await self._audit.record( + action=AuditAction.SESSIONS_REVOQUEES, + actor=principal, + detail={"sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + return revoquees + + def _session(self, principal: Principal, refresh_secret: str) -> AuthenticatedSession: jeton = encode_access_token( self._token_policy, subject=principal.id, @@ -124,8 +185,59 @@ class AuthService: principal=principal, access_token=jeton, expires_in=int(self._token_policy.access_ttl.total_seconds()), + refresh_secret=refresh_secret, ) + def _en_principal(self, compte: object) -> Principal: + return Principal( + id=compte.id, # type: ignore[attr-defined] + email=compte.email, # type: ignore[attr-defined] + role=Role(compte.role), # type: ignore[attr-defined] + kind=AccountKind(compte.kind), # type: ignore[attr-defined] + must_change_password=compte.must_change_password, # type: ignore[attr-defined] + ) + + async def _ouvre_une_famille( + self, *, user_id: UUID, client_ip: str | None, user_agent: str | None + ) -> str: + secret = generate_refresh_secret() + await self._refresh.create( + user_id=user_id, + family_id=uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + self._refresh_ttl, + client_ip=client_ip, + user_agent=user_agent, + ) + return secret + + async def _traite_rotation_refusee( + self, empreinte: bytes, client_ip: str | None, user_agent: str | None + ) -> NoReturn: + ligne = await self._refresh.inspect(empreinte) + if ligne is None: + raise SessionRejectedError("Session inconnue") + + if ligne.expires_at <= datetime.now(UTC): + raise SessionRejectedError("Session expirée") + + # Présenter un jeton déjà tourné est une preuve de compromission, pas un accident : toute + # la famille tombe, y compris la session encore vivante du voleur ou de la victime. + revoquees = await self._refresh.revoke_family( + ligne.family_id, RevocationReason.REUTILISATION + ) + await self._audit.record( + action=AuditAction.REFRESH_REUTILISE, + outcome=AuditOutcome.ECHEC, + target_type="refresh_token", + target_id=str(ligne.family_id), + client_ip=client_ip, + user_agent=user_agent, + detail={"famille": str(ligne.family_id), "sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + raise SessionRejectedError("Session révoquée") + async def _refuse_si_limite( self, *, email: str, client_ip: str | None, user_agent: str | None ) -> None: @@ -148,6 +260,7 @@ class AuthService: if compteurs.per_identifier >= politique.max_failures_per_identifier: await self._audit.record( action=AuditAction.LIMITE_PAR_IDENTIFIANT, + outcome=AuditOutcome.ECHEC, actor_label=email.strip().lower(), client_ip=client_ip, user_agent=user_agent, diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index f0e4f22..18bf979 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -60,8 +60,8 @@ select = [ ignore = ["B008"] [tool.ruff.lint.per-file-ignores] -# S105 et S106 signalent les secrets en dur, ce qui est justement la matière des tests d'auth. -"tests/**/*.py" = ["S101", "S105", "S106"] +# S105 à S107 signalent les secrets en dur, qui sont justement la matière des tests d'auth. +"tests/**/*.py" = ["S101", "S105", "S106", "S107"] [tool.ruff.lint.isort] known-first-party = ["app"] diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py index 08ceddd..1d734da 100644 --- a/apps/backend/tests/api/test_auth.py +++ b/apps/backend/tests/api/test_auth.py @@ -12,6 +12,7 @@ from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, RateLimitedError, + SessionRejectedError, ) IDENTIFIANTS = {"email": "operateur@enervision.fr", "password": "un-mot-de-passe-valide"} @@ -29,11 +30,20 @@ class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur + async def refresh(self, **_: object) -> AuthenticatedSession: + return await self.authenticate() + + async def logout(self, **_: object) -> None: + return None + async def authenticate(self, **_: object) -> AuthenticatedSession: if self._erreur is not None: raise self._erreur return AuthenticatedSession( - principal=PRINCIPAL, access_token="un.jeton.factice", expires_in=900 + principal=PRINCIPAL, + access_token="un.jeton.factice", + expires_in=900, + refresh_secret="un-secret-opaque", ) @@ -104,3 +114,95 @@ async def test_login_rejects_a_malformed_body_without_echoing_the_password( assert response.status_code == 422 assert "un-mot-de-passe-valide" not in response.text assert "x" * 129 not in response.text + + +async def test_login_posts_an_http_only_refresh_cookie_scoped_to_the_auth_routes( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + depose = response.headers["set-cookie"] + assert depose.startswith("ev_refresh=un-secret-opaque") + assert "HttpOnly" in depose + assert "SameSite=strict" in depose + assert "Path=/api/v1/auth" in depose + + +async def test_login_keeps_the_refresh_secret_out_of_the_response_body( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert "un-secret-opaque" not in response.text + + +async def test_refresh_returns_401_when_no_cookie_is_presented( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 401 + + +async def test_refresh_rotates_the_cookie_when_the_session_is_still_valid( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + client.cookies.set("ev_refresh", "un-secret-opaque") + + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 200 + assert "ev_refresh=" in response.headers["set-cookie"] + + +async def test_refresh_clears_the_cookie_when_the_session_is_rejected( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = SessionRejectedError("Session révoquée") + client.cookies.set("ev_refresh", "un-secret-rejoue") + + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 401 + assert 'ev_refresh=""' in response.headers["set-cookie"] + assert "Path=/api/v1/auth" in response.headers["set-cookie"] + + +async def test_logout_answers_204_and_clears_the_cookie( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + client.cookies.set("ev_refresh", "un-secret-opaque") + + response = await client.post("/api/v1/auth/logout") + + assert response.status_code == 204 + assert 'ev_refresh=""' in response.headers["set-cookie"] + + +async def test_logout_stays_idempotent_without_a_cookie( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/logout") + + assert response.status_code == 204 + + +@pytest.mark.parametrize( + "chemin", + ["/api/v1/auth/refresh", "/api/v1/auth/logout"], + ids=["rotation", "deconnexion"], +) +async def test_a_cookie_bearing_route_refuses_a_foreign_origin( + fake_auth_service: list[Exception | None], client: AsyncClient, chemin: str +) -> None: + response = await client.post(chemin, headers={"Origin": "https://malveillant.example"}) + + assert response.status_code == 403 + + +async def test_a_cookie_bearing_route_accepts_a_request_without_origin( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/logout") + + 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 bfdd2fe..9a04338 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -16,6 +16,8 @@ ROUTES_PUBLIQUES = frozenset( ("GET", "/api/v1/health/live"), ("GET", "/api/v1/health/ready"), ("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"), ("GET", "/metrics"), } ) diff --git a/apps/backend/tests/repositories/test_refresh_token.py b/apps/backend/tests/repositories/test_refresh_token.py new file mode 100644 index 0000000..73d82b4 --- /dev/null +++ b/apps/backend/tests/repositories/test_refresh_token.py @@ -0,0 +1,190 @@ +# Le premier test de ce fichier est le seul endroit où l'atomicité de la rotation se démontre : +# sur un double, deux appels concurrents réussiraient tous 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.models.refresh_token import RevocationReason +from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + +DUREE = timedelta(days=7) + + +async def un_compte(session: AsyncSession) -> uuid.UUID: + compte = await UserRepository(session).create( + email=f"jeton-{uuid.uuid4().hex[:12]}@enervision.fr", + password_hash="$argon2id$x", + role=Role.LECTEUR, + ) + return compte.id + + +async def un_jeton( + depot: RefreshTokenRepository, + user_id: uuid.UUID, + *, + family_id: uuid.UUID | None = None, + duree: timedelta = DUREE, +) -> tuple[str, uuid.UUID]: + secret = generate_refresh_secret() + jeton = await depot.create( + user_id=user_id, + family_id=family_id or uuid.uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + duree, + client_ip="203.0.113.10", + user_agent="pytest", + ) + return secret, jeton.family_id + + +async def test_claim_for_rotation_only_succeeds_once(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session)) + + premier = await depot.claim_for_rotation(fingerprint_refresh(secret)) + second = await depot.claim_for_rotation(fingerprint_refresh(secret)) + await session.rollback() + + assert premier is not None + assert second is None + + +async def test_claim_for_rotation_refuses_an_expired_token(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1)) + + revendique = await depot.claim_for_rotation(fingerprint_refresh(secret)) + await session.rollback() + + assert revendique is None + + +async def test_claim_for_rotation_returns_nothing_for_an_unknown_fingerprint( + session: AsyncSession, +) -> None: + revendique = await RefreshTokenRepository(session).claim_for_rotation( + fingerprint_refresh(generate_refresh_secret()) + ) + + assert revendique is None + + +async def test_inspect_finds_a_token_that_rotation_already_refused( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session)) + await depot.claim_for_rotation(fingerprint_refresh(secret)) + + ligne = await depot.inspect(fingerprint_refresh(secret)) + rotation, motif = (ligne.rotated_at, ligne.revoked_reason) if ligne else (None, None) + await session.rollback() + + assert rotation is not None + assert motif == RevocationReason.ROTATION.value + + +async def test_revoke_family_touches_every_living_token_of_that_family_only( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + famille = uuid.uuid4() + await un_jeton(depot, compte, family_id=famille) + await un_jeton(depot, compte, family_id=famille) + autre_secret, _ = await un_jeton(depot, compte) + + revoquees = await depot.revoke_family(famille, RevocationReason.REUTILISATION) + intacte = await depot.claim_for_rotation(fingerprint_refresh(autre_secret)) + await session.rollback() + + assert revoquees == 2 + assert intacte is not None + + +async def test_revoke_family_is_idempotent(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + famille = uuid.uuid4() + await un_jeton(depot, compte, family_id=famille) + + premier = await depot.revoke_family(famille, RevocationReason.DECONNEXION) + second = await depot.revoke_family(famille, RevocationReason.DECONNEXION) + await session.rollback() + + assert premier == 1 + assert second == 0 + + +async def test_revoke_all_for_user_closes_every_family_at_once(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + + revoquees = await depot.revoke_all_for_user(compte, RevocationReason.CHANGEMENT_MOT_DE_PASSE) + await session.rollback() + + assert revoquees == 3 + + +async def test_link_replacement_records_the_successor(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + ancien_secret, famille = await un_jeton(depot, compte) + revendique = await depot.claim_for_rotation(fingerprint_refresh(ancien_secret)) + assert revendique is not None + nouveau_secret = generate_refresh_secret() + nouveau = await depot.create( + user_id=compte, + family_id=famille, + token_hash=fingerprint_refresh(nouveau_secret), + expires_at=revendique.expires_at, + client_ip=None, + user_agent=None, + ) + + await depot.link_replacement(revendique.id, nouveau.id) + ligne = await depot.inspect(fingerprint_refresh(ancien_secret)) + successeur = ligne.replaced_by if ligne else None + await session.rollback() + + assert successeur == nouveau.id + + +async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + secret = generate_refresh_secret() + await depot.create( + user_id=compte, + family_id=uuid.uuid4(), + 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, + family_id=uuid.uuid4(), + 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/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 3b8902f..01697d8 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -6,14 +6,23 @@ from uuid import UUID, uuid4 import pytest -from app.core.security import TokenPolicy, decode_access_token +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.core.security import ( + TokenPolicy, + decode_access_token, + fingerprint_refresh, +) from app.models.login_attempt import LoginOutcome +from app.models.refresh_token import RevocationReason from app.repositories.login_attempt import FailureCounts +from app.repositories.refresh_token import ClaimedToken from app.services.auth import ( AuthService, InvalidCredentialsError, LoginPolicy, RateLimitedError, + SessionRejectedError, ) POLITIQUE_JETON = TokenPolicy( @@ -51,6 +60,9 @@ class FauxDepotComptes: async def get_by_email(self, email: str) -> FauxCompte | None: return self.compte + async def get_by_id(self, user_id: UUID) -> FauxCompte | None: + return self.compte + async def rehash_password(self, user_id: UUID, password_hash: str) -> None: self.rehachages += 1 @@ -78,6 +90,50 @@ class FauxDepotAudit: self.lignes.append((str(action), detail)) +@dataclass +class FauxJeton: + id: UUID = field(default_factory=uuid4) + family_id: UUID = field(default_factory=uuid4) + user_id: UUID = field(default_factory=uuid4) + expires_at: datetime = field(default_factory=lambda: datetime.now(UTC) + timedelta(days=7)) + rotated_at: datetime | None = None + revoked_at: datetime | None = None + + +class FauxDepotJetons: + def __init__( + self, revendique: ClaimedToken | None = None, connu: FauxJeton | None = None + ) -> None: + self.revendique = revendique + self.connu = connu + self.crees: list[UUID] = [] + self.familles_revoquees: list[tuple[UUID, str]] = [] + self.revocations_par_compte: list[tuple[UUID, str]] = [] + self.liaisons: list[tuple[UUID, UUID]] = [] + + async def create(self, *, user_id: UUID, family_id: UUID, **_: object) -> FauxJeton: + jeton = FauxJeton(user_id=user_id, family_id=family_id) + self.crees.append(jeton.id) + return jeton + + async def claim_for_rotation(self, token_hash: bytes) -> ClaimedToken | None: + return self.revendique + + async def inspect(self, token_hash: bytes) -> FauxJeton | None: + return self.connu + + async def link_replacement(self, ancien_id: UUID, nouveau_id: UUID) -> None: + self.liaisons.append((ancien_id, nouveau_id)) + + async def revoke_family(self, family_id: UUID, reason: RevocationReason) -> int: + self.familles_revoquees.append((family_id, reason.value)) + return 2 + + async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int: + self.revocations_par_compte.append((user_id, reason.value)) + return 3 + + class FauxHacheur: def __init__(self, *, accepte: bool = True, rehachage_requis: bool = False) -> None: self.verifications = 0 @@ -108,26 +164,40 @@ class FausseTransaction: self.validations += 1 +@dataclass +class Attirail: + service: AuthService + comptes: FauxDepotComptes + tentatives: FauxDepotTentatives + jetons: FauxDepotJetons + audit: FauxDepotAudit + hacheur: FauxHacheur + + def fabrique_service( *, compte: FauxCompte | None = None, compteurs: FailureCounts | None = None, hacheur: FauxHacheur | None = None, -) -> tuple[AuthService, FauxDepotComptes, FauxDepotTentatives, FauxDepotAudit, FauxHacheur]: + jetons: FauxDepotJetons | None = None, +) -> Attirail: comptes = FauxDepotComptes(compte) tentatives = FauxDepotTentatives(compteurs) + depot_jetons = jetons or FauxDepotJetons() audit = FauxDepotAudit() hacheur = hacheur or FauxHacheur() service = AuthService( users=comptes, # type: ignore[arg-type] attempts=tentatives, # type: ignore[arg-type] + refresh_tokens=depot_jetons, # type: ignore[arg-type] audit=audit, # type: ignore[arg-type] hasher=hacheur, # type: ignore[arg-type] transaction=FausseTransaction(), token_policy=POLITIQUE_JETON, login_policy=POLITIQUE_CONNEXION, + refresh_ttl=timedelta(days=7), ) - return service, comptes, tentatives, audit, hacheur + return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur) async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object: @@ -139,64 +209,73 @@ async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-va ) +async def rafraichit(service: AuthService, secret: str = "un-secret-opaque") -> object: + return await service.refresh(secret=secret, client_ip="203.0.113.10", user_agent="pytest") + + async def test_authenticate_returns_a_readable_access_token_when_credentials_match() -> None: compte = FauxCompte() - service, comptes, tentatives, _, _ = fabrique_service(compte=compte) + attirail = fabrique_service(compte=compte) - session = await connecte(service) + session = await connecte(attirail.service) claims = decode_access_token(POLITIQUE_JETON, session.access_token) # type: ignore[attr-defined] assert claims.subject == compte.id assert claims.role == "operateur" - assert tentatives.enregistrees == [LoginOutcome.SUCCES.value] - assert comptes.connexions_datees == 1 + assert attirail.tentatives.enregistrees == [LoginOutcome.SUCCES.value] + assert attirail.comptes.connexions_datees == 1 + + +async def test_authenticate_opens_one_refresh_family_per_login() -> None: + attirail = fabrique_service(compte=FauxCompte()) + + session = await connecte(attirail.service) + + assert len(attirail.jetons.crees) == 1 + assert session.refresh_secret # type: ignore[attr-defined] async def test_authenticate_verifies_a_decoy_digest_when_the_email_is_unknown() -> None: - service, _, tentatives, _, hacheur = fabrique_service(compte=None) + attirail = fabrique_service(compte=None) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 1 - assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + assert attirail.hacheur.verifications == 1 + assert attirail.tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] async def test_authenticate_skips_hashing_entirely_when_the_rate_limit_is_reached() -> None: compteurs = FailureCounts(per_identifier_and_ip=5, per_ip=5, per_identifier=5) - service, _, tentatives, audit, hacheur = fabrique_service( - compte=FauxCompte(), compteurs=compteurs - ) + attirail = fabrique_service(compte=FauxCompte(), compteurs=compteurs) with pytest.raises(RateLimitedError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 0 - assert hacheur.hachages == 0 - assert tentatives.enregistrees == [LoginOutcome.LIMITE.value] - assert audit.lignes == [] + assert attirail.hacheur.verifications == 0 + assert attirail.hacheur.hachages == 0 + assert attirail.tentatives.enregistrees == [LoginOutcome.LIMITE.value] + assert attirail.audit.lignes == [] async def test_authenticate_audits_when_the_identifier_threshold_alone_is_reached() -> None: compteurs = FailureCounts(per_identifier_and_ip=0, per_ip=0, per_identifier=50) - service, _, _, audit, _ = fabrique_service(compte=FauxCompte(), compteurs=compteurs) + attirail = fabrique_service(compte=FauxCompte(), compteurs=compteurs) with pytest.raises(RateLimitedError): - await connecte(service) + await connecte(attirail.service) - assert len(audit.lignes) == 1 - assert "identifier_throttled" in audit.lignes[0][0] + assert len(attirail.audit.lignes) == 1 + assert "identifier_throttled" in attirail.audit.lignes[0][0] async def test_authenticate_rejects_a_wrong_password_with_the_generic_error() -> None: - service, _, tentatives, _, _ = fabrique_service( - compte=FauxCompte(), hacheur=FauxHacheur(accepte=False) - ) + attirail = fabrique_service(compte=FauxCompte(), hacheur=FauxHacheur(accepte=False)) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + assert attirail.tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] @pytest.mark.parametrize( @@ -207,28 +286,155 @@ async def test_authenticate_rejects_a_wrong_password_with_the_generic_error() -> async def test_authenticate_rejects_unavailable_accounts_after_checking_the_password( compte: FauxCompte, ) -> None: - service, _, tentatives, _, hacheur = fabrique_service(compte=compte) + attirail = fabrique_service(compte=compte) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 1 - assert tentatives.enregistrees == [LoginOutcome.COMPTE_INDISPONIBLE.value] + assert attirail.hacheur.verifications == 1 + assert attirail.tentatives.enregistrees == [LoginOutcome.COMPTE_INDISPONIBLE.value] async def test_authenticate_rehashes_the_password_when_the_parameters_changed() -> None: - service, comptes, _, _, _ = fabrique_service( - compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True) - ) + attirail = fabrique_service(compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True)) - await connecte(service) + await connecte(attirail.service) - assert comptes.rehachages == 1 + assert attirail.comptes.rehachages == 1 async def test_authenticate_leaves_the_digest_alone_when_the_parameters_match() -> None: - service, comptes, _, _, _ = fabrique_service(compte=FauxCompte()) + attirail = fabrique_service(compte=FauxCompte()) - await connecte(service) + await connecte(attirail.service) - assert comptes.rehachages == 0 + assert attirail.comptes.rehachages == 0 + + +async def test_refresh_rotates_the_token_and_keeps_the_family() -> None: + compte = FauxCompte() + revendique = ClaimedToken( + id=uuid4(), + family_id=uuid4(), + user_id=compte.id, + expires_at=datetime.now(UTC) + timedelta(days=5), + ) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + session = await rafraichit(attirail.service) + + assert session.refresh_secret # type: ignore[attr-defined] + assert len(attirail.jetons.crees) == 1 + assert attirail.jetons.liaisons == [(revendique.id, attirail.jetons.crees[0])] + assert attirail.jetons.familles_revoquees == [] + + +async def test_refresh_inherits_the_absolute_expiry_of_its_predecessor() -> None: + compte = FauxCompte() + echeance = datetime.now(UTC) + timedelta(days=2) + revendique = ClaimedToken(id=uuid4(), family_id=uuid4(), user_id=compte.id, expires_at=echeance) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + await rafraichit(attirail.service) + + assert revendique.expires_at == echeance + + +async def test_refresh_rejects_an_unknown_secret_without_touching_any_family() -> None: + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons()) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [] + assert attirail.audit.lignes == [] + + +async def test_refresh_rejects_an_expired_token_without_revoking_its_family() -> None: + perime = FauxJeton(expires_at=datetime.now(UTC) - timedelta(minutes=1)) + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=perime)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [] + assert attirail.audit.lignes == [] + + +async def test_refresh_revokes_the_whole_family_when_a_rotated_token_comes_back() -> None: + rejoue = FauxJeton(rotated_at=datetime.now(UTC), revoked_at=datetime.now(UTC)) + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=rejoue)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [ + (rejoue.family_id, RevocationReason.REUTILISATION.value) + ] + assert "refresh_reuse_detected" in attirail.audit.lignes[0][0] + + +async def test_refresh_revokes_the_family_when_the_account_was_disabled_meanwhile() -> None: + compte = FauxCompte(is_active=False) + revendique = ClaimedToken( + id=uuid4(), + family_id=uuid4(), + user_id=compte.id, + expires_at=datetime.now(UTC) + timedelta(days=5), + ) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [ + (revendique.family_id, RevocationReason.ADMINISTRATION.value) + ] + + +async def test_logout_revokes_only_the_presented_family() -> None: + connu = FauxJeton() + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=connu)) + + await attirail.service.logout(secret="un-secret-opaque") + + assert attirail.jetons.familles_revoquees == [ + (connu.family_id, RevocationReason.DECONNEXION.value) + ] + assert attirail.jetons.revocations_par_compte == [] + + +async def test_logout_stays_silent_when_the_cookie_points_at_nothing() -> None: + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons()) + + await attirail.service.logout(secret="un-secret-inconnu") + + assert attirail.jetons.familles_revoquees == [] + + +async def test_logout_all_revokes_every_session_and_leaves_an_audit_trail() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + acteur = Principal( + id=compte.id, + email=compte.email, + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + revoquees = await attirail.service.logout_all(acteur) + + assert revoquees == 3 + assert attirail.jetons.revocations_par_compte == [ + (compte.id, RevocationReason.DECONNEXION.value) + ] + assert "all_sessions_revoked" in attirail.audit.lignes[0][0] + + +def test_fingerprint_is_what_the_service_stores_not_the_secret_itself() -> None: + secret = "un-secret-opaque" + + empreinte = fingerprint_refresh(secret) + + assert secret.encode() not in empreinte