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
69 lines
2.3 KiB
Python
69 lines
2.3 KiB
Python
# 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())
|