feat(auth): politique de complexite du mot de passe et flux de reinitialisation

Remplace la regle de longueur seule (12 caracteres) par une exigence de
composition (8 caracteres minimum, majuscule, minuscule, chiffre, caractere
special), non documentee dans les exigences officielles du projet, par une
regle explicite partagee entre le backend (validateur Pydantic) et le
frontend.

Ajoute un flux "mot de passe oublie" en libre-service, absent jusqu'ici :
jeton a usage unique hache en base (meme principe que les refresh tokens),
expirant a 15 minutes, envoye par email via un service SMTP (aiosmtplib,
Mailpit en dev), avec limitation de debit dediee et reponse generique pour
eviter l'enumeration des comptes.

Closes #87
This commit is contained in:
Johan LEROY
2026-09-17 10:53:58 +02:00
parent 3692d486c6
commit 9161b74874
48 changed files with 1914 additions and 25 deletions
@@ -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")