Compare commits

...
Author SHA1 Message Date
Johan LEROY c741ffc827 fix(auth): corrige la CI cassee par le nouvel endpoint de validation
Backend / Lint, typage et tests (push) Successful in 1m19s
/auth/reset-password/validate manquait a la liste explicite des routes
publiques (test_route_protection) et n'avait pas le modele de reponse
422 declare (openapi.json desynchronise du contrat genere).
2026-09-17 15:31:14 +02:00
Johan LEROY d7f775f9f7 feat(auth): verifie le lien de reset des le chargement, sans le consommer
Ajoute GET /auth/reset-password/validate (lecture seule, sans rate
limit : le jeton est un secret de 256 bits non brute-forcable) pour que
la page reset-password redirige immediatement vers /login si le lien
est invalide ou expire, plutot que d'attendre la soumission du
formulaire. La verification a la soumission (confirm_password_reset)
reste la seule source de verite atomique.
2026-09-17 15:28:50 +02:00
Johan LEROY e381e0de09 feat(frontend): checklist de complexite du mot de passe sur reset-password
Remplace l'indice statique sous le champ nouveau mot de passe par une
checklist qui coche chaque regle (longueur, majuscule, minuscule,
chiffre, caractere special) au fur et a mesure de la saisie. Les regles
individuelles (PASSWORD_REQUIREMENTS) sont exposees depuis le meme
validateur que PASSWORD_PATTERN pour rester la seule source de verite.
2026-09-17 14:57:05 +02:00
Johan LEROY 7674955637 fix(frontend): un lien de reset absent ou expire renvoie vers login avec un message standard
Avant, un token absent affichait un message inline sur /reset-password, et
un token invalide/expire ne se voyait qu'apres soumission du formulaire.
Les deux cas redirigent maintenant vers /login avec le motif
"lien-expire", qui y affiche le message standard "Ce lien de
reinitialisation est invalide ou a expire. Connectez-vous ou
redemandez-en un."
2026-09-17 14:49:46 +02:00
Johan LEROY 063092f2c7 fix(frontend): le rafraichissement de session au demarrage ne doit pas ecraser un lien de reset
Le refresh de session lance par provideAppInitializer echoue silencieusement
sans cookie valide, mais l'intercepteur forcait quand meme un
router.navigate(['/login']) sur le 401 resultant, ecrasant la navigation
vers /reset-password?token=... venue de l'email. L'intercepteur ne
redirige plus quand on est deja sur une route invitee (login,
forgot-password, reset-password).
2026-09-17 14:40:53 +02:00
Johan LEROY 921da48eb1 fix(auth): corrige 4 failles de la revue de securite sur la PR #90
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).
2026-09-17 14:35:14 +02:00
Johan LEROY 916b5d246a Merge remote-tracking branch 'origin/dev' into feat/password-policy-forgot-password
# Conflicts:
#	apps/backend/app/api/deps.py
#	apps/backend/pyproject.toml
2026-09-17 12:18:48 +02:00
Johan LEROYandGitHub 7913518c4b Merge pull request #94 from ineszang/feat/get-readings
feat(backend): expose GET /api/v1/readings avec fenetre bornee et pag…
2026-09-17 12:12:55 +02:00
ineszangandGitHub 7dfd7a7e74 Merge pull request #88 from ineszang/feat/data-import
Ajout du pipeline d'import des données historiques
2026-09-17 11:57:58 +02:00
Johan LEROY 62932e57c3 style(backend): formatage ruff de cli.py 2026-09-17 11:42:44 +02:00
Johan LEROY 9161b74874 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
2026-09-17 10:53:58 +02:00
Meryemel-gham 6798d35572 fix(data): corrige le typage de l'import historique
Backend / Lint, typage et tests (push) Successful in 1m16s
2026-09-17 10:41:18 +02:00
Meryemel-gham f03dce5fe3 style(data): applique le formatage Ruff 2026-09-17 10:06:54 +02:00
Meryemel-gham 74ac1b4577 docs(data): documente le pipeline d'import historique 2026-09-17 09:55:27 +02:00
Meryemel-gham ebb72fb399 test(data): couvre l'import historique 2026-09-17 09:34:30 +02:00
Meryemel-gham b2d52823ba fix(data): aligne l'import historique avec les contraintes BDD 2026-09-17 09:34:30 +02:00
Meryemel-gham fcbfcc8eb2 feat(data): ajoute l'import historique des donnees 2026-09-17 09:34:30 +02:00
64 changed files with 3914 additions and 50 deletions
+2 -1
View File
@@ -52,7 +52,8 @@ standalone_admin_password.txt
secrets/ secrets/
# Donnees locales # Donnees locales
data/ data/raw/*
!data/raw/.gitkeep
*.sqlite3 *.sqlite3
monitoring/grafana/data/ monitoring/grafana/data/
monitoring/prometheus/data/ monitoring/prometheus/data/
+10
View File
@@ -8,3 +8,13 @@ APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200 APP_CORS_ORIGINS=http://localhost:4200
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision 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
+2
View File
@@ -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` | Ferme la session courante | cookie, idempotente |
| `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton | | `/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/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/auth/me` | Décrit le compte connecté | jeton |
| `/api/v1/users` | Liste et crée des comptes | `admin` | | `/api/v1/users` | Liste et crée des comptes | `admin` |
| `/api/v1/users/{id}` | Change le rôle ou l'activation | `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")
+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.config import Settings, get_settings
from app.core.hashing import Argon2Hasher, build_hasher from app.core.hashing import Argon2Hasher, build_hasher
from app.core.mailer import Mailer, SmtpConfig
from app.core.principal import Principal from app.core.principal import Principal
from app.core.roles import AccountKind, Role, has_at_least from app.core.roles import AccountKind, Role, has_at_least
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy 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.alert import AlertRepository
from app.repositories.audit_log import AuditLogRepository from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository 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.reading import ReadingRepository
from app.repositories.recommendation import RecommendationRepository from app.repositories.recommendation import RecommendationRepository
from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.site import SiteRepository from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository from app.repositories.user import UserRepository
from app.services.alert import AlertService from app.services.alert import AlertService
from app.services.auth import AuthService, LoginPolicy from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
from app.services.reading import ReadingService from app.services.reading import ReadingService
from app.services.recommendation import RecommendationService from app.services.recommendation import RecommendationService
from app.services.sensor import SensorService from app.services.sensor import SensorService
@@ -98,11 +101,27 @@ def get_client_ip(request: Request, settings: SettingsDep) -> str | None:
return request.client.host if request.client else 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( def get_auth_service(
session: SessionDep, session: SessionDep,
settings: SettingsDep, settings: SettingsDep,
hasher: Annotated[Argon2Hasher, Depends(get_hasher)], hasher: Annotated[Argon2Hasher, Depends(get_hasher)],
token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], token_policy: Annotated[TokenPolicy, Depends(get_token_policy)],
mailer: Annotated[Mailer, Depends(get_mailer)],
) -> AuthService: ) -> AuthService:
return AuthService( return AuthService(
users=UserRepository(session), users=UserRepository(session),
@@ -119,6 +138,16 @@ def get_auth_service(
max_failures_per_identifier=settings.login_max_failures_per_identifier, max_failures_per_identifier=settings.login_max_failures_per_identifier,
), ),
refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds), 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
@@ -164,3 +164,16 @@ REPONSE_ORIGINE_REFUSEE: Final[Reponses] = {
"description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", "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"},
}
},
},
}
+97 -1
View File
@@ -2,7 +2,7 @@
# d'accès ne va jamais dans un cookie. C'est ce qui réduit la surface CSRF aux trois routes de # d'accès ne va jamais dans un cookie. C'est ce qui réduit la surface CSRF aux trois routes de
# ce module : partout ailleurs, le navigateur n'attache rien de lui-même. # ce module : partout ailleurs, le navigateur n'attache rien de lui-même.
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
from app.api.deps import ( from app.api.deps import (
AuthServiceDep, AuthServiceDep,
@@ -12,6 +12,7 @@ from app.api.deps import (
require_trusted_origin, require_trusted_origin,
) )
from app.api.openapi import ( from app.api.openapi import (
REPONSE_LIMITE,
REPONSE_ORIGINE_REFUSEE, REPONSE_ORIGINE_REFUSEE,
REPONSE_VALIDATION, REPONSE_VALIDATION,
REPONSES_AUTHENTIFIEES, REPONSES_AUTHENTIFIEES,
@@ -21,15 +22,19 @@ from app.api.openapi import (
from app.core.cookies import RefreshCookie, cookie_name from app.core.cookies import RefreshCookie, cookie_name
from app.core.logging import get_logger from app.core.logging import get_logger
from app.schemas.auth import ( from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest, LoginRequest,
PasswordChangeRequest, PasswordChangeRequest,
PrincipalResponse, PrincipalResponse,
ResetPasswordRequest,
ResetTokenValidationResponse,
TokenResponse, TokenResponse,
) )
from app.schemas.errors import ErrorResponse from app.schemas.errors import ErrorResponse
from app.services.auth import ( from app.services.auth import (
AuthenticatedSession, AuthenticatedSession,
InvalidCredentialsError, InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
RateLimitedError, RateLimitedError,
SessionRejectedError, SessionRejectedError,
) )
@@ -39,6 +44,7 @@ logger = get_logger(__name__)
DETAIL_IDENTIFIANTS = "Identifiants invalides" DETAIL_IDENTIFIANTS = "Identifiants invalides"
DETAIL_SESSION = "Session invalide" DETAIL_SESSION = "Session invalide"
DETAIL_LIEN_RESET = "Lien invalide ou expiré"
REPONSES_LOGIN: Reponses = { REPONSES_LOGIN: Reponses = {
**REPONSE_VALIDATION, **REPONSE_VALIDATION,
@@ -85,6 +91,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( def repond(
response: Response, settings: SettingsDep, session: AuthenticatedSession response: Response, settings: SettingsDep, session: AuthenticatedSession
@@ -267,3 +287,79 @@ async def change_password(
logger.info("auth.password_changed user_id=%s", principal.id) logger.info("auth.password_changed user_id=%s", principal.id)
return repond(response, settings, session) 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,
background_tasks: BackgroundTasks,
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"),
background_tasks=background_tasks,
)
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.get(
"/reset-password/validate",
response_model=ResetTokenValidationResponse,
summary="Vérifie sans le consommer si un lien de réinitialisation est encore valide",
responses=REPONSE_VALIDATION,
)
async def validate_reset_token(token: str, service: AuthServiceDep) -> ResetTokenValidationResponse:
return ResetTokenValidationResponse(valid=await service.is_reset_token_valid(token=token))
@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)
+24 -4
View File
@@ -9,6 +9,7 @@ import argparse
import asyncio import asyncio
import json import json
import secrets import secrets
import string
import sys import sys
from getpass import getpass from getpass import getpass
from pathlib import Path from pathlib import Path
@@ -22,9 +23,9 @@ from app.core.roles import Role
from app.db.session import get_session_factory from app.db.session import get_session_factory
from app.main import create_app from app.main import create_app
from app.repositories.user import UserRepository from app.repositories.user import UserRepository
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
LONGUEUR_MOT_DE_PASSE_GENERE = 24 LONGUEUR_MOT_DE_PASSE_GENERE = 24
LONGUEUR_MINIMALE = 12
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
@@ -111,15 +112,34 @@ def build_parser() -> argparse.ArgumentParser:
return parser return parser
def genere_mot_de_passe() -> str:
tirage = secrets.SystemRandom()
classes = [
string.ascii_uppercase,
string.ascii_lowercase,
string.digits,
SPECIAL_CHARACTERS,
]
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: def read_password(*, generate: bool) -> str:
if generate: 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}") print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}")
return mot_de_passe return mot_de_passe
mot_de_passe = getpass("Mot de passe : ") mot_de_passe = getpass("Mot de passe : ")
if len(mot_de_passe) < LONGUEUR_MINIMALE: if len(mot_de_passe) < PASSWORD_MIN_LENGTH:
raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères") 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 : "): if mot_de_passe != getpass("Confirmation : "):
raise SystemExit("Les deux saisies diffèrent") raise SystemExit("Les deux saisies diffèrent")
return mot_de_passe return mot_de_passe
+13
View File
@@ -54,6 +54,19 @@ class Settings(BaseSettings):
login_max_failures_per_ip: int = Field(default=20, ge=1) login_max_failures_per_ip: int = Field(default=20, ge=1)
login_max_failures_per_identifier: int = Field(default=50, 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 trust_proxy_headers: bool = False
expose_api_docs: bool | None = None expose_api_docs: bool | None = None
metrics_token: SecretStr | None = None metrics_token: SecretStr | None = None
+48
View File
@@ -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)
View File
+621
View File
@@ -0,0 +1,621 @@
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
from pathlib import Path
from typing import Any, cast
import pandas as pd
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings
REQUIRED_COLUMNS = {
"timestamp",
"site_id",
"site_type",
"site_name",
"consumption_kwh",
"consumption_euros",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
"hour",
"day_of_week",
"day_name",
"month",
"is_weekend",
"is_working_hours",
}
MEASURE_COLUMNS = [
"consumption_kwh",
"consumption_euros",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
]
SOURCE_NAME = "csv"
def compute_sha256(path: Path) -> str:
"""Calcule l'empreinte SHA-256 du fichier source."""
sha256 = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
sha256.update(block)
return sha256.hexdigest()
def load_metadata(path: Path) -> dict[str, Any]:
"""Charge les métadonnées fournies avec le dataset."""
with path.open("r", encoding="utf-8") as source:
metadata = json.load(source)
if not isinstance(metadata, dict):
raise ValueError("Le fichier de métadonnées doit contenir un objet JSON.")
return cast(dict[str, Any], metadata)
def classify_quality(
row: dict[str, Any],
) -> tuple[str, list[str]]:
"""
Déduit une qualité technique à partir des champs manquants.
Les valeurs NULL sont conservées. On ne cherche pas ici à
déterminer la cause physique exacte de leur absence.
"""
missing = [column for column in MEASURE_COLUMNS if pd.isna(row.get(column))]
if not missing:
quality = "good"
elif len(missing) == len(MEASURE_COLUMNS):
quality = "critical"
elif "consumption_kwh" in missing:
quality = "degraded"
else:
quality = "partial"
reasons = [f"missing:{column}" for column in missing]
return quality, reasons
def validate_source(
frame: pd.DataFrame,
metadata: dict[str, Any],
) -> None:
"""Valide le dataset avant tout chargement en base."""
missing_columns = REQUIRED_COLUMNS.difference(frame.columns)
if missing_columns:
raise ValueError(f"Colonnes obligatoires absentes : {sorted(missing_columns)}")
expected_records = int(metadata["total_records"])
if len(frame) != expected_records:
raise ValueError(f"Nombre de lignes inattendu : {len(frame)} au lieu de {expected_records}")
expected_sites = set(metadata["sites"].keys())
actual_sites = set(frame["site_id"].unique())
if actual_sites != expected_sites:
raise ValueError(
f"Sites incohérents. Attendus={sorted(expected_sites)}, trouvés={sorted(actual_sites)}"
)
duplicated = frame.duplicated(subset=["site_id", "timestamp"]).sum()
if duplicated:
raise ValueError(f"{duplicated} doublons (site_id, timestamp) détectés")
static_variants = frame.groupby("site_id")[["site_type", "site_name"]].nunique()
if (static_variants > 1).any().any():
raise ValueError("Un site possède plusieurs valeurs de site_type ou site_name.")
# Vérifie également que tous les timestamps
# peuvent être interprétés correctement.
pd.to_datetime(
frame["timestamp"],
errors="raise",
)
def normalize_timestamps(
frame: pd.DataFrame,
source_timezone: str,
) -> pd.DataFrame:
"""
Normalise les timestamps et leur associe une timezone.
Les timestamps originaux sont conservés dans une colonne
temporaire afin de pouvoir les stocker dans raw_data.
"""
normalized = frame.copy()
normalized["_source_timestamp"] = normalized["timestamp"]
timestamps = pd.to_datetime(
normalized["timestamp"],
errors="raise",
)
if timestamps.dt.tz is None:
timestamps = timestamps.dt.tz_localize(source_timezone)
else:
timestamps = timestamps.dt.tz_convert(source_timezone)
normalized["timestamp"] = timestamps
return normalized
def to_json_value(value: Any) -> Any:
"""
Convertit une valeur Pandas/Numpy en valeur
compatible JSON.
"""
if value is None:
return None
try:
if pd.isna(value):
return None
except TypeError, ValueError:
pass
if isinstance(value, pd.Timestamp):
return value.isoformat()
if hasattr(value, "item"):
return value.item()
return value
async def ensure_dataset(
connection: AsyncConnection,
metadata: dict[str, Any],
sha256: str,
source_timezone: str,
storage_uri: str,
) -> int:
"""
Crée l'entrée dataset si elle n'existe pas.
Le SHA-256 permet de reconnaître un fichier déjà importé
et participe à l'idempotence et à la traçabilité.
"""
result = await connection.execute(
text(
"""
SELECT dataset_id
FROM dataset
WHERE archive_sha256 = :sha256
LIMIT 1
"""
),
{
"sha256": sha256,
},
)
existing = result.scalar_one_or_none()
if existing is not None:
return int(existing)
metadata_summary = {
"generator_version": metadata.get("generator_version"),
"total_sites": metadata.get("total_sites"),
"total_records": metadata.get("total_records"),
"date_range": metadata.get("date_range"),
"frequency": metadata.get("frequency"),
"null_injection_enabled": metadata.get("null_injection_enabled"),
"null_strategies": metadata.get("null_strategies"),
"importer": "historical_import_v1",
}
result = await connection.execute(
text(
"""
INSERT INTO dataset (
dataset_name,
archive_sha256,
storage_uri,
source_timezone,
"metadata"
)
VALUES (
:dataset_name,
:archive_sha256,
:storage_uri,
:source_timezone,
CAST(:metadata AS jsonb)
)
RETURNING dataset_id
"""
),
{
"dataset_name": ("EnerVision historical dataset 2023-2024"),
"archive_sha256": sha256,
"storage_uri": storage_uri,
"source_timezone": source_timezone,
"metadata": json.dumps(
metadata_summary,
ensure_ascii=False,
),
},
)
return int(result.scalar_one())
async def upsert_sites(
connection: AsyncConnection,
frame: pd.DataFrame,
) -> None:
"""Insère ou met à jour les sites du dataset."""
sites = cast(
list[dict[str, Any]],
frame[
[
"site_id",
"site_type",
"site_name",
]
]
.drop_duplicates(subset=["site_id"])
.to_dict(orient="records"),
)
await connection.execute(
text(
"""
INSERT INTO site (
site_id,
site_type,
site_name
)
VALUES (
:site_id,
:site_type,
:site_name
)
ON CONFLICT (site_id)
DO UPDATE SET
site_type = EXCLUDED.site_type,
site_name = EXCLUDED.site_name
"""
),
sites,
)
def build_reading_batch(
chunk: pd.DataFrame,
dataset_id: int,
) -> list[dict[str, Any]]:
"""
Transforme un chunk Pandas en lignes prêtes
à être chargées dans la table reading.
"""
rows: list[dict[str, Any]] = []
records = cast(
list[dict[str, Any]],
chunk.to_dict(orient="records"),
)
for record in records:
quality, reasons = classify_quality(record)
raw_data = {
column: to_json_value(value)
for column, value in record.items()
if column != "_source_timestamp"
}
# Dans raw_data, on conserve le timestamp
# exactement tel qu'il était dans le CSV.
raw_data["timestamp"] = to_json_value(record["_source_timestamp"])
rows.append(
{
"site_id": record["site_id"],
"timestamp": record["timestamp"],
"source": SOURCE_NAME,
"dataset_id": dataset_id,
# Non fourni par le dataset historique.
"consumption_kw": None,
"consumption_kwh": to_json_value(record["consumption_kwh"]),
"consumption_euros": to_json_value(record["consumption_euros"]),
# Non fournis par le CSV historique.
"voltage_v": None,
"current_a": None,
"power_factor": None,
"temperature_celsius": (to_json_value(record["temperature_celsius"])),
"humidity_percent": (to_json_value(record["humidity_percent"])),
"solar_irradiance_wm2": (to_json_value(record["solar_irradiance_wm2"])),
"is_working_hours": bool(record["is_working_hours"]),
"data_quality": quality,
"null_reasons": reasons,
# Aucune imputation pendant l'ingestion RAW.
# Les valeurs manquantes sont conservées telles quelles
# afin de préserver la donnée source.
"imputed_values": None,
"imputation_method": None,
# Conservation de la donnée source
# pour la traçabilité.
"raw_data": json.dumps(
raw_data,
ensure_ascii=False,
),
}
)
return rows
READING_INSERT = text(
"""
INSERT INTO reading (
site_id,
timestamp,
source,
dataset_id,
consumption_kw,
consumption_kwh,
consumption_euros,
voltage_v,
current_a,
power_factor,
temperature_celsius,
humidity_percent,
solar_irradiance_wm2,
is_working_hours,
data_quality,
null_reasons,
imputed_values,
imputation_method,
raw_data
)
VALUES (
:site_id,
:timestamp,
:source,
:dataset_id,
:consumption_kw,
:consumption_kwh,
:consumption_euros,
:voltage_v,
:current_a,
:power_factor,
:temperature_celsius,
:humidity_percent,
:solar_irradiance_wm2,
:is_working_hours,
:data_quality,
:null_reasons,
CAST(:imputed_values AS jsonb),
:imputation_method,
CAST(:raw_data AS jsonb)
)
ON CONFLICT DO NOTHING
"""
)
async def import_historical(
csv_path: Path,
metadata_path: Path,
source_timezone: str,
batch_size: int,
dry_run: bool,
storage_uri: str,
) -> None:
"""
Exécute le pipeline ETL historique EnerVision.
Étapes :
1. Extract
2. Validate
3. Transform
4. Load
"""
metadata = load_metadata(metadata_path)
frame = pd.read_csv(csv_path)
validate_source(
frame,
metadata,
)
print(f"Lignes : {len(frame)}")
print(f"Sites : {frame['site_id'].nunique()}")
print(f"Période : {frame['timestamp'].min()} -> {frame['timestamp'].max()}")
print(f"Doublons : {frame.duplicated(['site_id', 'timestamp']).sum()}")
print("\nValeurs NULL :")
print(frame[MEASURE_COLUMNS].isna().sum())
sha256 = compute_sha256(csv_path)
print(f"\nSHA-256 : {sha256}")
if dry_run:
print("\nDry-run terminé : aucune donnée écrite.")
return
normalized = normalize_timestamps(
frame,
source_timezone,
)
settings = get_settings()
engine = create_async_engine(
str(settings.database_url),
pool_pre_ping=True,
)
try:
async with engine.begin() as connection:
dataset_id = await ensure_dataset(
connection=connection,
metadata=metadata,
sha256=sha256,
source_timezone=source_timezone,
storage_uri=storage_uri,
)
await upsert_sites(
connection,
normalized,
)
result = await connection.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE dataset_id = :dataset_id
AND source = :source
"""
),
{
"dataset_id": dataset_id,
"source": SOURCE_NAME,
},
)
before = int(result.scalar_one())
for start in range(
0,
len(normalized),
batch_size,
):
chunk = normalized.iloc[start : start + batch_size]
rows = build_reading_batch(
chunk,
dataset_id,
)
await connection.execute(
READING_INSERT,
rows,
)
loaded = min(
start + batch_size,
len(normalized),
)
print(f"Chargement : {loaded}/{len(normalized)}")
result = await connection.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE dataset_id = :dataset_id
AND source = :source
"""
),
{
"dataset_id": dataset_id,
"source": SOURCE_NAME,
},
)
after = int(result.scalar_one())
print("\nImport terminé.")
print(f"dataset_id : {dataset_id}")
print(f"lectures avant : {before}")
print(f"lectures après : {after}")
print(f"nouvelles lectures : {after - before}")
finally:
await engine.dispose()
def parse_args() -> argparse.Namespace:
"""Définit les arguments CLI de l'import."""
parser = argparse.ArgumentParser(description=("Import historique EnerVision"))
parser.add_argument(
"--csv",
type=Path,
required=True,
help="Chemin vers le CSV historique.",
)
parser.add_argument(
"--metadata",
type=Path,
required=True,
help=("Chemin vers le fichier dataset_metadata.json."),
)
parser.add_argument(
"--source-timezone",
default="UTC",
help=("Timezone associée aux timestamps du dataset. Défaut : UTC."),
)
parser.add_argument(
"--batch-size",
type=int,
default=1000,
help=("Nombre de lignes insérées par batch. Défaut : 1000."),
)
parser.add_argument(
"--dry-run",
action="store_true",
help=("Valide les données sans écrire en base."),
)
return parser.parse_args()
def main() -> None:
"""Point d'entrée CLI du pipeline."""
args = parse_args()
if args.batch_size <= 0:
raise ValueError("--batch-size doit être strictement supérieur à 0.")
# resolve() est volontairement exécuté ici,
# dans la partie synchrone du programme.
# Cela évite une opération filesystem bloquante
# à l'intérieur d'une fonction async.
storage_uri = args.csv.resolve().as_uri()
asyncio.run(
import_historical(
csv_path=args.csv,
metadata_path=args.metadata,
source_timezone=(args.source_timezone),
batch_size=args.batch_size,
dry_run=args.dry_run,
storage_uri=storage_uri,
)
)
if __name__ == "__main__":
main()
+4
View File
@@ -4,6 +4,8 @@
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
from app.models.login_attempt import LoginAttempt 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.refresh_token import RefreshToken
from app.models.user import AppUser from app.models.user import AppUser
@@ -13,6 +15,8 @@ __all__ = [
"AuditLog", "AuditLog",
"Dataset", "Dataset",
"LoginAttempt", "LoginAttempt",
"PasswordResetAttempt",
"PasswordResetToken",
"Prediction", "Prediction",
"Reading", "Reading",
"Recommendation", "Recommendation",
+2
View File
@@ -29,6 +29,8 @@ class AuditAction(StrEnum):
COMPTE_ACTIVE = "user.enabled" COMPTE_ACTIVE = "user.enabled"
COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin" COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin"
COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed" 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" REFRESH_REUTILISE = "auth.refresh_reuse_detected"
SESSIONS_REVOQUEES = "auth.all_sessions_revoked" SESSIONS_REVOQUEES = "auth.all_sessions_revoked"
LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled" 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,78 @@
# 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, select, 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)
# Piège : simple SELECT, volontairement pas atomique avec la consommation. Sert seulement
# au feedback UX (jeton encore valide ?) ; `consume()` reste la seule source de vérité.
async def exists_valid(self, token_hash: bytes) -> bool:
requete = select(PasswordResetToken.id).where(
PasswordResetToken.token_hash == token_hash,
PasswordResetToken.consumed_at.is_(None),
PasswordResetToken.expires_at > func.clock_timestamp(),
)
return (await self._session.execute(requete)).first() is not None
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())
+53 -2
View File
@@ -1,17 +1,45 @@
# Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix # 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. # 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 typing import Literal, Self
from uuid import UUID 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.principal import Principal
from app.core.roles import AccountKind, Role from app.core.roles import AccountKind, Role
PASSWORD_MIN_LENGTH = 12 PASSWORD_MIN_LENGTH = 8
PASSWORD_MAX_LENGTH = 128 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): class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
@@ -22,6 +50,25 @@ class PasswordChangeRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) 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) 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): class PrincipalResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -37,6 +84,10 @@ class PrincipalResponse(BaseModel):
return cls.model_validate(principal) return cls.model_validate(principal)
class ResetTokenValidationResponse(BaseModel):
valid: bool
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: Literal["bearer"] = "bearer" # noqa: S105 token_type: Literal["bearer"] = "bearer" # noqa: S105
+142
View File
@@ -14,7 +14,11 @@ from datetime import UTC, datetime, timedelta
from typing import NoReturn, Protocol from typing import NoReturn, Protocol
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import BackgroundTasks
from app.core.hashing import Argon2Hasher from app.core.hashing import Argon2Hasher
from app.core.logging import get_logger
from app.core.mailer import Mailer
from app.core.principal import Principal from app.core.principal import Principal
from app.core.roles import AccountKind, Role from app.core.roles import AccountKind, Role
from app.core.security import ( from app.core.security import (
@@ -28,9 +32,13 @@ from app.models.login_attempt import LoginOutcome
from app.models.refresh_token import RevocationReason from app.models.refresh_token import RevocationReason
from app.repositories.audit_log import AuditLogRepository from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository 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.refresh_token import RefreshTokenRepository
from app.repositories.user import UserRepository from app.repositories.user import UserRepository
logger = get_logger(__name__)
class Transaction(Protocol): class Transaction(Protocol):
async def commit(self) -> None: ... async def commit(self) -> None: ...
@@ -54,6 +62,10 @@ class RateLimitedError(AuthError):
self.retry_after = retry_after self.retry_after = retry_after
class InvalidOrExpiredResetTokenError(AuthError):
pass
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class LoginPolicy: class LoginPolicy:
window_seconds: int window_seconds: int
@@ -62,6 +74,15 @@ class LoginPolicy:
max_failures_per_identifier: int 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) @dataclass(frozen=True, slots=True)
class AuthenticatedSession: class AuthenticatedSession:
principal: Principal principal: Principal
@@ -83,6 +104,10 @@ class AuthService:
token_policy: TokenPolicy, token_policy: TokenPolicy,
login_policy: LoginPolicy, login_policy: LoginPolicy,
refresh_ttl: timedelta, refresh_ttl: timedelta,
reset_tokens: PasswordResetTokenRepository,
reset_attempts: PasswordResetAttemptRepository,
reset_policy: PasswordResetPolicy,
mailer: Mailer,
) -> None: ) -> None:
self._users = users self._users = users
self._attempts = attempts self._attempts = attempts
@@ -93,6 +118,10 @@ class AuthService:
self._token_policy = token_policy self._token_policy = token_policy
self._login_policy = login_policy self._login_policy = login_policy
self._refresh_ttl = refresh_ttl 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( async def authenticate(
self, *, email: str, password: str, client_ip: str | None, user_agent: str | None self, *, email: str, password: str, client_ip: str | None, user_agent: str | None
@@ -200,6 +229,102 @@ class AuthService:
rafraichi = await self._users.get_by_id(principal.id) rafraichi = await self._users.get_by_id(principal.id)
return self._session(self._en_principal(rafraichi or compte), secret) 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,
background_tasks: BackgroundTasks,
) -> 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. L'envoi SMTP lui-même est différé
# en tâche de fond : le laisser dans le chemin de réponse rouvrirait le même oracle par le
# temps (aller-retour réseau) et par la forme (500 si le relais SMTP échoue, contre 202).
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}"
background_tasks.add_task(self._envoie_email_reset, compte.email, lien)
async def _envoie_email_reset(self, email: str, reset_url: str) -> None:
try:
await self._mailer.send_password_reset_email(to=email, reset_url=reset_url)
except Exception:
logger.exception("auth.password_reset.mail_failed")
# Piège : lecture seule, pas d'appel à `consume()`. Aucune limitation de débit n'est
# nécessaire ici : le jeton est un secret de 256 bits (`generate_refresh_secret`), donc
# non brute-forçable, et cette route n'apprend rien sur l'existence d'un compte ou d'un
# email, seulement si le lien déjà en main du visiteur est encore valide.
async def is_reset_token_valid(self, token: str) -> bool:
return await self._reset_tokens.exists_valid(fingerprint_refresh(token))
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é")
# Piège : le jeton peut avoir été émis avant une désactivation du compte. Sans cette
# relecture, un lien encore valide (15 min) changerait quand même le mot de passe d'un
# compte désactivé, réutilisable dès sa réactivation.
compte = await self._users.get_by_id(revendique.user_id)
if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value:
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: async def logout_all(self, principal: Principal) -> int:
revoquees = await self._refresh.revoke_all_for_user( revoquees = await self._refresh.revoke_all_for_user(
principal.id, RevocationReason.DECONNEXION principal.id, RevocationReason.DECONNEXION
@@ -307,6 +432,23 @@ class AuthService:
await self._transaction.commit() await self._transaction.commit()
raise RateLimitedError(politique.window_seconds) 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( async def _echoue(
self, self,
email: str, email: str,
+239 -1
View File
@@ -424,6 +424,196 @@
] ]
} }
}, },
"/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/validate": {
"get": {
"tags": [
"auth"
],
"summary": "Vérifie sans le consommer si un lien de réinitialisation est encore valide",
"operationId": "validate_reset_token_api_v1_auth_reset_password_validate_get",
"parameters": [
{
"name": "token",
"in": "query",
"required": true,
"schema": {
"type": "string",
"title": "Token"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ResetTokenValidationResponse"
}
}
}
},
"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"
}
}
}
}
}
}
},
"/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": { "/api/v1/users": {
"get": { "get": {
"tags": [ "tags": [
@@ -1587,6 +1777,20 @@
], ],
"title": "FieldError" "title": "FieldError"
}, },
"ForgotPasswordRequest": {
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "Email"
}
},
"type": "object",
"required": [
"email"
],
"title": "ForgotPasswordRequest"
},
"InternalErrorResponse": { "InternalErrorResponse": {
"properties": { "properties": {
"detail": { "detail": {
@@ -1666,7 +1870,7 @@
"new_password": { "new_password": {
"type": "string", "type": "string",
"maxLength": 128, "maxLength": 128,
"minLength": 12, "minLength": 8,
"title": "New Password" "title": "New Password"
} }
}, },
@@ -1993,6 +2197,40 @@
], ],
"title": "RecommendationResponse" "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"
},
"ResetTokenValidationResponse": {
"properties": {
"valid": {
"type": "boolean",
"title": "Valid"
}
},
"type": "object",
"required": [
"valid"
],
"title": "ResetTokenValidationResponse"
},
"Role": { "Role": {
"type": "string", "type": "string",
"enum": [ "enum": [
+3
View File
@@ -16,6 +16,8 @@ dependencies = [
"pyjwt>=2.10", "pyjwt>=2.10",
"argon2-cffi>=23.1", "argon2-cffi>=23.1",
"anyio>=4.0", "anyio>=4.0",
"aiosmtplib>=5.1.3",
"pandas>=3.0.5",
] ]
[dependency-groups] [dependency-groups]
@@ -26,6 +28,7 @@ dev = [
"pytest-asyncio>=1.4.0", "pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0", "pytest-cov>=7.1.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"pandas-stubs>=3.0.5.260914",
] ]
[build-system] [build-system]
+137 -1
View File
@@ -11,6 +11,7 @@ from app.core.roles import AccountKind, Role
from app.services.auth import ( from app.services.auth import (
AuthenticatedSession, AuthenticatedSession,
InvalidCredentialsError, InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
RateLimitedError, RateLimitedError,
SessionRejectedError, SessionRejectedError,
) )
@@ -27,15 +28,27 @@ PRINCIPAL = Principal(
class FauxService: class FauxService:
def __init__(self, erreur: Exception | None = None) -> None: def __init__(self, erreur: Exception | None = None, *, jeton_valide: bool = True) -> None:
self._erreur = erreur self._erreur = erreur
self._jeton_valide = jeton_valide
async def refresh(self, **_: object) -> AuthenticatedSession: async def refresh(self, **_: object) -> AuthenticatedSession:
return await self.authenticate() return await self.authenticate()
async def is_reset_token_valid(self, **_: object) -> bool:
return self._jeton_valide
async def logout(self, **_: object) -> None: async def logout(self, **_: object) -> None:
return 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: async def authenticate(self, **_: object) -> AuthenticatedSession:
if self._erreur is not None: if self._erreur is not None:
raise self._erreur raise self._erreur
@@ -206,3 +219,126 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin(
response = await client.post("/api/v1/auth/logout") response = await client.post("/api/v1/auth/logout")
assert response.status_code != 403 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
@pytest.fixture
def fake_auth_service_reset_validity(app: FastAPI) -> Iterator[list[bool]]:
programme = [True]
app.dependency_overrides[get_auth_service] = lambda: FauxService(jeton_valide=programme[0])
yield programme
app.dependency_overrides.pop(get_auth_service, None)
async def test_validate_reset_token_reports_a_living_token(
fake_auth_service_reset_validity: list[bool], client: AsyncClient
) -> None:
response = await client.get(
"/api/v1/auth/reset-password/validate", params={"token": "un-secret-opaque"}
)
assert response.status_code == 200
assert response.json() == {"valid": True}
async def test_validate_reset_token_reports_an_invalid_or_expired_token(
fake_auth_service_reset_validity: list[bool], client: AsyncClient
) -> None:
fake_auth_service_reset_validity[0] = False
response = await client.get(
"/api/v1/auth/reset-password/validate", params={"token": "un-secret-perime"}
)
assert response.status_code == 200
assert response.json() == {"valid": False}
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,13 @@ ROUTES_PUBLIQUES = frozenset(
("POST", "/api/v1/auth/login"), ("POST", "/api/v1/auth/login"),
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
("POST", "/api/v1/auth/logout"), ("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"),
# Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un
# `Principal`. Le jeton est un secret de 256 bits, non brute-forçable.
("GET", "/api/v1/auth/reset-password/validate"),
("GET", "/metrics"), ("GET", "/metrics"),
} }
) )
@@ -0,0 +1,239 @@
import hashlib
import json
import pandas as pd
import pytest
from app.etl.historical_import import (
SOURCE_NAME,
build_reading_batch,
classify_quality,
compute_sha256,
load_metadata,
normalize_timestamps,
validate_source,
)
def make_metadata() -> dict:
return {
"total_records": 2,
"sites": {
"SITE001": {},
},
}
def make_dataframe() -> pd.DataFrame:
return pd.DataFrame(
[
{
"timestamp": "2023-01-01 00:00:00",
"site_id": "SITE001",
"site_type": "office",
"site_name": "Site 1",
"consumption_kwh": 10.5,
"consumption_euros": 2.5,
"temperature_celsius": 20.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"hour": 0,
"day_of_week": 6,
"day_name": "Sunday",
"month": 1,
"is_weekend": True,
"is_working_hours": False,
},
{
"timestamp": "2023-01-01 01:00:00",
"site_id": "SITE001",
"site_type": "office",
"site_name": "Site 1",
"consumption_kwh": 11.0,
"consumption_euros": 2.7,
"temperature_celsius": 19.5,
"humidity_percent": 52.0,
"solar_irradiance_wm2": 0.0,
"hour": 1,
"day_of_week": 6,
"day_name": "Sunday",
"month": 1,
"is_weekend": True,
"is_working_hours": False,
},
]
)
def test_compute_sha256(tmp_path):
file_path = tmp_path / "dataset.csv"
content = b"hello-enervision"
file_path.write_bytes(content)
expected = hashlib.sha256(content).hexdigest()
assert compute_sha256(file_path) == expected
def test_load_metadata(tmp_path):
metadata_path = tmp_path / "metadata.json"
metadata = {
"total_records": 2,
"sites": {
"SITE001": {},
},
}
metadata_path.write_text(
json.dumps(metadata),
encoding="utf-8",
)
assert load_metadata(metadata_path) == metadata
def test_validate_source_accepts_valid_dataset():
frame = make_dataframe()
validate_source(
frame,
make_metadata(),
)
def test_validate_source_rejects_missing_column():
frame = make_dataframe().drop(columns=["consumption_kwh"])
with pytest.raises(
ValueError,
match="Colonnes obligatoires absentes",
):
validate_source(
frame,
make_metadata(),
)
def test_validate_source_rejects_duplicates():
frame = make_dataframe()
frame.loc[1, "timestamp"] = frame.loc[
0,
"timestamp",
]
with pytest.raises(
ValueError,
match="doublons",
):
validate_source(
frame,
make_metadata(),
)
def test_validate_source_rejects_unknown_site():
frame = make_dataframe()
frame.loc[1, "site_id"] = "SITE999"
with pytest.raises(
ValueError,
match="Sites incohérents",
):
validate_source(
frame,
make_metadata(),
)
def test_normalize_timestamps_adds_timezone():
frame = make_dataframe()
normalized = normalize_timestamps(
frame,
"UTC",
)
assert normalized["timestamp"].dt.tz is not None
assert "_source_timestamp" in normalized.columns
def test_classify_quality_good():
row = make_dataframe().iloc[0].to_dict()
quality, reasons = classify_quality(row)
assert quality == "good"
assert reasons == []
def test_classify_quality_degraded_when_consumption_missing():
row = make_dataframe().iloc[0].to_dict()
row["consumption_kwh"] = None
quality, reasons = classify_quality(row)
assert quality == "degraded"
assert "missing:consumption_kwh" in reasons
def test_build_reading_batch_respects_database_contract():
frame = normalize_timestamps(
make_dataframe(),
"UTC",
)
rows = build_reading_batch(
frame.iloc[:1],
dataset_id=3,
)
assert len(rows) == 1
row = rows[0]
assert row["dataset_id"] == 3
# Important :
# contrainte ck_reading_dataset_source.
assert row["source"] == "csv"
assert SOURCE_NAME == "csv"
# Important :
# contrainte ck_reading_imputation.
assert row["imputed_values"] is None
assert row["imputation_method"] is None
assert row["data_quality"] == "good"
assert row["null_reasons"] == []
def test_build_reading_batch_keeps_missing_values():
frame = make_dataframe()
frame.loc[0, "temperature_celsius"] = None
frame = normalize_timestamps(
frame,
"UTC",
)
rows = build_reading_batch(
frame.iloc[:1],
dataset_id=3,
)
row = rows[0]
assert row["temperature_celsius"] is None
assert "missing:temperature_celsius" in row["null_reasons"]
# RAW ingestion : aucune imputation.
assert row["imputed_values"] is None
assert row["imputation_method"] is None
@@ -0,0 +1,142 @@
# 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_exists_valid_is_true_for_a_living_token(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
secret = await un_jeton(depot, await un_compte(session))
assert await depot.exists_valid(fingerprint_refresh(secret)) is True
async def test_exists_valid_is_false_for_an_expired_token(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1))
assert await depot.exists_valid(fingerprint_refresh(secret)) is False
async def test_exists_valid_is_false_once_the_token_is_consumed(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
secret = await un_jeton(depot, await un_compte(session))
await depot.consume(fingerprint_refresh(secret))
assert await depot.exists_valid(fingerprint_refresh(secret)) is False
async def test_exists_valid_is_false_for_an_unknown_fingerprint(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
assert await depot.exists_valid(fingerprint_refresh(generate_refresh_secret())) is False
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()
+61
View File
@@ -0,0 +1,61 @@
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")
def test_valide_complexite_accepts_an_accented_password() -> None:
assert valide_complexite("Sécurité1!") == "Sécurité1!"
@pytest.mark.parametrize("mot_de_passe", ["abcdefg1×", "abcdefg1÷"]) # noqa: RUF001
def test_valide_complexite_rejects_a_password_without_uppercase_despite_times_or_divide(
mot_de_passe: str,
) -> None:
with pytest.raises(ValueError, match="majuscule"):
valide_complexite(mot_de_passe)
@pytest.mark.parametrize("mot_de_passe", ["ABCDEFG1×", "ABCDEFG1÷"]) # noqa: RUF001
def test_valide_complexite_rejects_a_password_without_lowercase_despite_times_or_divide(
mot_de_passe: str,
) -> None:
with pytest.raises(ValueError, match="minuscule"):
valide_complexite(mot_de_passe)
+223 -1
View File
@@ -5,6 +5,7 @@ from typing import Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import pytest import pytest
from fastapi import BackgroundTasks
from app.core.principal import Principal from app.core.principal import Principal
from app.core.roles import AccountKind, Role from app.core.roles import AccountKind, Role
@@ -16,11 +17,15 @@ from app.core.security import (
from app.models.login_attempt import LoginOutcome from app.models.login_attempt import LoginOutcome
from app.models.refresh_token import RevocationReason from app.models.refresh_token import RevocationReason
from app.repositories.login_attempt import FailureCounts 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.repositories.refresh_token import ClaimedToken
from app.services.auth import ( from app.services.auth import (
AuthService, AuthService,
InvalidCredentialsError, InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
LoginPolicy, LoginPolicy,
PasswordResetPolicy,
RateLimitedError, RateLimitedError,
SessionRejectedError, SessionRejectedError,
) )
@@ -37,6 +42,13 @@ POLITIQUE_CONNEXION = LoginPolicy(
max_failures_per_ip=20, max_failures_per_ip=20,
max_failures_per_identifier=50, 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 @dataclass
@@ -168,6 +180,49 @@ class FausseTransaction:
self.validations += 1 self.validations += 1
class FauxDepotJetonsReset:
def __init__(
self, revendique: ConsumedResetToken | None = None, *, valide: bool = False
) -> None:
self.revendique = revendique
self.valide = valide
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 exists_valid(self, token_hash: bytes) -> bool:
return self.valide
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 @dataclass
class Attirail: class Attirail:
service: AuthService service: AuthService
@@ -176,6 +231,9 @@ class Attirail:
jetons: FauxDepotJetons jetons: FauxDepotJetons
audit: FauxDepotAudit audit: FauxDepotAudit
hacheur: FauxHacheur hacheur: FauxHacheur
jetons_reset: FauxDepotJetonsReset
tentatives_reset: FauxDepotTentativesReset
mailer: FauxMailer
def fabrique_service( def fabrique_service(
@@ -184,12 +242,17 @@ def fabrique_service(
compteurs: FailureCounts | None = None, compteurs: FailureCounts | None = None,
hacheur: FauxHacheur | None = None, hacheur: FauxHacheur | None = None,
jetons: FauxDepotJetons | None = None, jetons: FauxDepotJetons | None = None,
jetons_reset: FauxDepotJetonsReset | None = None,
compteurs_reset: ResetRequestCounts | None = None,
) -> Attirail: ) -> Attirail:
comptes = FauxDepotComptes(compte) comptes = FauxDepotComptes(compte)
tentatives = FauxDepotTentatives(compteurs) tentatives = FauxDepotTentatives(compteurs)
depot_jetons = jetons or FauxDepotJetons() depot_jetons = jetons or FauxDepotJetons()
audit = FauxDepotAudit() audit = FauxDepotAudit()
hacheur = hacheur or FauxHacheur() hacheur = hacheur or FauxHacheur()
depot_jetons_reset = jetons_reset or FauxDepotJetonsReset()
tentatives_reset = FauxDepotTentativesReset(compteurs_reset)
mailer = FauxMailer()
service = AuthService( service = AuthService(
users=comptes, # type: ignore[arg-type] users=comptes, # type: ignore[arg-type]
attempts=tentatives, # type: ignore[arg-type] attempts=tentatives, # type: ignore[arg-type]
@@ -200,8 +263,22 @@ def fabrique_service(
token_policy=POLITIQUE_JETON, token_policy=POLITIQUE_JETON,
login_policy=POLITIQUE_CONNEXION, login_policy=POLITIQUE_CONNEXION,
refresh_ttl=timedelta(days=7), 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: async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object:
@@ -493,3 +570,148 @@ async def test_change_password_refuses_a_wrong_current_password() -> None:
assert attirail.jetons.revocations_par_compte == [] assert attirail.jetons.revocations_par_compte == []
assert attirail.jetons.crees == [] 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)
taches = BackgroundTasks()
await attirail.service.request_password_reset(
email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches
)
assert attirail.jetons_reset.invalidations == [compte.id]
assert attirail.jetons_reset.crees == [compte.id]
assert attirail.mailer.envois == [], "l'envoi doit être différé, pas fait dans la réponse"
await taches()
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)
taches = BackgroundTasks()
await attirail.service.request_password_reset(
email="inconnu@enervision.fr",
client_ip="203.0.113.10",
user_agent="pytest",
background_tasks=taches,
)
await taches()
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)
taches = BackgroundTasks()
await attirail.service.request_password_reset(
email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches
)
await taches()
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))
taches = BackgroundTasks()
with pytest.raises(RateLimitedError):
await attirail.service.request_password_reset(
email="operateur@enervision.fr",
client_ip="203.0.113.10",
user_agent="pytest",
background_tasks=taches,
)
await taches()
assert attirail.mailer.envois == []
async def test_request_password_reset_logs_instead_of_raising_when_the_mailer_fails() -> None:
compte = FauxCompte()
attirail = fabrique_service(compte=compte)
taches = BackgroundTasks()
async def echoue(*, to: str, reset_url: str) -> None:
raise RuntimeError("relais SMTP indisponible")
attirail.mailer.send_password_reset_email = echoue # type: ignore[method-assign]
await attirail.service.request_password_reset(
email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches
)
await taches()
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_is_reset_token_valid_reflects_the_repository() -> None:
attirail_valide = fabrique_service(jetons_reset=FauxDepotJetonsReset(valide=True))
attirail_invalide = fabrique_service(jetons_reset=FauxDepotJetonsReset(valide=False))
assert await attirail_valide.service.is_reset_token_valid("un-secret-opaque") is True
assert await attirail_invalide.service.is_reset_token_valid("un-secret-opaque") is False
async def test_confirm_password_reset_rejects_a_token_for_an_account_disabled_since() -> None:
compte = FauxCompte(is_active=False)
jetons_reset = FauxDepotJetonsReset(
revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id)
)
attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset)
with pytest.raises(InvalidOrExpiredResetTokenError):
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.comptes.mots_de_passe_changes == 0
assert attirail.jetons.revocations_par_compte == []
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 == []
+15 -4
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest import pytest
from app import cli from app import cli
from app.schemas.auth import valide_complexite
def test_build_parser_reads_the_create_admin_arguments() -> None: 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 len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE
assert mot_de_passe in capsys.readouterr().out 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: 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)) 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( def test_read_password_refuses_a_password_below_the_minimum_length(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> 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): with pytest.raises(SystemExit):
cli.read_password(generate=False) cli.read_password(generate=False)
def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None: 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)) monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
+120
View File
@@ -1,6 +1,20 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = "==3.14.*" requires-python = "==3.14.*"
resolution-markers = [
"sys_platform == 'win32'",
"sys_platform == 'emscripten'",
"sys_platform != 'emscripten' and sys_platform != 'win32'",
]
[[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]] [[package]]
name = "alembic" name = "alembic"
@@ -306,11 +320,13 @@ name = "enervision-backend"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosmtplib" },
{ name = "alembic" }, { name = "alembic" },
{ name = "anyio" }, { name = "anyio" },
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "pandas" },
{ name = "prometheus-fastapi-instrumentator" }, { name = "prometheus-fastapi-instrumentator" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@@ -324,6 +340,7 @@ dependencies = [
dev = [ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "mypy" }, { name = "mypy" },
{ name = "pandas-stubs" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
@@ -332,11 +349,13 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiosmtplib", specifier = ">=5.1.3" },
{ name = "alembic", specifier = ">=1.20.0" }, { name = "alembic", specifier = ">=1.20.0" },
{ name = "anyio", specifier = ">=4.0" }, { name = "anyio", specifier = ">=4.0" },
{ name = "argon2-cffi", specifier = ">=23.1" }, { name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.31.0" }, { name = "asyncpg", specifier = ">=0.31.0" },
{ name = "fastapi", specifier = ">=0.141.1" }, { name = "fastapi", specifier = ">=0.141.1" },
{ name = "pandas", specifier = ">=3.0.5" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.13.5" }, { name = "pydantic", extras = ["email"], specifier = ">=2.13.5" },
{ name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" },
@@ -350,6 +369,7 @@ requires-dist = [
dev = [ dev = [
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=2.3.1" }, { name = "mypy", specifier = ">=2.3.1" },
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
{ name = "pytest", specifier = ">=9.1.1" }, { name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-cov", specifier = ">=7.1.0" },
@@ -595,6 +615,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
] ]
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
{ url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
{ url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
{ url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
{ url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
{ url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
{ url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
{ url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
{ url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
{ url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
{ url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
{ url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.3" version = "26.3"
@@ -604,6 +653,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
] ]
[[package]]
name = "pandas"
version = "3.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" },
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" },
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" },
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" },
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" },
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" },
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" },
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" },
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" },
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" },
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
]
[[package]]
name = "pandas-stubs"
version = "3.0.5.260914"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/93/8948ae6c1e1e3d6833596fd266f7be2d27c1451b8be094975ad42c5e842e/pandas_stubs-3.0.5.260914.tar.gz", hash = "sha256:3f6fc1f147f68fd89c007105e7c94a948acb4ecd7eb20dc1c02e153c4ed5c250", size = 117622, upload-time = "2026-09-14T16:42:35.065Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/cb/5ad79e02a556cc23fed5816de0109fa8af660c66cfa5f4af74c3e8d4cd26/pandas_stubs-3.0.5.260914-py3-none-any.whl", hash = "sha256:39a1300c5c5c55fdf609e3476805decce5d5015539a4dcb683449f8feaeee2fb", size = 177344, upload-time = "2026-09-14T16:42:33.771Z" },
]
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "1.1.1" version = "1.1.1"
@@ -788,6 +878,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.3" version = "1.2.3"
@@ -857,6 +959,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.52" version = "2.0.52"
@@ -916,6 +1027,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
] ]
[[package]]
name = "tzdata"
version = "2026.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.53.0" version = "0.53.0"
+2
View File
@@ -5,6 +5,8 @@ export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) }, { path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) }, { path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
{ {
path: 'dashboard', path: 'dashboard',
canActivate: [authGuard], canActivate: [authGuard],
@@ -41,7 +41,10 @@ describe('authInterceptor', () => {
httpMock = TestBed.inject(HttpTestingController); httpMock = TestBed.inject(HttpTestingController);
}); });
afterEach(() => httpMock.verify()); afterEach(() => {
httpMock.verify();
vi.restoreAllMocks();
});
it('ajoute le header Authorization quand un token est disponible', () => { it('ajoute le header Authorization quand un token est disponible', () => {
http.get('/api/v1/stats/summary').subscribe(); http.get('/api/v1/stats/summary').subscribe();
@@ -97,6 +100,19 @@ describe('authInterceptor', () => {
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
}); });
it("ne redirige pas vers /login sur un 401 de /auth/refresh si on est déjà sur /reset-password", () => {
vi.spyOn(window, 'location', 'get').mockReturnValue({
pathname: '/reset-password',
} as Location);
http.post('/api/v1/auth/refresh', {}).subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/auth/refresh');
req.flush({}, { status: 401, statusText: 'Unauthorized' });
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).not.toHaveBeenCalled();
});
it('rafraîchit puis rejoue la requête sur un 401 avec error="expired"', () => { it('rafraîchit puis rejoue la requête sur un 401 avec error="expired"', () => {
authMock.refreshShared.mockReturnValue(of({ access_token: 'new-token' })); authMock.refreshShared.mockReturnValue(of({ access_token: 'new-token' }));
authMock.getAccessToken.mockReturnValueOnce('old-token').mockReturnValue('new-token'); authMock.getAccessToken.mockReturnValueOnce('old-token').mockReturnValue('new-token');
@@ -11,6 +11,16 @@ function parseAuthError(response: HttpErrorResponse): string | null {
return match ? match[1] : null; return match ? match[1] : null;
} }
const ROUTES_INVITEES = ['/login', '/forgot-password', '/reset-password'];
// Piège : le rafraîchissement de session lancé au démarrage de l'app (provideAppInitializer)
// échoue silencieusement sans cookie valide. `window.location.pathname` (pas `router.url`,
// pas encore fiable à ce stade) évite qu'un 401 de fond écrase la navigation vers le lien de
// reset reçu par email.
function surRouteInvitee(): boolean {
return ROUTES_INVITEES.some((chemin) => window.location.pathname.startsWith(chemin));
}
export const authInterceptor: HttpInterceptorFn = (req, next) => { export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService); const auth = inject(AuthService);
const router = inject(Router); const router = inject(Router);
@@ -43,7 +53,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (req.url.endsWith('/auth/refresh')) { if (req.url.endsWith('/auth/refresh')) {
auth.clearSession(); auth.clearSession();
router.navigate(['/login']); if (!surRouteInvitee()) {
router.navigate(['/login']);
}
return throwError(() => error); return throwError(() => error);
} }
@@ -51,7 +63,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (kind === 'invalid_token') { if (kind === 'invalid_token') {
auth.clearSession(); auth.clearSession();
router.navigate(['/login']); if (!surRouteInvitee()) {
router.navigate(['/login']);
}
return throwError(() => error); return throwError(() => error);
} }
@@ -65,7 +79,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
}), }),
catchError((refreshError) => { catchError((refreshError) => {
auth.clearSession(); auth.clearSession();
router.navigate(['/login']); if (!surRouteInvitee()) {
router.navigate(['/login']);
}
return throwError(() => refreshError); return throwError(() => refreshError);
}) })
); );
@@ -83,4 +83,17 @@ describe('AuthService', () => {
expect(result).toEqual(tokenResponse.principal); expect(result).toEqual(tokenResponse.principal);
}); });
it('vérifie la validité du jeton de reset via GET /auth/reset-password/validate', () => {
let result: { valid: boolean } | undefined;
service.validateResetToken('un-secret-opaque').subscribe((r) => (result = r));
const req = httpMock.expectOne(
`${environment.apiUrl}/auth/reset-password/validate?token=un-secret-opaque`
);
expect(req.request.method).toBe('GET');
req.flush({ valid: true });
expect(result).toEqual({ valid: true });
});
}); });
@@ -1,7 +1,14 @@
import { Service, signal, computed, inject } from '@angular/core'; import { Service, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable, tap, finalize, shareReplay } from 'rxjs'; import { Observable, tap, finalize, shareReplay } from 'rxjs';
import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model'; import {
ForgotPasswordRequest,
LoginRequest,
PasswordChangeRequest,
Principal,
ResetPasswordRequest,
TokenResponse,
} from '../../shared/models/auth.model';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@Service() @Service()
@@ -66,4 +73,20 @@ export class AuthService {
me(): Observable<Principal> { me(): Observable<Principal> {
return this.http.get<Principal>(`${environment.apiUrl}/auth/me`); return this.http.get<Principal>(`${environment.apiUrl}/auth/me`);
} }
forgotPassword(payload: ForgotPasswordRequest): Observable<void> {
return this.http.post<void>(`${environment.apiUrl}/auth/forgot-password`, payload);
}
resetPassword(payload: ResetPasswordRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/reset-password`, payload, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
validateResetToken(token: string): Observable<{ valid: boolean }> {
return this.http.get<{ valid: boolean }>(`${environment.apiUrl}/auth/reset-password/validate`, {
params: { token },
});
}
} }
@@ -18,7 +18,7 @@
formControlName="new_password" formControlName="new_password"
autocomplete="new-password" autocomplete="new-password"
/> />
<span class="auth-hint">12 à 128 caractères</span> <span class="auth-hint">{{ passwordHint }}</span>
@if (errorMessage()) { @if (errorMessage()) {
<p class="auth-error">{{ errorMessage() }}</p> <p class="auth-error">{{ errorMessage() }}</p>
@@ -32,10 +32,19 @@ describe('ChangePassword', () => {
expect(authMock.changePassword).not.toHaveBeenCalled(); expect(authMock.changePassword).not.toHaveBeenCalled();
}); });
it('ne soumet pas si le mot de passe ne couvre pas les 4 classes de caractères', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'old', new_password: 'longueur-suffisante-sans-majuscule-ni-chiffre' });
component.onSubmit();
expect(authMock.changePassword).not.toHaveBeenCalled();
});
it('redirige vers /dashboard après un changement réussi', () => { it('redirige vers /dashboard après un changement réussi', () => {
const fixture = TestBed.createComponent(ChangePassword); const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance; const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
@@ -46,7 +55,7 @@ describe('ChangePassword', () => {
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => { it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
const fixture = TestBed.createComponent(ChangePassword); const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance; const component = fixture.componentInstance;
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
authMock.changePassword.mockReturnValue(throwError(() => new Error('401'))); authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
@@ -70,7 +79,7 @@ describe('ChangePassword', () => {
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
const fixture = TestBed.createComponent(ChangePassword); const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance; const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
fixture.detectChanges(); fixture.detectChanges();
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
@@ -81,7 +90,7 @@ describe('ChangePassword', () => {
expect(authMock.changePassword).toHaveBeenCalledWith({ expect(authMock.changePassword).toHaveBeenCalledWith({
current_password: 'ancien-mot-de-passe', current_password: 'ancien-mot-de-passe',
new_password: 'un-nouveau-mot-de-passe-valide', new_password: 'Un-nouveau-mot-de-passe1!',
}); });
}); });
@@ -2,6 +2,7 @@ import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
@Component({ @Component({
selector: 'app-change-password', selector: 'app-change-password',
@@ -17,10 +18,11 @@ export class ChangePassword {
errorMessage = signal<string | null>(null); errorMessage = signal<string | null>(null);
isLoading = signal(false); isLoading = signal(false);
passwordHint = PASSWORD_HINT;
form = this.fb.nonNullable.group({ form = this.fb.nonNullable.group({
current_password: ['', Validators.required], current_password: ['', Validators.required],
new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]], new_password: ['', passwordValidators],
}); });
onSubmit(): void { onSubmit(): void {
@@ -34,7 +36,7 @@ export class ChangePassword {
}, },
error: () => { error: () => {
this.isLoading.set(false); this.isLoading.set(false);
this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).'); this.errorMessage.set(`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`);
}, },
}); });
} }
@@ -0,0 +1,37 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Mot de passe oublié</h1>
<p class="auth-subtitle">Recevez un lien de réinitialisation par email</p>
@if (submitted()) {
<p class="auth-success">
Si un compte existe pour cet email, un lien de réinitialisation vient d'être envoyé.
Il expire dans 15 minutes.
</p>
} @else {
<label for="email">Email</label>
<input
id="email"
type="email"
formControlName="email"
autocomplete="username"
placeholder="vous@enervision.fr"
/>
@if (errorMessage()) {
<p class="auth-error">
{{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) {
(réessayez dans {{ seconds }}s)
}
</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Envoi...' : 'Envoyer le lien' }}
</button>
}
<p class="auth-link"><a routerLink="/login">Retour à la connexion</a></p>
</form>
</div>
@@ -0,0 +1,104 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
line-height: 1.4;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
.auth-success {
margin: 0.75rem 0 0;
color: #16a34a;
font-size: 0.85rem;
}
.auth-link {
margin-top: 1rem;
font-size: 0.85rem;
text-align: center;
a {
color: #3b82f6;
}
}
@@ -0,0 +1,75 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ForgotPassword } from './forgot-password';
import { AuthService } from '../../../core/services/auth.service';
describe('ForgotPassword', () => {
let authMock: { forgotPassword: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
authMock = { forgotPassword: vi.fn() };
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [ForgotPassword, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
{ provide: ActivatedRoute, useValue: {} },
],
}).compileComponents();
});
it('ne soumet pas si le formulaire est invalide', () => {
const fixture = TestBed.createComponent(ForgotPassword);
fixture.componentInstance.onSubmit();
expect(authMock.forgotPassword).not.toHaveBeenCalled();
});
it('affiche le message générique après une soumission réussie', () => {
const fixture = TestBed.createComponent(ForgotPassword);
const component = fixture.componentInstance;
component.form.setValue({ email: 'operateur@enervision.fr' });
authMock.forgotPassword.mockReturnValue(of(undefined));
component.onSubmit();
expect(component.submitted()).toBe(true);
});
it('affiche le même message générique même quand le serveur répond une erreur autre que 429', () => {
const fixture = TestBed.createComponent(ForgotPassword);
const component = fixture.componentInstance;
component.form.setValue({ email: 'inconnu@enervision.fr' });
authMock.forgotPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 })));
component.onSubmit();
expect(component.submitted()).toBe(true);
});
it('affiche le délai à respecter quand le taux limite est atteint', () => {
const fixture = TestBed.createComponent(ForgotPassword);
const component = fixture.componentInstance;
component.form.setValue({ email: 'operateur@enervision.fr' });
authMock.forgotPassword.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 429,
headers: new HttpHeaders({ 'Retry-After': '900' }),
})
)
);
component.onSubmit();
expect(component.submitted()).toBe(false);
expect(component.retryAfterSeconds()).toBe(900);
});
});
@@ -0,0 +1,53 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
@Component({
selector: 'app-forgot-password',
standalone: true,
imports: [ReactiveFormsModule, RouterLink],
templateUrl: './forgot-password.html',
styleUrl: './forgot-password.scss',
})
export class ForgotPassword {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
errorMessage = signal<string | null>(null);
retryAfterSeconds = signal<number | null>(null);
submitted = signal(false);
isLoading = signal(false);
form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
});
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.retryAfterSeconds.set(null);
this.auth.forgotPassword(this.form.getRawValue()).subscribe({
// Le message affiché ne dépend jamais du fait que le compte existe ou non : la réponse
// du serveur est déjà générique, l'écran doit l'être aussi.
next: () => {
this.isLoading.set(false);
this.submitted.set(true);
},
error: (error: HttpErrorResponse) => {
this.isLoading.set(false);
if (error.status === 429) {
const retryAfter = error.headers.get('Retry-After');
this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null);
this.errorMessage.set('Trop de demandes, réessayez plus tard.');
return;
}
this.submitted.set(true);
},
});
}
}
@@ -32,5 +32,7 @@
<button type="submit" [disabled]="form.invalid || isLoading()"> <button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Connexion...' : 'Se connecter' }} {{ isLoading() ? 'Connexion...' : 'Se connecter' }}
</button> </button>
<p class="auth-link"><a routerLink="/forgot-password">Mot de passe oublié ?</a></p>
</form> </form>
</div> </div>
@@ -79,3 +79,13 @@
color: #dc2626; color: #dc2626;
font-size: 0.85rem; font-size: 0.85rem;
} }
.auth-link {
margin-top: 1rem;
font-size: 0.85rem;
text-align: center;
a {
color: #3b82f6;
}
}
@@ -1,27 +1,43 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { Login } from './login'; import { Login } from './login';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
function configure(queryParams: Record<string, string> = {}) {
const authMock = { login: vi.fn() };
const routerMock = { navigate: vi.fn() };
return {
authMock,
routerMock,
testBed: TestBed.configureTestingModule({
imports: [Login, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
{
provide: ActivatedRoute,
useValue: { snapshot: { queryParamMap: convertToParamMap(queryParams) } },
},
],
}),
};
}
describe('Login', () => { describe('Login', () => {
let authMock: { login: ReturnType<typeof vi.fn> }; let authMock: { login: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> }; let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => { beforeEach(async () => {
authMock = { login: vi.fn() }; const attirail = configure();
routerMock = { navigate: vi.fn() }; authMock = attirail.authMock;
routerMock = attirail.routerMock;
await TestBed.configureTestingModule({ await attirail.testBed.compileComponents();
imports: [Login, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
}).compileComponents();
}); });
it('ne soumet pas si le formulaire est invalide', () => { it('ne soumet pas si le formulaire est invalide', () => {
@@ -84,6 +100,14 @@ describe('Login', () => {
expect(errorEl?.textContent).toContain('30s'); expect(errorEl?.textContent).toContain('30s');
}); });
it('affiche le message standard quand on arrive avec ?motif=lien-expire', async () => {
const attirail = configure({ motif: MOTIF_LIEN_RESET_INVALIDE });
await attirail.testBed.compileComponents();
const fixture = TestBed.createComponent(Login);
expect(fixture.componentInstance.errorMessage()).toContain('expiré');
});
it('désactive le bouton tant que le formulaire est invalide', () => { it('désactive le bouton tant que le formulaire est invalide', () => {
const fixture = TestBed.createComponent(Login); const fixture = TestBed.createComponent(Login);
fixture.detectChanges(); fixture.detectChanges();
@@ -1,13 +1,14 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { MESSAGE_LIEN_RESET_INVALIDE, MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
standalone: true, standalone: true,
imports: [ReactiveFormsModule], imports: [ReactiveFormsModule, RouterLink],
templateUrl: './login.html', templateUrl: './login.html',
styleUrl: './login.scss', styleUrl: './login.scss',
}) })
@@ -15,8 +16,13 @@ export class Login {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
private auth = inject(AuthService); private auth = inject(AuthService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute);
errorMessage = signal<string | null>(null); errorMessage = signal<string | null>(
this.route.snapshot.queryParamMap.get('motif') === MOTIF_LIEN_RESET_INVALIDE
? MESSAGE_LIEN_RESET_INVALIDE
: null,
);
retryAfterSeconds = signal<number | null>(null); retryAfterSeconds = signal<number | null>(null);
isLoading = signal(false); isLoading = signal(false);
@@ -0,0 +1,32 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Nouveau mot de passe</h1>
@if (hasToken && !isCheckingToken()) {
<p class="auth-subtitle">Choisissez votre nouveau mot de passe</p>
<label for="new_password">Nouveau mot de passe</label>
<input
id="new_password"
type="password"
formControlName="new_password"
autocomplete="new-password"
/>
<app-password-requirements [password]="password()" />
@if (errorMessage()) {
<p class="auth-error">{{ errorMessage() }}</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Modification...' : 'Valider' }}
</button>
}
@if (hasToken && isCheckingToken()) {
<p class="auth-subtitle">Vérification du lien...</p>
}
<p class="auth-link"><a routerLink="/forgot-password">Redemander un lien</a></p>
</form>
</div>
@@ -0,0 +1,104 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
line-height: 1.4;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
.auth-success {
margin: 0.75rem 0 0;
color: #16a34a;
font-size: 0.85rem;
}
.auth-link {
margin-top: 1rem;
font-size: 0.85rem;
text-align: center;
a {
color: #3b82f6;
}
}
@@ -0,0 +1,129 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ResetPassword } from './reset-password';
import { AuthService } from '../../../core/services/auth.service';
import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
function configure(token: string | null) {
return TestBed.configureTestingModule({
imports: [ResetPassword, ReactiveFormsModule],
providers: [
{
provide: AuthService,
useValue: {
resetPassword: vi.fn(),
validateResetToken: vi.fn().mockReturnValue(of({ valid: true })),
},
},
{ provide: Router, useValue: { navigate: vi.fn() } },
{
provide: ActivatedRoute,
useValue: { snapshot: { queryParamMap: convertToParamMap(token ? { token } : {}) } },
},
],
}).compileComponents();
}
describe('ResetPassword', () => {
it("redirige vers /login avec le motif standard quand le jeton est absent de l'URL", async () => {
await configure(null);
const fixture = TestBed.createComponent(ResetPassword);
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
fixture.detectChanges();
expect(fixture.componentInstance.hasToken).toBe(false);
expect(router.navigate).toHaveBeenCalledWith(['/login'], {
queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE },
});
});
it('vérifie le jeton sans le consommer dès le chargement de la page', async () => {
await configure('un-secret-opaque');
const fixture = TestBed.createComponent(ResetPassword);
const auth = TestBed.inject(AuthService) as unknown as { validateResetToken: ReturnType<typeof vi.fn> };
fixture.detectChanges();
expect(auth.validateResetToken).toHaveBeenCalledWith('un-secret-opaque');
expect(fixture.componentInstance.isCheckingToken()).toBe(false);
});
it('redirige immédiatement vers /login si la vérification signale un jeton invalide', async () => {
await configure('un-secret-perime');
TestBed.overrideProvider(AuthService, {
useValue: { resetPassword: vi.fn(), validateResetToken: vi.fn().mockReturnValue(of({ valid: false })) },
});
const fixture = TestBed.createComponent(ResetPassword);
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
fixture.detectChanges();
expect(router.navigate).toHaveBeenCalledWith(['/login'], {
queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE },
});
});
it('ne soumet pas si le mot de passe ne respecte pas la politique de complexité', async () => {
await configure('un-secret-opaque');
const fixture = TestBed.createComponent(ResetPassword);
const component = fixture.componentInstance;
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
component.form.setValue({ new_password: 'trop-simple' });
component.onSubmit();
expect(auth.resetPassword).not.toHaveBeenCalled();
});
it('redirige vers /dashboard après une réinitialisation réussie', async () => {
await configure('un-secret-opaque');
const fixture = TestBed.createComponent(ResetPassword);
const component = fixture.componentInstance;
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' });
auth.resetPassword.mockReturnValue(of({ principal: { role: 'operateur' } }));
component.onSubmit();
expect(auth.resetPassword).toHaveBeenCalledWith({
token: 'un-secret-opaque',
new_password: 'Un-nouveau-mot-de-passe1!',
});
expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
});
it('redirige vers /login avec le motif standard quand le lien est invalide ou expiré', async () => {
await configure('un-secret-perime');
const fixture = TestBed.createComponent(ResetPassword);
const component = fixture.componentInstance;
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
const router = TestBed.inject(Router) as unknown as { navigate: ReturnType<typeof vi.fn> };
component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' });
auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 })));
component.onSubmit();
expect(router.navigate).toHaveBeenCalledWith(['/login'], {
queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE },
});
});
it('affiche un message générique sur une erreur inattendue (pas 400)', async () => {
await configure('un-secret-opaque');
const fixture = TestBed.createComponent(ResetPassword);
const component = fixture.componentInstance;
const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType<typeof vi.fn> };
component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' });
auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 })));
component.onSubmit();
expect(component.errorMessage()).toContain('invalide');
});
});
@@ -0,0 +1,79 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
import { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements';
import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
@Component({
selector: 'app-reset-password',
standalone: true,
imports: [ReactiveFormsModule, RouterLink, PasswordRequirementsChecklist],
templateUrl: './reset-password.html',
styleUrl: './reset-password.scss',
})
export class ResetPassword implements OnInit {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private token = this.route.snapshot.queryParamMap.get('token') ?? '';
errorMessage = signal<string | null>(null);
isLoading = signal(false);
passwordHint = PASSWORD_HINT;
hasToken = this.token.length > 0;
form = this.fb.nonNullable.group({
new_password: ['', passwordValidators],
});
password = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' });
isCheckingToken = signal(this.hasToken);
ngOnInit(): void {
if (!this.hasToken) {
this.redirigeVersLoginLienInvalide();
return;
}
this.auth.validateResetToken(this.token).subscribe({
next: ({ valid }) => {
this.isCheckingToken.set(false);
if (!valid) {
this.redirigeVersLoginLienInvalide();
}
},
error: () => this.isCheckingToken.set(false),
});
}
onSubmit(): void {
if (this.form.invalid || !this.hasToken) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.auth.resetPassword({ token: this.token, new_password: this.form.getRawValue().new_password }).subscribe({
next: () => {
this.router.navigate(['/dashboard']);
},
error: (error: HttpErrorResponse) => {
this.isLoading.set(false);
if (error.status === 400) {
this.redirigeVersLoginLienInvalide();
return;
}
this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`);
},
});
}
private redirigeVersLoginLienInvalide(): void {
this.router.navigate(['/login'], { queryParams: { motif: MOTIF_LIEN_RESET_INVALIDE } });
}
}
@@ -0,0 +1,8 @@
<ul class="password-requirements">
@for (requirement of requirements(); track requirement.label) {
<li [class.met]="requirement.met" [class.unmet]="!requirement.met">
<span class="password-requirements-icon">{{ requirement.met ? '✓' : '○' }}</span>
{{ requirement.label }}
</li>
}
</ul>
@@ -0,0 +1,29 @@
:host {
display: block;
}
.password-requirements {
list-style: none;
margin: 0.25rem 0 0;
padding: 0;
font-size: 0.8rem;
line-height: 1.5;
li {
display: flex;
align-items: center;
gap: 0.4rem;
}
.password-requirements-icon {
font-weight: 700;
}
.unmet {
color: #9ca3af;
}
.met {
color: #16a34a;
}
}
@@ -0,0 +1,39 @@
import { TestBed } from '@angular/core/testing';
import { PasswordRequirementsChecklist } from './password-requirements';
describe('PasswordRequirementsChecklist', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PasswordRequirementsChecklist],
}).compileComponents();
});
it('ne coche aucune règle pour un mot de passe vide', () => {
const fixture = TestBed.createComponent(PasswordRequirementsChecklist);
fixture.componentRef.setInput('password', '');
fixture.detectChanges();
expect(fixture.componentInstance.requirements().every((r) => !r.met)).toBe(true);
});
it('ne coche que les règles satisfaites pour un mot de passe partiel', () => {
const fixture = TestBed.createComponent(PasswordRequirementsChecklist);
fixture.componentRef.setInput('password', 'abcdefgh');
fixture.detectChanges();
const parLabel = new Map(fixture.componentInstance.requirements().map((r) => [r.label, r.met]));
expect(parLabel.get('8 caractères minimum')).toBe(true);
expect(parLabel.get('1 minuscule')).toBe(true);
expect(parLabel.get('1 majuscule')).toBe(false);
expect(parLabel.get('1 chiffre')).toBe(false);
expect(parLabel.get('1 caractère spécial')).toBe(false);
});
it('coche toutes les règles pour un mot de passe conforme', () => {
const fixture = TestBed.createComponent(PasswordRequirementsChecklist);
fixture.componentRef.setInput('password', 'Un-nouveau-mot-de-passe1!');
fixture.detectChanges();
expect(fixture.componentInstance.requirements().every((r) => r.met)).toBe(true);
});
});
@@ -0,0 +1,19 @@
import { Component, computed, input } from '@angular/core';
import { PASSWORD_REQUIREMENTS } from '../../validators/password.validator';
@Component({
selector: 'app-password-requirements',
standalone: true,
templateUrl: './password-requirements.html',
styleUrl: './password-requirements.scss',
})
export class PasswordRequirementsChecklist {
password = input('');
requirements = computed(() =>
PASSWORD_REQUIREMENTS.map((requirement) => ({
label: requirement.label,
met: requirement.test(this.password()),
})),
);
}
@@ -0,0 +1,3 @@
export const MOTIF_LIEN_RESET_INVALIDE = 'lien-expire';
export const MESSAGE_LIEN_RESET_INVALIDE =
'Ce lien de réinitialisation est invalide ou a expiré. Connectez-vous ou redemandez-en un.';
@@ -10,6 +10,15 @@ export interface PasswordChangeRequest {
new_password: string; new_password: string;
} }
export interface ForgotPasswordRequest {
email: string;
}
export interface ResetPasswordRequest {
token: string;
new_password: string;
}
export interface Principal { export interface Principal {
id: string; id: string;
email: string; email: string;
@@ -0,0 +1,26 @@
import { FormControl } from '@angular/forms';
import { passwordValidators } from './password.validator';
function estValide(motDePasse: string): boolean {
return new FormControl(motDePasse, passwordValidators).valid;
}
describe('passwordValidators', () => {
it('accepte un mot de passe couvrant les quatre classes', () => {
expect(estValide('Un-mot-de-passe1!')).toBe(true);
});
it('accepte un mot de passe accentué (alignement avec le backend, ex: "Sécurité1")', () => {
expect(estValide('Sécurité1!')).toBe(true);
});
it('refuse un mot de passe sans majuscule même avec un "×" ou un "÷"', () => {
expect(estValide('abcdefg1×')).toBe(false);
expect(estValide('abcdefg1÷')).toBe(false);
});
it('refuse un mot de passe sans minuscule même avec un "×" ou un "÷"', () => {
expect(estValide('ABCDEFG1×')).toBe(false);
expect(estValide('ABCDEFG1÷')).toBe(false);
});
});
@@ -0,0 +1,40 @@
// Contrainte : `PASSWORD_PATTERN` doit rester identique au validateur Pydantic de
// `app/schemas/auth.py` côté backend (mêmes plages de majuscules/minuscules, excluant
// × et ÷, mêmes chiffres 0-9, même jeu de caractères spéciaux). `\w`/`\d` divergent entre
// JavaScript (ASCII) et Python (Unicode) : une négation aurait accepté ou rejeté un même
// mot de passe différemment d'un côté à l'autre (ex. "Sécurité1").
import { Validators } from '@angular/forms';
export const PASSWORD_MIN_LENGTH = 8;
export const PASSWORD_MAX_LENGTH = 128;
export const PASSWORD_HINT =
'8 à 128 caractères, avec au moins 1 majuscule, 1 minuscule, 1 chiffre et 1 caractère spécial';
const SPECIAL_CHARACTERS = '!@#$%^&*()\\-_=+[\\]{};:,.?';
const PASSWORD_PATTERN = new RegExp(
`^(?=.*[A-ZÀ-ÖØ-Þ])(?=.*[a-zà-öø-þ])` +
`(?=.*[0-9])(?=.*[${SPECIAL_CHARACTERS}]).*$`,
);
export const passwordValidators = [
Validators.required,
Validators.minLength(PASSWORD_MIN_LENGTH),
Validators.maxLength(PASSWORD_MAX_LENGTH),
Validators.pattern(PASSWORD_PATTERN),
];
export interface PasswordRequirement {
label: string;
test: (value: string) => boolean;
}
const SPECIAL_REGEX = new RegExp(`[${SPECIAL_CHARACTERS}]`);
export const PASSWORD_REQUIREMENTS: PasswordRequirement[] = [
{ label: `${PASSWORD_MIN_LENGTH} caractères minimum`, test: (v) => v.length >= PASSWORD_MIN_LENGTH },
{ label: '1 majuscule', test: (v) => /[A-ZÀ-ÖØ-Þ]/.test(v) },
{ label: '1 minuscule', test: (v) => /[a-zà-öø-þ]/.test(v) },
{ label: '1 chiffre', test: (v) => /[0-9]/.test(v) },
{ label: '1 caractère spécial', test: (v) => SPECIAL_REGEX.test(v) },
];
View File
+16
View File
@@ -27,11 +27,22 @@ services:
start_period: 40s start_period: 40s
restart: unless-stopped restart: unless-stopped
# Piege : Mailpit ne relaie rien vers l'exterieur, il capture tout email envoye par le
# backend. Aucun acces reseau sortant n'est requis ; l'UI web (8025) sert a lire les emails.
mailpit:
image: axllent/mailpit
ports:
- "${MAILPIT_SMTP_PORT:-1025}:1025"
- "${MAILPIT_UI_PORT:-8025}:8025"
restart: unless-stopped
backend: backend:
build: ./apps/backend build: ./apps/backend
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
mailpit:
condition: service_started
environment: environment:
APP_ENV: ${APP_ENV:-local} APP_ENV: ${APP_ENV:-local}
APP_DEBUG: ${APP_DEBUG:-false} APP_DEBUG: ${APP_DEBUG:-false}
@@ -39,6 +50,11 @@ services:
APP_SECRET_KEY: ${APP_SECRET_KEY:?} APP_SECRET_KEY: ${APP_SECRET_KEY:?}
APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200} APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password}
APP_SMTP_HOST: mailpit
APP_SMTP_PORT: "1025"
APP_SMTP_USE_TLS: "false"
APP_SMTP_FROM_ADDRESS: ${APP_SMTP_FROM_ADDRESS:-no-reply@enervision.fr}
ports: ports:
- "${BACKEND_PORT:-8000}:8000" - "${BACKEND_PORT:-8000}:8000"
restart: unless-stopped restart: unless-stopped
@@ -20,6 +20,8 @@ gérer : il suffit d'envoyer les requêtes avec `withCredentials`.
| POST | `/api/v1/auth/logout` | cookie | `204` | | POST | `/api/v1/auth/logout` | cookie | `204` |
| POST | `/api/v1/auth/logout-all` | jeton d'accès | `204` | | POST | `/api/v1/auth/logout-all` | jeton d'accès | `204` |
| POST | `/api/v1/auth/password` | jeton d'accès | `200` `TokenResponse` | | POST | `/api/v1/auth/password` | jeton d'accès | `200` `TokenResponse` |
| POST | `/api/v1/auth/forgot-password` | aucune | `202` (toujours, que le compte existe ou non) |
| POST | `/api/v1/auth/reset-password` | aucune (jeton dans le corps) | `200` `TokenResponse` |
| GET | `/api/v1/auth/me` | jeton d'accès | `200` `PrincipalResponse` | | GET | `/api/v1/auth/me` | jeton d'accès | `200` `PrincipalResponse` |
| GET | `/api/v1/users` | jeton d'accès, `admin` | `200` `UserResponse[]` | | GET | `/api/v1/users` | jeton d'accès, `admin` | `200` `UserResponse[]` |
| POST | `/api/v1/users` | jeton d'accès, `admin` | `201` `TemporaryPasswordResponse` | | POST | `/api/v1/users` | jeton d'accès, `admin` | `201` `TemporaryPasswordResponse` |
@@ -51,7 +53,17 @@ codes d'erreur ci-dessous reste la référence de comportement, le schéma celle
} }
// POST /auth/password // POST /auth/password
{ "current_password": "...", "new_password": "..." } // 12 à 128 caractères { "current_password": "...", "new_password": "..." } // 8 à 128 caractères, au moins 1 majuscule, 1 minuscule, 1 chiffre, 1 caractère spécial
// POST /auth/forgot-password
{ "email": "operateur@enervision.fr" }
// Répond toujours 202, sans corps, que le compte existe, soit inactif, ou soit inconnu.
// POST /auth/reset-password
{ "token": "...", "new_password": "..." } // même règle de complexité que /auth/password
// Le jeton vient du lien reçu par email, valable 15 minutes, à usage unique. Répond
// TokenResponse au succès (l'appareil qui pose le nouveau mot de passe reste connecté), ou 400
// si le jeton est invalide, déjà utilisé, ou expiré.
``` ```
Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la réponse. Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la réponse.
@@ -70,6 +82,9 @@ Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la répon
| `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter | | `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter |
| `403` sur `/auth/refresh`, `/logout`, `/logout-all`, `/password` | origine hors liste autorisée (voir « Origines autorisées ») | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | | `403` sur `/auth/refresh`, `/logout`, `/logout-all`, `/password` | origine hors liste autorisée (voir « Origines autorisées ») | erreur de configuration réseau, pas un cas à gérer par l'utilisateur |
| `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée | | `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée |
| `429` sur `/auth/forgot-password` | trop de demandes | afficher l'attente, l'en-tête `Retry-After` donne les secondes |
| `400` sur `/auth/reset-password` | lien invalide, déjà utilisé, ou expiré | inviter à redemander un lien depuis `/forgot-password` |
| `403` sur `/auth/reset-password` | origine hors liste autorisée | erreur de configuration réseau, pas un cas à gérer par l'utilisateur |
## Les quatre règles qui comptent ## Les quatre règles qui comptent
+70
View File
@@ -231,3 +231,73 @@ et ne sont pas considérées comme des alertes actuelles.
- Les mesures API ne sont pas rattachées à un dataset historique. - Les mesures API ne sont pas rattachées à un dataset historique.
- Une alerte peut être associée à une prévision du même site. - Une alerte peut être associée à une prévision du même site.
- Une alerte peut donner lieu à plusieurs recommandations. - Une alerte peut donner lieu à plusieurs recommandations.
## Ingestion des données historiques
Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre du projet.
Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période du 1er janvier 2023 au 31 décembre 2024.
Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`.
### Architecture du flux
```text
Dataset CSV + métadonnées JSON
|
v
historical_import.py
|
+------+------+
| |
v v
Validation SHA-256
| Traçabilité
+------+------+
|
v
Normalisation
+ qualité data
|
v
Chargement par batches
|
v
PostgreSQL / TimescaleDB
| | |
v v v
dataset site reading
```
Le pipeline est développé en Python.
Pandas est utilisé pour l'extraction, la validation et la préparation des données. SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB.
Une empreinte SHA-256 permet d'identifier le dataset utilisé et d'assurer sa traçabilité.
Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. Aucune imputation n'est réalisée à cette étape.
Le chargement des mesures est effectué par batches de 1 000 lignes.
Les données provenant du dataset CSV sont identifiées par `source = "csv"` et associées à leur `dataset_id`.
### Résultats validés
Le chargement de référence a permis d'obtenir :
- 1 dataset ;
- 7 sites ;
- 122 647 mesures ;
- 0 doublon détecté dans le dataset source.
L'idempotence a également été vérifiée par une deuxième exécution du pipeline : aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647.
La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`.
### Évolution prévue
L'étape suivante consiste à orchestrer les traitements Data avec Apache Airflow.
L'orchestration réutilisera la logique ETL existante afin de séparer la logique de traitement de la planification, du suivi des exécutions et de la gestion des erreurs.
Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle de Machine Learning.
+347 -7
View File
@@ -1,9 +1,349 @@
# ETL # Pipeline ETL — EnerVision
Orchestration Apache Airflow : ingestion des mesures, agregations continues, ## Objectif
controles de qualite. Non initialise, voir le ticket dedie.
- `airflow/dags` : DAGs. Le pipeline ETL EnerVision permet d'intégrer les données énergétiques historiques dans PostgreSQL/TimescaleDB.
- `airflow/plugins` : operateurs et hooks maison.
- `airflow/include` : requetes SQL et ressources referencees par les DAGs. Cette première étape du pipeline Data permet de charger le dataset fourni dans le cadre du projet, contenant les mesures énergétiques de 7 sites sur la période du 1er janvier 2023 au 31 décembre 2024.
- `airflow/tests` : tests d'integrite des DAGs.
Le pipeline assure :
- l'extraction des données sources ;
- la validation de leur structure et de leur cohérence ;
- la normalisation des données nécessaires au stockage ;
- le suivi de la qualité des données ;
- la traçabilité du dataset importé ;
- le chargement des données dans PostgreSQL/TimescaleDB ;
- l'idempotence du chargement afin d'éviter la création de doublons.
## Données sources
Le dataset est fourni par le formateur dans le cadre du projet EnerVision.
Il contient les deux fichiers suivants :
```text
all_sites_combined.csv
dataset_metadata.json
```
Ces fichiers sont nécessaires une seule fois pour initialiser les données historiques de l'environnement.
Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement une fois les fichiers fournis par le formateur et les place dans :
```text
data/raw/
```
Structure locale attendue :
```text
data/
└── raw/
├── .gitkeep
├── all_sites_combined.csv
└── dataset_metadata.json
```
Le fichier `.gitkeep` est versionné afin de conserver le répertoire `data/raw/` dans Git. Les fichiers CSV et JSON sont ignorés par Git.
## Technologies utilisées
| Technologie | Utilisation |
|---|---|
| Python | Développement du pipeline ETL |
| Pandas | Lecture, validation et transformation des données |
| JSON | Lecture des métadonnées du dataset |
| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset |
| SQLAlchemy Async | Connexion et chargement asynchrone en base |
| PostgreSQL | Stockage relationnel |
| TimescaleDB | Stockage des séries temporelles énergétiques |
| Docker Compose | Exécution de l'environnement local |
| Alembic | Gestion des migrations du schéma |
| uv | Gestion et exécution de l'environnement Python |
| Ruff | Contrôle de la qualité du code |
| Pytest | Tests automatisés |
## Fonctionnement du pipeline
Le script principal d'import se trouve dans :
```text
apps/backend/app/etl/historical_import.py
```
Le flux d'import est le suivant :
```text
CSV + métadonnées JSON
|
v
Extraction
|
v
Validation
|
v
Traçabilité SHA-256
|
v
Transformation
|
v
Chargement par batches
|
v
PostgreSQL / TimescaleDB
```
### 1. Extraction
Le pipeline charge :
- `all_sites_combined.csv` avec Pandas ;
- `dataset_metadata.json` avec le module JSON de Python.
### 2. Validation
Avant toute écriture en base, le pipeline contrôle notamment :
- la présence des colonnes obligatoires ;
- le nombre de lignes ;
- la cohérence des identifiants des sites ;
- la cohérence des informations associées aux sites ;
- les doublons sur le couple `(site_id, timestamp)` ;
- les timestamps ;
- les valeurs manquantes.
Une incohérence détectée pendant cette étape interrompt l'import avant le chargement.
### 3. Dry-run
Un mode `--dry-run` permet d'exécuter les contrôles sans écrire de données dans PostgreSQL.
Il permet notamment de vérifier :
- le nombre de lignes ;
- le nombre de sites ;
- la période couverte ;
- les doublons ;
- les valeurs NULL ;
- l'empreinte SHA-256.
### 4. Traçabilité
Une empreinte SHA-256 est calculée à partir du fichier CSV afin d'identifier le dataset utilisé.
Empreinte SHA-256 du dataset validé :
```text
6E3777A97A5660B11855750B9028F70BE72138A11F26795F3A35D9CE74CE0C8D
```
Cette empreinte participe à la traçabilité du dataset chargé.
### 5. Transformation
Les timestamps sont normalisés avec la timezone :
```text
UTC
```
Le pipeline détermine également la qualité des mesures à partir des données disponibles.
Les valeurs manquantes sont conservées pendant cette phase afin de préserver la donnée source.
Aucune imputation n'est réalisée pendant l'ingestion :
```text
imputed_values = NULL
imputation_method = NULL
```
### 6. Chargement
Le chargement est réalisé avec SQLAlchemy Async dans PostgreSQL/TimescaleDB.
Les données sont enregistrées dans les tables :
```text
dataset
site
reading
```
Les mesures sont chargées par batches de :
```text
1000 lignes
```
Les mesures provenant du dataset CSV utilisent :
```text
source = "csv"
dataset_id = identifiant du dataset
```
Cette représentation respecte les contraintes définies dans le schéma de la base.
## Dataset validé
Le dataset traité contient :
- 122 647 mesures ;
- 7 sites ;
- une période du 01/01/2023 au 31/12/2024 ;
- 0 doublon détecté dans les données sources.
Valeurs manquantes identifiées :
| Variable | Nombre de valeurs NULL |
|---|---:|
| `consumption_kwh` | 2 840 |
| `consumption_euros` | 2 487 |
| `temperature_celsius` | 3 416 |
| `humidity_percent` | 3 423 |
| `solar_irradiance_wm2` | 3 964 |
## Exécution en dry-run
Depuis le dossier :
```text
apps/backend/
```
exécuter :
```powershell
uv run python -m app.etl.historical_import `
--csv ..\..\data\raw\all_sites_combined.csv `
--metadata ..\..\data\raw\dataset_metadata.json `
--source-timezone UTC `
--dry-run
```
Aucune donnée n'est écrite dans la base pendant cette exécution.
## Chargement réel
Depuis `apps/backend/` :
```powershell
uv run python -m app.etl.historical_import `
--csv ..\..\data\raw\all_sites_combined.csv `
--metadata ..\..\data\raw\dataset_metadata.json `
--source-timezone UTC
```
Le chargement est effectué progressivement par batches.
Exemple :
```text
Chargement : 1000/122647
Chargement : 2000/122647
...
Chargement : 122647/122647
```
## Résultats obtenus
Après le chargement initial, les contrôles en base ont confirmé :
```text
datasets = 1
sites = 7
readings = 122647
source = csv
```
Le premier import a créé :
```text
nouvelles lectures : 122647
```
## Idempotence
Le pipeline a été exécuté une deuxième fois avec exactement le même dataset afin de vérifier son idempotence.
Résultat :
```text
lectures avant : 122647
lectures après : 122647
nouvelles lectures : 0
```
Une nouvelle exécution du même import ne crée donc pas de mesures supplémentaires pour le dataset testé.
## Vérifications SQL
Depuis la racine du projet, vérifier le nombre d'enregistrements avec :
```powershell
docker compose exec db psql -U enervision -d enervision -c "SELECT COUNT(*) AS datasets FROM dataset; SELECT COUNT(*) AS sites FROM site; SELECT COUNT(*) AS readings FROM reading;"
```
Résultat attendu après l'import initial :
```text
datasets = 1
sites = 7
readings = 122647
```
Vérifier la source des mesures avec :
```powershell
docker compose exec db psql -U enervision -d enervision -c "SELECT source, COUNT(*) FROM reading GROUP BY source ORDER BY source;"
```
Résultat attendu :
```text
csv | 122647
```
## Tests et qualité
Les tests automatisés du pipeline sont situés dans :
```text
apps/backend/tests/etl/
```
Ils couvrent notamment :
- la validation du dataset ;
- les colonnes obligatoires ;
- la détection des doublons ;
- la cohérence des sites ;
- la normalisation des timestamps ;
- la gestion des valeurs manquantes ;
- la classification de la qualité des données ;
- la construction des mesures destinées à la BDD ;
- le respect des contraintes du modèle de données.
Exécuter les tests ETL :
```powershell
uv run pytest tests\etl -v
```
Contrôler la qualité du code :
```powershell
uv run ruff check app\etl tests\etl
```
## Suite du pipeline Data
L'import historique constitue la première brique du pipeline Data EnerVision.
La prochaine étape consiste à orchestrer les traitements ETL avec Apache Airflow, puis à préparer les données nécessaires à l'entraînement du modèle de Machine Learning.
Airflow sera utilisé comme orchestrateur des traitements existants et ne remplacera pas la logique métier déjà implémentée dans le pipeline ETL.