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).
92 lines
2.8 KiB
Python
92 lines
2.8 KiB
Python
# 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
|
|
from uuid import UUID
|
|
|
|
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 = 8
|
|
PASSWORD_MAX_LENGTH = 128
|
|
|
|
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:
|
|
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
|
|
password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH)
|
|
|
|
|
|
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)
|
|
|
|
id: UUID
|
|
email: str
|
|
role: Role
|
|
kind: AccountKind
|
|
must_change_password: bool
|
|
|
|
@classmethod
|
|
def from_principal(cls, principal: Principal) -> Self:
|
|
return cls.model_validate(principal)
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
token_type: Literal["bearer"] = "bearer" # noqa: S105
|
|
expires_in: int
|
|
principal: PrincipalResponse
|