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.
This commit is contained in:
Johan LEROY
2026-09-17 15:28:50 +02:00
parent e381e0de09
commit d7f775f9f7
12 changed files with 181 additions and 5 deletions
@@ -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)