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
+30 -1
View File
@@ -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,
)
+13
View File
@@ -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"},
}
},
},
}
+83
View File
@@ -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)