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:
@@ -8,3 +8,13 @@ APP_SECRET_KEY=change_me
|
||||
|
||||
APP_CORS_ORIGINS=http://localhost:4200
|
||||
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision
|
||||
|
||||
# Mot de passe oublié : lien à usage unique valable 15 minutes par défaut.
|
||||
APP_FRONTEND_RESET_PASSWORD_URL=http://localhost:4200/reset-password
|
||||
|
||||
# SMTP local de dev (Mailpit, cf. docker-compose.yml) : aucune authentification, aucun TLS.
|
||||
# À remplacer par un vrai relais en staging/prod.
|
||||
APP_SMTP_HOST=localhost
|
||||
APP_SMTP_PORT=1025
|
||||
APP_SMTP_USE_TLS=false
|
||||
APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr
|
||||
|
||||
@@ -103,6 +103,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
|
||||
| `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente |
|
||||
| `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton |
|
||||
| `/api/v1/auth/password` | Change son propre mot de passe | jeton |
|
||||
| `/api/v1/auth/forgot-password` | Demande un lien de réinitialisation par email | public |
|
||||
| `/api/v1/auth/reset-password` | Choisit un nouveau mot de passe depuis ce lien | public |
|
||||
| `/api/v1/auth/me` | Décrit le compte connecté | jeton |
|
||||
| `/api/v1/users` | Liste et crée des comptes | `admin` |
|
||||
| `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` |
|
||||
|
||||
@@ -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")
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.hashing import Argon2Hasher, build_hasher
|
||||
from app.core.mailer import Mailer, SmtpConfig
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role, has_at_least
|
||||
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
|
||||
@@ -24,13 +25,15 @@ from app.db.session import get_session
|
||||
from app.repositories.alert import AlertRepository
|
||||
from app.repositories.audit_log import AuditLogRepository
|
||||
from app.repositories.login_attempt import LoginAttemptRepository
|
||||
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
|
||||
from app.repositories.password_reset_token import PasswordResetTokenRepository
|
||||
from app.repositories.reading import ReadingRepository
|
||||
from app.repositories.recommendation import RecommendationRepository
|
||||
from app.repositories.refresh_token import RefreshTokenRepository
|
||||
from app.repositories.site import SiteRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.alert import AlertService
|
||||
from app.services.auth import AuthService, LoginPolicy
|
||||
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
|
||||
from app.services.recommendation import RecommendationService
|
||||
from app.services.sensor import SensorService
|
||||
from app.services.site import SiteService
|
||||
@@ -97,11 +100,27 @@ def get_client_ip(request: Request, settings: SettingsDep) -> str | None:
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def get_mailer(settings: SettingsDep) -> Mailer:
|
||||
return Mailer(
|
||||
SmtpConfig(
|
||||
host=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_username,
|
||||
password=(
|
||||
settings.smtp_password.get_secret_value() if settings.smtp_password else None
|
||||
),
|
||||
use_tls=settings.smtp_use_tls,
|
||||
from_address=settings.smtp_from_address,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_auth_service(
|
||||
session: SessionDep,
|
||||
settings: SettingsDep,
|
||||
hasher: Annotated[Argon2Hasher, Depends(get_hasher)],
|
||||
token_policy: Annotated[TokenPolicy, Depends(get_token_policy)],
|
||||
mailer: Annotated[Mailer, Depends(get_mailer)],
|
||||
) -> AuthService:
|
||||
return AuthService(
|
||||
users=UserRepository(session),
|
||||
@@ -118,6 +137,16 @@ def get_auth_service(
|
||||
max_failures_per_identifier=settings.login_max_failures_per_identifier,
|
||||
),
|
||||
refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds),
|
||||
reset_tokens=PasswordResetTokenRepository(session),
|
||||
reset_attempts=PasswordResetAttemptRepository(session),
|
||||
reset_policy=PasswordResetPolicy(
|
||||
window_seconds=settings.password_reset_window_seconds,
|
||||
max_requests_per_identifier=settings.password_reset_max_requests_per_identifier,
|
||||
max_requests_per_ip=settings.password_reset_max_requests_per_ip,
|
||||
token_ttl=timedelta(seconds=settings.password_reset_ttl_seconds),
|
||||
frontend_reset_url=settings.frontend_reset_password_url,
|
||||
),
|
||||
mailer=mailer,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -156,3 +156,16 @@ REPONSE_ORIGINE_REFUSEE: Final[Reponses] = {
|
||||
"description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).",
|
||||
},
|
||||
}
|
||||
|
||||
REPONSE_LIMITE: Final[Reponses] = {
|
||||
429: {
|
||||
"model": ErrorResponse,
|
||||
"description": "Trop de demandes sur cette fenêtre glissante.",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Secondes à attendre avant une nouvelle tentative.",
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.api.deps import (
|
||||
require_trusted_origin,
|
||||
)
|
||||
from app.api.openapi import (
|
||||
REPONSE_LIMITE,
|
||||
REPONSE_ORIGINE_REFUSEE,
|
||||
REPONSE_VALIDATION,
|
||||
REPONSES_AUTHENTIFIEES,
|
||||
@@ -21,15 +22,18 @@ from app.api.openapi import (
|
||||
from app.core.cookies import RefreshCookie, cookie_name
|
||||
from app.core.logging import get_logger
|
||||
from app.schemas.auth import (
|
||||
ForgotPasswordRequest,
|
||||
LoginRequest,
|
||||
PasswordChangeRequest,
|
||||
PrincipalResponse,
|
||||
ResetPasswordRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.schemas.errors import ErrorResponse
|
||||
from app.services.auth import (
|
||||
AuthenticatedSession,
|
||||
InvalidCredentialsError,
|
||||
InvalidOrExpiredResetTokenError,
|
||||
RateLimitedError,
|
||||
SessionRejectedError,
|
||||
)
|
||||
@@ -39,6 +43,7 @@ logger = get_logger(__name__)
|
||||
|
||||
DETAIL_IDENTIFIANTS = "Identifiants invalides"
|
||||
DETAIL_SESSION = "Session invalide"
|
||||
DETAIL_LIEN_RESET = "Lien invalide ou expiré"
|
||||
|
||||
REPONSES_LOGIN: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
@@ -85,6 +90,20 @@ REPONSES_MOT_DE_PASSE: Reponses = {
|
||||
},
|
||||
}
|
||||
|
||||
REPONSES_FORGOT_PASSWORD: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
**REPONSE_LIMITE,
|
||||
}
|
||||
|
||||
REPONSES_RESET_PASSWORD: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
**REPONSE_ORIGINE_REFUSEE,
|
||||
400: {
|
||||
"model": ErrorResponse,
|
||||
"description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def repond(
|
||||
response: Response, settings: SettingsDep, session: AuthenticatedSession
|
||||
@@ -267,3 +286,67 @@ async def change_password(
|
||||
|
||||
logger.info("auth.password_changed user_id=%s", principal.id)
|
||||
return repond(response, settings, session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/forgot-password",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
summary="Demande un lien de réinitialisation par email",
|
||||
responses=REPONSES_FORGOT_PASSWORD,
|
||||
)
|
||||
async def forgot_password(
|
||||
payload: ForgotPasswordRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
service: AuthServiceDep,
|
||||
client_ip: str | None = Depends(get_client_ip),
|
||||
) -> None:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
try:
|
||||
await service.request_password_reset(
|
||||
email=payload.email,
|
||||
client_ip=client_ip,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
except RateLimitedError as erreur:
|
||||
logger.warning("auth.password_reset.rate_limited ip=%s", client_ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Trop de demandes, réessayez plus tard",
|
||||
headers={"Retry-After": str(erreur.retry_after)},
|
||||
) from erreur
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reset-password",
|
||||
response_model=TokenResponse,
|
||||
summary="Choisit un nouveau mot de passe depuis un lien reçu par email",
|
||||
dependencies=[Depends(require_trusted_origin)],
|
||||
responses=REPONSES_RESET_PASSWORD,
|
||||
)
|
||||
async def reset_password(
|
||||
payload: ResetPasswordRequest,
|
||||
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.confirm_password_reset(
|
||||
token=payload.token,
|
||||
new_password=payload.new_password,
|
||||
client_ip=client_ip,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
except InvalidOrExpiredResetTokenError as erreur:
|
||||
logger.warning("auth.password_reset.invalid_token ip=%s", client_ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=DETAIL_LIEN_RESET
|
||||
) from erreur
|
||||
|
||||
logger.info("auth.password_reset.success user_id=%s", session.principal.id)
|
||||
return repond(response, settings, session)
|
||||
|
||||
+27
-4
@@ -9,6 +9,7 @@ import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
import sys
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
@@ -22,9 +23,10 @@ from app.core.roles import Role
|
||||
from app.db.session import get_session_factory
|
||||
from app.main import create_app
|
||||
from app.repositories.user import UserRepository
|
||||
from app.schemas.auth import PASSWORD_MIN_LENGTH, valide_complexite
|
||||
|
||||
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
||||
LONGUEUR_MINIMALE = 12
|
||||
CARACTERES_SPECIAUX = "!@#$%^&*()-_=+[]{};:,.?"
|
||||
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
|
||||
|
||||
|
||||
@@ -111,15 +113,36 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
return parser
|
||||
|
||||
|
||||
def genere_mot_de_passe() -> str:
|
||||
tirage = secrets.SystemRandom()
|
||||
classes = [
|
||||
string.ascii_uppercase,
|
||||
string.ascii_lowercase,
|
||||
string.digits,
|
||||
CARACTERES_SPECIAUX,
|
||||
]
|
||||
reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes)
|
||||
caracteres = [tirage.choice(classe) for classe in classes]
|
||||
caracteres += [tirage.choice("".join(classes)) for _ in range(reste)]
|
||||
tirage.shuffle(caracteres)
|
||||
return "".join(caracteres)
|
||||
|
||||
|
||||
def read_password(*, generate: bool) -> str:
|
||||
if generate:
|
||||
mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE)
|
||||
mot_de_passe = genere_mot_de_passe()
|
||||
print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}")
|
||||
return mot_de_passe
|
||||
|
||||
mot_de_passe = getpass("Mot de passe : ")
|
||||
if len(mot_de_passe) < LONGUEUR_MINIMALE:
|
||||
raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères")
|
||||
if len(mot_de_passe) < PASSWORD_MIN_LENGTH:
|
||||
raise SystemExit(
|
||||
f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères"
|
||||
)
|
||||
try:
|
||||
valide_complexite(mot_de_passe)
|
||||
except ValueError as erreur:
|
||||
raise SystemExit(str(erreur)) from erreur
|
||||
if mot_de_passe != getpass("Confirmation : "):
|
||||
raise SystemExit("Les deux saisies diffèrent")
|
||||
return mot_de_passe
|
||||
|
||||
@@ -54,6 +54,19 @@ class Settings(BaseSettings):
|
||||
login_max_failures_per_ip: int = Field(default=20, ge=1)
|
||||
login_max_failures_per_identifier: int = Field(default=50, ge=1)
|
||||
|
||||
password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600)
|
||||
password_reset_window_seconds: int = Field(default=900, ge=60)
|
||||
password_reset_max_requests_per_identifier: int = Field(default=3, ge=1)
|
||||
password_reset_max_requests_per_ip: int = Field(default=10, ge=1)
|
||||
|
||||
smtp_host: str = "localhost"
|
||||
smtp_port: int = Field(default=587, ge=1, le=65535)
|
||||
smtp_username: str | None = None
|
||||
smtp_password: SecretStr | None = None
|
||||
smtp_use_tls: bool = False
|
||||
smtp_from_address: str = "no-reply@enervision.fr"
|
||||
frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105
|
||||
|
||||
trust_proxy_headers: bool = False
|
||||
expose_api_docs: bool | None = None
|
||||
metrics_token: SecretStr | None = None
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Piège : l'URL de réinitialisation porte le jeton en clair. Ne jamais la journaliser :
|
||||
# `send_password_reset_email()` ne logue que le destinataire, jamais `reset_url`.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SmtpConfig:
|
||||
host: str
|
||||
port: int
|
||||
username: str | None
|
||||
password: str | None
|
||||
use_tls: bool
|
||||
from_address: str
|
||||
|
||||
|
||||
class Mailer:
|
||||
def __init__(self, config: SmtpConfig) -> None:
|
||||
self._config = config
|
||||
|
||||
async def send_password_reset_email(self, *, to: str, reset_url: str) -> None:
|
||||
message = EmailMessage()
|
||||
message["From"] = self._config.from_address
|
||||
message["To"] = to
|
||||
message["Subject"] = "Réinitialisation de votre mot de passe EnerVision"
|
||||
message.set_content(
|
||||
"Une réinitialisation de mot de passe a été demandée pour ce compte.\n\n"
|
||||
f"Ouvrez ce lien dans les 15 minutes pour choisir un nouveau mot de passe : "
|
||||
f"{reset_url}\n\n"
|
||||
"Si vous n'êtes pas à l'origine de cette demande, ignorez cet email."
|
||||
)
|
||||
|
||||
_, message_recu = await aiosmtplib.send(
|
||||
message,
|
||||
hostname=self._config.host,
|
||||
port=self._config.port,
|
||||
username=self._config.username,
|
||||
password=self._config.password,
|
||||
use_tls=self._config.use_tls,
|
||||
)
|
||||
logger.info("mailer.password_reset_sent to=%s smtp_response=%s", to, message_recu)
|
||||
@@ -4,6 +4,8 @@
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
|
||||
from app.models.login_attempt import LoginAttempt
|
||||
from app.models.password_reset_attempt import PasswordResetAttempt
|
||||
from app.models.password_reset_token import PasswordResetToken
|
||||
from app.models.refresh_token import RefreshToken
|
||||
from app.models.user import AppUser
|
||||
|
||||
@@ -13,6 +15,8 @@ __all__ = [
|
||||
"AuditLog",
|
||||
"Dataset",
|
||||
"LoginAttempt",
|
||||
"PasswordResetAttempt",
|
||||
"PasswordResetToken",
|
||||
"Prediction",
|
||||
"Reading",
|
||||
"Recommendation",
|
||||
|
||||
@@ -29,6 +29,8 @@ class AuditAction(StrEnum):
|
||||
COMPTE_ACTIVE = "user.enabled"
|
||||
COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin"
|
||||
COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed"
|
||||
MOT_DE_PASSE_OUBLIE_DEMANDE = "auth.password_reset_requested"
|
||||
MOT_DE_PASSE_REINITIALISE_PAR_SOI = "auth.password_reset_self_service"
|
||||
REFRESH_REUTILISE = "auth.refresh_reuse_detected"
|
||||
SESSIONS_REVOQUEES = "auth.all_sessions_revoked"
|
||||
LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Pourquoi : même séparation que `login_attempt` par rapport à `audit_log` : ce compteur est
|
||||
# piloté par l'attaquant (une campagne de demandes) et se purge, l'audit log est en ajout seul.
|
||||
# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 apprendrait
|
||||
# qu'un compte existe.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Identity, Index, String, func
|
||||
from sqlalchemy.dialects.postgresql import INET
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PasswordResetAttempt(Base):
|
||||
__tablename__ = "password_reset_attempt"
|
||||
__table_args__ = (
|
||||
Index("ix_password_reset_attempt_email_date", "email_tried", "occurred_at"),
|
||||
Index("ix_password_reset_attempt_ip_date", "client_ip", "occurred_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
|
||||
occurred_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
email_tried: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
|
||||
@@ -0,0 +1,40 @@
|
||||
# Pourquoi : même schéma que `refresh_token` (chaîne opaque, jamais un JWT) pour la même
|
||||
# raison : un jeton de réinitialisation doit être révocable d'un coup, et un JWT ne figure
|
||||
# dans aucune ligne à invalider.
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import 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 PasswordResetToken(Base):
|
||||
__tablename__ = "password_reset_token"
|
||||
__table_args__ = (
|
||||
Index("ix_password_reset_token_user", "user_id"),
|
||||
Index(
|
||||
"ix_password_reset_token_vivants",
|
||||
"user_id",
|
||||
postgresql_where="consumed_at is null",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()
|
||||
)
|
||||
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)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,42 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.password_reset_attempt import PasswordResetAttempt
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResetRequestCounts:
|
||||
per_identifier: int
|
||||
per_ip: int
|
||||
|
||||
|
||||
class PasswordResetAttemptRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def record(self, *, email: str, client_ip: str | None) -> None:
|
||||
self._session.add(
|
||||
PasswordResetAttempt(email_tried=email.strip().lower(), client_ip=client_ip)
|
||||
)
|
||||
|
||||
async def count_recent(
|
||||
self, *, email: str, client_ip: str | None, window_seconds: int
|
||||
) -> ResetRequestCounts:
|
||||
identifiant = email.strip().lower()
|
||||
meme_email = PasswordResetAttempt.email_tried == identifiant
|
||||
meme_ip = PasswordResetAttempt.client_ip == client_ip
|
||||
|
||||
requete = select(
|
||||
func.count().filter(meme_email),
|
||||
func.count().filter(meme_ip),
|
||||
).where(
|
||||
PasswordResetAttempt.occurred_at
|
||||
> datetime.now(UTC) - timedelta(seconds=window_seconds),
|
||||
meme_email | meme_ip,
|
||||
)
|
||||
|
||||
par_identifiant, par_ip = (await self._session.execute(requete)).one()
|
||||
return ResetRequestCounts(per_identifier=par_identifiant, per_ip=par_ip)
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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())
|
||||
@@ -1,17 +1,39 @@
|
||||
# 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.
|
||||
|
||||
import re
|
||||
from typing import Literal, Self
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
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 = 12
|
||||
PASSWORD_MIN_LENGTH = 8
|
||||
PASSWORD_MAX_LENGTH = 128
|
||||
|
||||
_MAJUSCULE = re.compile(r"[A-ZÀ-Ý]")
|
||||
_MINUSCULE = re.compile(r"[a-zà-ÿ]")
|
||||
_CHIFFRE = re.compile(r"\d")
|
||||
_SPECIAL = re.compile(r"[^\w\s]")
|
||||
|
||||
|
||||
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
|
||||
@@ -22,6 +44,25 @@ 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)
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import NoReturn, Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.core.hashing import Argon2Hasher
|
||||
from app.core.mailer import Mailer
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.core.security import (
|
||||
@@ -28,6 +29,8 @@ 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.password_reset_attempt import PasswordResetAttemptRepository
|
||||
from app.repositories.password_reset_token import PasswordResetTokenRepository
|
||||
from app.repositories.refresh_token import RefreshTokenRepository
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
@@ -54,6 +57,10 @@ class RateLimitedError(AuthError):
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class InvalidOrExpiredResetTokenError(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoginPolicy:
|
||||
window_seconds: int
|
||||
@@ -62,6 +69,15 @@ class LoginPolicy:
|
||||
max_failures_per_identifier: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PasswordResetPolicy:
|
||||
window_seconds: int
|
||||
max_requests_per_identifier: int
|
||||
max_requests_per_ip: int
|
||||
token_ttl: timedelta
|
||||
frontend_reset_url: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthenticatedSession:
|
||||
principal: Principal
|
||||
@@ -83,6 +99,10 @@ class AuthService:
|
||||
token_policy: TokenPolicy,
|
||||
login_policy: LoginPolicy,
|
||||
refresh_ttl: timedelta,
|
||||
reset_tokens: PasswordResetTokenRepository,
|
||||
reset_attempts: PasswordResetAttemptRepository,
|
||||
reset_policy: PasswordResetPolicy,
|
||||
mailer: Mailer,
|
||||
) -> None:
|
||||
self._users = users
|
||||
self._attempts = attempts
|
||||
@@ -93,6 +113,10 @@ class AuthService:
|
||||
self._token_policy = token_policy
|
||||
self._login_policy = login_policy
|
||||
self._refresh_ttl = refresh_ttl
|
||||
self._reset_tokens = reset_tokens
|
||||
self._reset_attempts = reset_attempts
|
||||
self._reset_policy = reset_policy
|
||||
self._mailer = mailer
|
||||
|
||||
async def authenticate(
|
||||
self, *, email: str, password: str, client_ip: str | None, user_agent: str | None
|
||||
@@ -200,6 +224,75 @@ class AuthService:
|
||||
rafraichi = await self._users.get_by_id(principal.id)
|
||||
return self._session(self._en_principal(rafraichi or compte), secret)
|
||||
|
||||
async def request_password_reset(
|
||||
self, *, email: str, client_ip: str | None, user_agent: str | None
|
||||
) -> None:
|
||||
await self._refuse_si_limite_reset(email=email, client_ip=client_ip)
|
||||
|
||||
compte = await self._users.get_by_email(email)
|
||||
# Piège : le hachage factice équilibre le temps de réponse sur un compte inconnu, comme
|
||||
# `authenticate()`. La réponse et sa forme restent identiques dans tous les cas : compte
|
||||
# inconnu, compte inactif, ou email envoyé avec succès.
|
||||
if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value:
|
||||
await self._hasher.verify_dummy()
|
||||
await self._reset_attempts.record(email=email, client_ip=client_ip)
|
||||
await self._transaction.commit()
|
||||
return
|
||||
|
||||
await self._reset_tokens.invalidate_all_for_user(compte.id)
|
||||
secret = generate_refresh_secret()
|
||||
await self._reset_tokens.create(
|
||||
user_id=compte.id,
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + self._reset_policy.token_ttl,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
await self._reset_attempts.record(email=email, client_ip=client_ip)
|
||||
await self._audit.record(
|
||||
action=AuditAction.MOT_DE_PASSE_OUBLIE_DEMANDE,
|
||||
actor_label=compte.email,
|
||||
target_type="app_user",
|
||||
target_id=str(compte.id),
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
await self._transaction.commit()
|
||||
|
||||
lien = f"{self._reset_policy.frontend_reset_url}?token={secret}"
|
||||
await self._mailer.send_password_reset_email(to=compte.email, reset_url=lien)
|
||||
|
||||
async def confirm_password_reset(
|
||||
self, *, token: str, new_password: str, client_ip: str | None, user_agent: str | None
|
||||
) -> AuthenticatedSession:
|
||||
revendique = await self._reset_tokens.consume(fingerprint_refresh(token))
|
||||
if revendique is None:
|
||||
raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
|
||||
|
||||
await self._users.update_password(
|
||||
revendique.user_id, await self._hasher.hash(new_password), must_change_password=False
|
||||
)
|
||||
revoquees = await self._refresh.revoke_all_for_user(
|
||||
revendique.user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE
|
||||
)
|
||||
secret = await self._ouvre_une_famille(
|
||||
user_id=revendique.user_id, client_ip=client_ip, user_agent=user_agent
|
||||
)
|
||||
await self._audit.record(
|
||||
action=AuditAction.MOT_DE_PASSE_REINITIALISE_PAR_SOI,
|
||||
target_type="app_user",
|
||||
target_id=str(revendique.user_id),
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
detail={"sessions_revoquees": revoquees},
|
||||
)
|
||||
await self._transaction.commit()
|
||||
|
||||
compte = await self._users.get_by_id(revendique.user_id)
|
||||
if compte is None:
|
||||
raise SessionRejectedError("Compte introuvable")
|
||||
return self._session(self._en_principal(compte), secret)
|
||||
|
||||
async def logout_all(self, principal: Principal) -> int:
|
||||
revoquees = await self._refresh.revoke_all_for_user(
|
||||
principal.id, RevocationReason.DECONNEXION
|
||||
@@ -307,6 +400,23 @@ class AuthService:
|
||||
await self._transaction.commit()
|
||||
raise RateLimitedError(politique.window_seconds)
|
||||
|
||||
async def _refuse_si_limite_reset(self, *, email: str, client_ip: str | None) -> None:
|
||||
politique = self._reset_policy
|
||||
compteurs = await self._reset_attempts.count_recent(
|
||||
email=email, client_ip=client_ip, window_seconds=politique.window_seconds
|
||||
)
|
||||
|
||||
depasse = (
|
||||
compteurs.per_identifier >= politique.max_requests_per_identifier
|
||||
or compteurs.per_ip >= politique.max_requests_per_ip
|
||||
)
|
||||
if not depasse:
|
||||
return
|
||||
|
||||
await self._reset_attempts.record(email=email, client_ip=client_ip)
|
||||
await self._transaction.commit()
|
||||
raise RateLimitedError(politique.window_seconds)
|
||||
|
||||
async def _echoue(
|
||||
self,
|
||||
email: str,
|
||||
|
||||
+174
-1
@@ -424,6 +424,144 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/forgot-password": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Demande un lien de réinitialisation par email",
|
||||
"operationId": "forgot_password_api_v1_auth_forgot_password_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ForgotPasswordRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InternalErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Trop de demandes sur cette fenêtre glissante.",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Secondes à attendre avant une nouvelle tentative.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/reset-password": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Choisit un nouveau mot de passe depuis un lien reçu par email",
|
||||
"operationId": "reset_password_api_v1_auth_reset_password_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ResetPasswordRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TokenResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InternalErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/users": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1432,6 +1570,20 @@
|
||||
],
|
||||
"title": "FieldError"
|
||||
},
|
||||
"ForgotPasswordRequest": {
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"title": "Email"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"email"
|
||||
],
|
||||
"title": "ForgotPasswordRequest"
|
||||
},
|
||||
"InternalErrorResponse": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
@@ -1511,7 +1663,7 @@
|
||||
"new_password": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"minLength": 12,
|
||||
"minLength": 8,
|
||||
"title": "New Password"
|
||||
}
|
||||
},
|
||||
@@ -1619,6 +1771,27 @@
|
||||
],
|
||||
"title": "RecommendationResponse"
|
||||
},
|
||||
"ResetPasswordRequest": {
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"title": "Token"
|
||||
},
|
||||
"new_password": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"minLength": 8,
|
||||
"title": "New Password"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"token",
|
||||
"new_password"
|
||||
],
|
||||
"title": "ResetPasswordRequest"
|
||||
},
|
||||
"Role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"pyjwt>=2.10",
|
||||
"argon2-cffi>=23.1",
|
||||
"anyio>=4.0",
|
||||
"aiosmtplib>=5.1.3",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.core.roles import AccountKind, Role
|
||||
from app.services.auth import (
|
||||
AuthenticatedSession,
|
||||
InvalidCredentialsError,
|
||||
InvalidOrExpiredResetTokenError,
|
||||
RateLimitedError,
|
||||
SessionRejectedError,
|
||||
)
|
||||
@@ -36,6 +37,14 @@ class FauxService:
|
||||
async def logout(self, **_: object) -> None:
|
||||
return None
|
||||
|
||||
async def request_password_reset(self, **_: object) -> None:
|
||||
if self._erreur is not None:
|
||||
raise self._erreur
|
||||
return None
|
||||
|
||||
async def confirm_password_reset(self, **_: object) -> AuthenticatedSession:
|
||||
return await self.authenticate()
|
||||
|
||||
async def authenticate(self, **_: object) -> AuthenticatedSession:
|
||||
if self._erreur is not None:
|
||||
raise self._erreur
|
||||
@@ -206,3 +215,94 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin(
|
||||
response = await client.post("/api/v1/auth/logout")
|
||||
|
||||
assert response.status_code != 403
|
||||
|
||||
|
||||
async def test_forgot_password_answers_202_when_the_account_exists(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"}
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
async def test_forgot_password_answers_202_identically_when_the_account_is_unknown(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/forgot-password", json={"email": "inconnu@enervision.fr"}
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
|
||||
|
||||
async def test_forgot_password_returns_429_with_a_retry_after_when_the_rate_limit_is_reached(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_auth_service[0] = RateLimitedError(900)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"}
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.headers["retry-after"] == "900"
|
||||
|
||||
|
||||
async def test_forgot_password_rejects_a_malformed_email(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post("/api/v1/auth/forgot-password", json={"email": "pas-un-email"})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_reset_password_returns_the_token_and_the_cookie_on_success(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.cookies.get("ev_refresh") is not None
|
||||
assert "refresh_secret" not in response.text
|
||||
|
||||
|
||||
async def test_reset_password_rejects_an_invalid_or_expired_token(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_auth_service[0] = InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={"token": "un-secret-perime", "new_password": "Un-nouveau-mot-de-passe1!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
async def test_reset_password_rejects_a_weak_password(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={"token": "un-secret-opaque", "new_password": "trop-simple"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_reset_password_refuses_a_foreign_origin(
|
||||
fake_auth_service: list[Exception | None], client: AsyncClient
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"},
|
||||
headers={"Origin": "https://malveillant.example"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -18,6 +18,10 @@ ROUTES_PUBLIQUES = frozenset(
|
||||
("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"),
|
||||
("POST", "/api/v1/auth/forgot-password"),
|
||||
# Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune
|
||||
# authentification préalable ne s'applique, c'est la validité du jeton qui tranche.
|
||||
("POST", "/api/v1/auth/reset-password"),
|
||||
("GET", "/metrics"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Le premier test démontre l'atomicité de `consume()` : sur un double, deux soumissions
|
||||
# concurrentes du même lien réussiraient toutes 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.repositories.password_reset_token import PasswordResetTokenRepository
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
DUREE = timedelta(minutes=15)
|
||||
|
||||
|
||||
async def un_compte(session: AsyncSession) -> uuid.UUID:
|
||||
compte = await UserRepository(session).create(
|
||||
email=f"reset-{uuid.uuid4().hex[:12]}@enervision.fr",
|
||||
password_hash="$argon2id$x",
|
||||
role=Role.LECTEUR,
|
||||
)
|
||||
return compte.id
|
||||
|
||||
|
||||
async def un_jeton(
|
||||
depot: PasswordResetTokenRepository, user_id: uuid.UUID, *, duree: timedelta = DUREE
|
||||
) -> str:
|
||||
secret = generate_refresh_secret()
|
||||
await depot.create(
|
||||
user_id=user_id,
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + duree,
|
||||
client_ip="203.0.113.10",
|
||||
user_agent="pytest",
|
||||
)
|
||||
return secret
|
||||
|
||||
|
||||
async def test_consume_only_succeeds_once(session: AsyncSession) -> None:
|
||||
depot = PasswordResetTokenRepository(session)
|
||||
secret = await un_jeton(depot, await un_compte(session))
|
||||
|
||||
premier = await depot.consume(fingerprint_refresh(secret))
|
||||
second = await depot.consume(fingerprint_refresh(secret))
|
||||
await session.rollback()
|
||||
|
||||
assert premier is not None
|
||||
assert second is None
|
||||
|
||||
|
||||
async def test_consume_refuses_an_expired_token(session: AsyncSession) -> None:
|
||||
depot = PasswordResetTokenRepository(session)
|
||||
secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1))
|
||||
|
||||
revendique = await depot.consume(fingerprint_refresh(secret))
|
||||
await session.rollback()
|
||||
|
||||
assert revendique is None
|
||||
|
||||
|
||||
async def test_consume_returns_nothing_for_an_unknown_fingerprint(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
revendique = await PasswordResetTokenRepository(session).consume(
|
||||
fingerprint_refresh(generate_refresh_secret())
|
||||
)
|
||||
|
||||
assert revendique is None
|
||||
|
||||
|
||||
async def test_invalidate_all_for_user_only_touches_living_tokens(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
depot = PasswordResetTokenRepository(session)
|
||||
compte = await un_compte(session)
|
||||
await un_jeton(depot, compte)
|
||||
await un_jeton(depot, compte)
|
||||
|
||||
invalides = await depot.invalidate_all_for_user(compte)
|
||||
second_passage = await depot.invalidate_all_for_user(compte)
|
||||
await session.rollback()
|
||||
|
||||
assert invalides == 2
|
||||
assert second_passage == 0
|
||||
|
||||
|
||||
async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
depot = PasswordResetTokenRepository(session)
|
||||
compte = await un_compte(session)
|
||||
secret = generate_refresh_secret()
|
||||
await depot.create(
|
||||
user_id=compte,
|
||||
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,
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + DUREE,
|
||||
client_ip=None,
|
||||
user_agent=None,
|
||||
)
|
||||
await session.rollback()
|
||||
@@ -0,0 +1,41 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.auth import PasswordChangeRequest, valide_complexite
|
||||
|
||||
MOT_DE_PASSE_VALIDE = "Un-mot-de-passe1!"
|
||||
|
||||
|
||||
def test_password_change_request_accepts_a_password_covering_the_four_classes() -> None:
|
||||
requete = PasswordChangeRequest(
|
||||
current_password="peu-importe", new_password=MOT_DE_PASSE_VALIDE
|
||||
)
|
||||
|
||||
assert requete.new_password == MOT_DE_PASSE_VALIDE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_password",
|
||||
[
|
||||
"un-mot-de-passe1!",
|
||||
"UN-MOT-DE-PASSE1!",
|
||||
"Un-mot-de-passe!",
|
||||
"Un mot de passe 1",
|
||||
],
|
||||
ids=["sans_majuscule", "sans_minuscule", "sans_chiffre", "sans_caractere_special"],
|
||||
)
|
||||
def test_password_change_request_rejects_a_password_missing_a_character_class(
|
||||
new_password: str,
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
PasswordChangeRequest(current_password="peu-importe", new_password=new_password)
|
||||
|
||||
|
||||
def test_password_change_request_rejects_a_password_below_the_minimum_length() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
PasswordChangeRequest(current_password="peu-importe", new_password="Ab1!")
|
||||
|
||||
|
||||
def test_valide_complexite_names_every_missing_class_in_the_error() -> None:
|
||||
with pytest.raises(ValueError, match=r"majuscule.*chiffre|chiffre.*majuscule"):
|
||||
valide_complexite("minuscules-seulement")
|
||||
@@ -16,11 +16,15 @@ from app.core.security import (
|
||||
from app.models.login_attempt import LoginOutcome
|
||||
from app.models.refresh_token import RevocationReason
|
||||
from app.repositories.login_attempt import FailureCounts
|
||||
from app.repositories.password_reset_attempt import ResetRequestCounts
|
||||
from app.repositories.password_reset_token import ConsumedResetToken
|
||||
from app.repositories.refresh_token import ClaimedToken
|
||||
from app.services.auth import (
|
||||
AuthService,
|
||||
InvalidCredentialsError,
|
||||
InvalidOrExpiredResetTokenError,
|
||||
LoginPolicy,
|
||||
PasswordResetPolicy,
|
||||
RateLimitedError,
|
||||
SessionRejectedError,
|
||||
)
|
||||
@@ -37,6 +41,13 @@ POLITIQUE_CONNEXION = LoginPolicy(
|
||||
max_failures_per_ip=20,
|
||||
max_failures_per_identifier=50,
|
||||
)
|
||||
POLITIQUE_RESET = PasswordResetPolicy(
|
||||
window_seconds=900,
|
||||
max_requests_per_identifier=3,
|
||||
max_requests_per_ip=10,
|
||||
token_ttl=timedelta(minutes=15),
|
||||
frontend_reset_url="http://localhost:4200/reset-password",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -168,6 +179,43 @@ class FausseTransaction:
|
||||
self.validations += 1
|
||||
|
||||
|
||||
class FauxDepotJetonsReset:
|
||||
def __init__(self, revendique: ConsumedResetToken | None = None) -> None:
|
||||
self.revendique = revendique
|
||||
self.crees: list[UUID] = []
|
||||
self.invalidations: list[UUID] = []
|
||||
|
||||
async def create(self, *, user_id: UUID, **_: object) -> None:
|
||||
self.crees.append(user_id)
|
||||
|
||||
async def consume(self, token_hash: bytes) -> ConsumedResetToken | None:
|
||||
return self.revendique
|
||||
|
||||
async def invalidate_all_for_user(self, user_id: UUID) -> int:
|
||||
self.invalidations.append(user_id)
|
||||
return len(self.invalidations)
|
||||
|
||||
|
||||
class FauxDepotTentativesReset:
|
||||
def __init__(self, compteurs: ResetRequestCounts | None = None) -> None:
|
||||
self.compteurs = compteurs or ResetRequestCounts(0, 0)
|
||||
self.enregistrees: list[str] = []
|
||||
|
||||
async def count_recent(self, **_: object) -> ResetRequestCounts:
|
||||
return self.compteurs
|
||||
|
||||
async def record(self, *, email: str, **_: object) -> None:
|
||||
self.enregistrees.append(email)
|
||||
|
||||
|
||||
class FauxMailer:
|
||||
def __init__(self) -> None:
|
||||
self.envois: list[tuple[str, str]] = []
|
||||
|
||||
async def send_password_reset_email(self, *, to: str, reset_url: str) -> None:
|
||||
self.envois.append((to, reset_url))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attirail:
|
||||
service: AuthService
|
||||
@@ -176,6 +224,9 @@ class Attirail:
|
||||
jetons: FauxDepotJetons
|
||||
audit: FauxDepotAudit
|
||||
hacheur: FauxHacheur
|
||||
jetons_reset: FauxDepotJetonsReset
|
||||
tentatives_reset: FauxDepotTentativesReset
|
||||
mailer: FauxMailer
|
||||
|
||||
|
||||
def fabrique_service(
|
||||
@@ -184,12 +235,17 @@ def fabrique_service(
|
||||
compteurs: FailureCounts | None = None,
|
||||
hacheur: FauxHacheur | None = None,
|
||||
jetons: FauxDepotJetons | None = None,
|
||||
jetons_reset: FauxDepotJetonsReset | None = None,
|
||||
compteurs_reset: ResetRequestCounts | None = None,
|
||||
) -> Attirail:
|
||||
comptes = FauxDepotComptes(compte)
|
||||
tentatives = FauxDepotTentatives(compteurs)
|
||||
depot_jetons = jetons or FauxDepotJetons()
|
||||
audit = FauxDepotAudit()
|
||||
hacheur = hacheur or FauxHacheur()
|
||||
depot_jetons_reset = jetons_reset or FauxDepotJetonsReset()
|
||||
tentatives_reset = FauxDepotTentativesReset(compteurs_reset)
|
||||
mailer = FauxMailer()
|
||||
service = AuthService(
|
||||
users=comptes, # type: ignore[arg-type]
|
||||
attempts=tentatives, # type: ignore[arg-type]
|
||||
@@ -200,8 +256,22 @@ def fabrique_service(
|
||||
token_policy=POLITIQUE_JETON,
|
||||
login_policy=POLITIQUE_CONNEXION,
|
||||
refresh_ttl=timedelta(days=7),
|
||||
reset_tokens=depot_jetons_reset, # type: ignore[arg-type]
|
||||
reset_attempts=tentatives_reset, # type: ignore[arg-type]
|
||||
reset_policy=POLITIQUE_RESET,
|
||||
mailer=mailer, # type: ignore[arg-type]
|
||||
)
|
||||
return Attirail(
|
||||
service,
|
||||
comptes,
|
||||
tentatives,
|
||||
depot_jetons,
|
||||
audit,
|
||||
hacheur,
|
||||
depot_jetons_reset,
|
||||
tentatives_reset,
|
||||
mailer,
|
||||
)
|
||||
return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur)
|
||||
|
||||
|
||||
async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object:
|
||||
@@ -493,3 +563,89 @@ async def test_change_password_refuses_a_wrong_current_password() -> None:
|
||||
|
||||
assert attirail.jetons.revocations_par_compte == []
|
||||
assert attirail.jetons.crees == []
|
||||
|
||||
|
||||
async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None:
|
||||
compte = FauxCompte()
|
||||
attirail = fabrique_service(compte=compte)
|
||||
|
||||
await attirail.service.request_password_reset(
|
||||
email=compte.email, client_ip="203.0.113.10", user_agent="pytest"
|
||||
)
|
||||
|
||||
assert attirail.jetons_reset.invalidations == [compte.id]
|
||||
assert attirail.jetons_reset.crees == [compte.id]
|
||||
assert len(attirail.mailer.envois) == 1
|
||||
assert attirail.mailer.envois[0][0] == compte.email
|
||||
assert "auth.password_reset_requested" in attirail.audit.lignes[0][0]
|
||||
|
||||
|
||||
async def test_request_password_reset_stays_silent_when_the_account_is_unknown() -> None:
|
||||
attirail = fabrique_service(compte=None)
|
||||
|
||||
await attirail.service.request_password_reset(
|
||||
email="inconnu@enervision.fr", client_ip="203.0.113.10", user_agent="pytest"
|
||||
)
|
||||
|
||||
assert attirail.jetons_reset.crees == []
|
||||
assert attirail.mailer.envois == []
|
||||
assert attirail.hacheur.verifications == 1, "le hachage factice doit tout de même tourner"
|
||||
|
||||
|
||||
async def test_request_password_reset_stays_silent_when_the_account_is_inactive() -> None:
|
||||
compte = FauxCompte(is_active=False)
|
||||
attirail = fabrique_service(compte=compte)
|
||||
|
||||
await attirail.service.request_password_reset(
|
||||
email=compte.email, client_ip="203.0.113.10", user_agent="pytest"
|
||||
)
|
||||
|
||||
assert attirail.jetons_reset.crees == []
|
||||
assert attirail.mailer.envois == []
|
||||
|
||||
|
||||
async def test_request_password_reset_raises_when_the_rate_limit_is_reached() -> None:
|
||||
attirail = fabrique_service(compteurs_reset=ResetRequestCounts(per_identifier=3, per_ip=0))
|
||||
|
||||
with pytest.raises(RateLimitedError):
|
||||
await attirail.service.request_password_reset(
|
||||
email="operateur@enervision.fr", client_ip="203.0.113.10", user_agent="pytest"
|
||||
)
|
||||
|
||||
assert attirail.mailer.envois == []
|
||||
|
||||
|
||||
async def test_confirm_password_reset_revokes_every_session_then_reopens_the_current_one() -> None:
|
||||
compte = FauxCompte()
|
||||
jetons_reset = FauxDepotJetonsReset(
|
||||
revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id)
|
||||
)
|
||||
attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset)
|
||||
|
||||
session = await attirail.service.confirm_password_reset(
|
||||
token="un-secret-opaque",
|
||||
new_password="Un-nouveau-mot-de-passe1!",
|
||||
client_ip="203.0.113.10",
|
||||
user_agent="pytest",
|
||||
)
|
||||
|
||||
assert attirail.jetons.revocations_par_compte == [
|
||||
(compte.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value)
|
||||
]
|
||||
assert len(attirail.jetons.crees) == 1
|
||||
assert session.refresh_secret
|
||||
assert "auth.password_reset_self_service" in attirail.audit.lignes[0][0]
|
||||
|
||||
|
||||
async def test_confirm_password_reset_rejects_an_invalid_or_expired_token() -> None:
|
||||
attirail = fabrique_service(jetons_reset=FauxDepotJetonsReset(revendique=None))
|
||||
|
||||
with pytest.raises(InvalidOrExpiredResetTokenError):
|
||||
await attirail.service.confirm_password_reset(
|
||||
token="un-secret-invalide",
|
||||
new_password="Un-nouveau-mot-de-passe1!",
|
||||
client_ip=None,
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
assert attirail.jetons.revocations_par_compte == []
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from app import cli
|
||||
from app.schemas.auth import valide_complexite
|
||||
|
||||
|
||||
def test_build_parser_reads_the_create_admin_arguments() -> None:
|
||||
@@ -34,26 +35,36 @@ def test_read_password_generates_a_long_secret_when_asked(
|
||||
|
||||
assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE
|
||||
assert mot_de_passe in capsys.readouterr().out
|
||||
valide_complexite(mot_de_passe)
|
||||
|
||||
|
||||
def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"])
|
||||
saisies = iter(["Un-mot-de-passe-valide1", "Un-mot-de-passe-valide1"])
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
||||
|
||||
assert cli.read_password(generate=False) == "un-mot-de-passe-valide"
|
||||
assert cli.read_password(generate=False) == "Un-mot-de-passe-valide1"
|
||||
|
||||
|
||||
def test_read_password_refuses_a_password_below_the_minimum_length(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: "court")
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: "Court1!")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.read_password(generate=False)
|
||||
|
||||
|
||||
def test_read_password_refuses_a_password_missing_a_character_class(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: "un-mot-de-passe-sans-majuscule-ni-chiffre")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.read_password(generate=False)
|
||||
|
||||
|
||||
def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"])
|
||||
saisies = iter(["Un-mot-de-passe-valide1", "Un-autre-mot-de-passe2"])
|
||||
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
|
||||
Generated
+11
@@ -2,6 +2,15 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = "==3.14.*"
|
||||
|
||||
[[package]]
|
||||
name = "aiosmtplib"
|
||||
version = "5.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/5c/9cabc5db6d607616e81ba6d8f1f231cd5a75955807a308c1090a59072d6d/aiosmtplib-5.1.3.tar.gz", hash = "sha256:ac2b418d3260ba62d9cfd0fe7359726e9dc009a4e8e8d9909fdfae332f522a7c", size = 77010, upload-time = "2026-09-08T02:11:20.532Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/0a/b56ab8163d54960337fdca475d3dfd56c8badf6172e79cf2ad00d5335dc1/aiosmtplib-5.1.3-py3-none-any.whl", hash = "sha256:f7d76ce3d4995a65a178c1f11e1bd1607706b921d00cb768e7a2c7f7ef5517a8", size = 30116, upload-time = "2026-09-08T02:11:19.352Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.20.0"
|
||||
@@ -306,6 +315,7 @@ name = "enervision-backend"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosmtplib" },
|
||||
{ name = "alembic" },
|
||||
{ name = "anyio" },
|
||||
{ name = "argon2-cffi" },
|
||||
@@ -332,6 +342,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosmtplib", specifier = ">=5.1.3" },
|
||||
{ name = "alembic", specifier = ">=1.20.0" },
|
||||
{ name = "anyio", specifier = ">=4.0" },
|
||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||
|
||||
Reference in New Issue
Block a user