diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ecebb0d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +version: 2 +updates: + # Frontend — npm + - package-ecosystem: "npm" + directory: "/apps/frontend" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + frontend-dependencies: + patterns: + - "*" + + # Backend — uv (lit pyproject.toml / uv.lock) + - package-ecosystem: "uv" + directory: "/apps/backend" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + backend-dependencies: + patterns: + - "*" + + # Les workflows GitHub Actions eux-mêmes ont aussi des dépendances à jour + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + # Si un Dockerfile existe pour le backend + - package-ecosystem: "docker" + directory: "/apps/backend" + schedule: + interval: "weekly" + + - package-ecosystem: "docker" + directory: "/apps/frontend" + schedule: + interval: "weekly" diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 2fa84fd..55db548 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -2,19 +2,6 @@ name: Frontend # Pipeline à choix multiple on: - # workflow_dispatch -> lancement manuel des jobs - workflow_dispatch: - inputs: - job_choice: - required: true - description: "Choix du job" - type: choice - default: all - options: - - build - - sonarqube - - test - - all # lancer tous les jobs push: paths: - "apps/frontend/**" @@ -69,24 +56,28 @@ jobs: cache-dependency-path: apps/frontend/package-lock.json - run: npm ci working-directory: apps/frontend - - run: npm test -- --watch=false + - run: npm test --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend - + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: frontend-coverage + path: apps/frontend/coverage/frontend/lcov.info + sonarqube: needs: [build, test] name: SonarQube runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@v6 with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + fetch-depth: 0 + - name: Download coverage + uses: actions/download-artifact@v4 + with: + name: frontend-coverage + path: apps/frontend/coverage/frontend - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0 + uses: SonarSource/sonarqube-scan-action@v8 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - # deploy: - # runs-on: ubuntu-latest - # steps: - # - run: echo "DEPLOY job is running" diff --git a/.github/workflows/ml.yml b/.github/workflows/ml.yml new file mode 100644 index 0000000..b85fec7 --- /dev/null +++ b/.github/workflows/ml.yml @@ -0,0 +1,59 @@ +name: ML + +# Piège : la version de Python vient de ml/.python-version, et doit rester en 3.14 (cf. +# .github/workflows/backend.yml, même contrainte). + +on: + push: + paths: + - "ml/**" + - ".github/workflows/ml.yml" + pull_request: + paths: + - "ml/**" + - ".github/workflows/ml.yml" + +permissions: + contents: read + +concurrency: + group: ml-${{ github.ref }} + cancel-in-progress: true + +jobs: + verification: + name: Lint, typage et tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: ml + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: ml/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + + - name: Vérifie le formatage + run: uv run ruff format --check . + + - name: Analyse statique + run: uv run ruff check --output-format=github . + + - name: Typage + run: uv run mypy enervision_ml tests + + # Aucun test ne touche PostgreSQL ni MLflow distant : tout tourne sur donnees + # synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py). + - name: Tests + run: uv run pytest diff --git a/.gitignore b/.gitignore index 38ef5cf..47574d1 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,14 @@ data/raw/* monitoring/grafana/data/ monitoring/prometheus/data/ +# ML : jeu de donnees, modeles entraines et suivi MLflow local, tous generes/volumineux +ml/data/ +ml/models/* +!ml/models/.gitkeep +ml/mlruns/ +ml/mlartifacts/ +ml/mlflow.db + # IDE et OS .idea/ .vscode/ diff --git a/Makefile b/Makefile index ef7a692..0bb1dcb 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,17 @@ BACKEND := apps/backend FRONTEND := apps/frontend +ML := ml .DEFAULT_GOAL := help -.PHONY: help install install-backend install-frontend dev dev-backend dev-frontend \ +.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \ lint format typecheck test test-cov test-integration check \ - openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin + openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ + ml-lint ml-typecheck ml-test ml-check ml-train help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' -install: install-backend install-frontend ## Installe les dépendances backend et frontend +install: install-backend install-frontend install-ml ## Installe les dépendances backend, frontend et ML install-backend: ## Installe les dépendances du backend cd $(BACKEND) && uv sync --all-groups @@ -17,6 +19,9 @@ install-backend: ## Installe les dépendances du backend install-frontend: ## Installe les dépendances du frontend cd $(FRONTEND) && npm ci +install-ml: ## Installe les dépendances du pipeline ML + cd $(ML) && uv sync --all-groups + dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud @trap 'kill 0' EXIT INT TERM; \ $(MAKE) --no-print-directory dev-backend & \ @@ -55,6 +60,20 @@ check: lint typecheck test ## Chaîne de vérification complète openapi: ## Régénère apps/backend/openapi.json depuis les routes déclarées cd $(BACKEND) && uv run python -m app.cli export-openapi +ml-lint: ## Analyse statique du pipeline ML + cd $(ML) && uv run ruff check . + +ml-typecheck: ## Vérifie le typage du pipeline ML + cd $(ML) && uv run mypy enervision_ml tests + +ml-test: ## Exécute les tests du pipeline ML (donnees synthetiques, sans base ni serveur MLflow) + cd $(ML) && uv run pytest + +ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML + +ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL + cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),) + docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) diff --git a/README.md b/README.md index 27affce..33426fb 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | CI/CD | GitHub Actions | `.github/workflows` | Backend en place | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | +| ML | LightGBM, MLflow | `ml` | Entrainement initialise | Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints @@ -53,6 +54,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans ├── infra/terraform/ │ ├── modules/ Modules reutilisables │ └── environments/ Racines Terraform, une par environnement +├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow ├── monitoring/ │ ├── prometheus/ Collecte et regles d'alerte │ ├── grafana/ Provisioning et dashboards diff --git a/apps/backend/.env.example b/apps/backend/.env.example index f36551e..8dff67f 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -8,3 +8,13 @@ APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision + +# Mot de passe oublié : lien à usage unique valable 15 minutes par défaut. +APP_FRONTEND_RESET_PASSWORD_URL=http://localhost:4200/reset-password + +# SMTP local de dev (Mailpit, cf. docker-compose.yml) : aucune authentification, aucun TLS. +# À remplacer par un vrai relais en staging/prod. +APP_SMTP_HOST=localhost +APP_SMTP_PORT=1025 +APP_SMTP_USE_TLS=false +APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr diff --git a/apps/backend/README.md b/apps/backend/README.md index 91f9608..6c48b3a 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -103,6 +103,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente | | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton | | `/api/v1/auth/password` | Change son propre mot de passe | jeton | +| `/api/v1/auth/forgot-password` | Demande un lien de réinitialisation par email | public | +| `/api/v1/auth/reset-password` | Choisit un nouveau mot de passe depuis ce lien | public | | `/api/v1/auth/me` | Décrit le compte connecté | jeton | | `/api/v1/users` | Liste et crée des comptes | `admin` | | `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` | diff --git a/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py new file mode 100644 index 0000000..7f75d21 --- /dev/null +++ b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py @@ -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") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 4996917..c544dae 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings, get_settings from app.core.hashing import Argon2Hasher, build_hasher +from app.core.mailer import Mailer, SmtpConfig from app.core.principal import Principal from app.core.roles import AccountKind, Role, has_at_least from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy @@ -24,13 +25,15 @@ from app.db.session import get_session from app.repositories.alert import AlertRepository from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.password_reset_attempt import PasswordResetAttemptRepository +from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.reading import ReadingRepository from app.repositories.recommendation import RecommendationRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.alert import AlertService -from app.services.auth import AuthService, LoginPolicy +from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy from app.services.reading import ReadingService from app.services.recommendation import RecommendationService 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 +def get_mailer(settings: SettingsDep) -> Mailer: + return Mailer( + SmtpConfig( + host=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username, + password=( + settings.smtp_password.get_secret_value() if settings.smtp_password else None + ), + use_tls=settings.smtp_use_tls, + from_address=settings.smtp_from_address, + ) + ) + + def get_auth_service( session: SessionDep, settings: SettingsDep, hasher: Annotated[Argon2Hasher, Depends(get_hasher)], token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], + mailer: Annotated[Mailer, Depends(get_mailer)], ) -> AuthService: return AuthService( users=UserRepository(session), @@ -119,6 +138,16 @@ def get_auth_service( max_failures_per_identifier=settings.login_max_failures_per_identifier, ), refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds), + reset_tokens=PasswordResetTokenRepository(session), + reset_attempts=PasswordResetAttemptRepository(session), + reset_policy=PasswordResetPolicy( + window_seconds=settings.password_reset_window_seconds, + max_requests_per_identifier=settings.password_reset_max_requests_per_identifier, + max_requests_per_ip=settings.password_reset_max_requests_per_ip, + token_ttl=timedelta(seconds=settings.password_reset_ttl_seconds), + frontend_reset_url=settings.frontend_reset_password_url, + ), + mailer=mailer, ) @@ -142,7 +171,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)] def get_site_service(session: SessionDep) -> SiteService: - return SiteService(sites=SiteRepository(session)) + return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session)) SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 15f88d2..9937467 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -164,3 +164,16 @@ REPONSE_ORIGINE_REFUSEE: Final[Reponses] = { "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", }, } + +REPONSE_LIMITE: Final[Reponses] = { + 429: { + "model": ErrorResponse, + "description": "Trop de demandes sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": {"type": "integer"}, + } + }, + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 32bf8b2..9fd374f 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -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 # 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 ( AuthServiceDep, @@ -12,6 +12,7 @@ from app.api.deps import ( require_trusted_origin, ) from app.api.openapi import ( + REPONSE_LIMITE, REPONSE_ORIGINE_REFUSEE, REPONSE_VALIDATION, REPONSES_AUTHENTIFIEES, @@ -21,15 +22,19 @@ from app.api.openapi import ( from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger from app.schemas.auth import ( + ForgotPasswordRequest, LoginRequest, PasswordChangeRequest, PrincipalResponse, + ResetPasswordRequest, + ResetTokenValidationResponse, TokenResponse, ) from app.schemas.errors import ErrorResponse from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, RateLimitedError, SessionRejectedError, ) @@ -39,6 +44,7 @@ logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" DETAIL_SESSION = "Session invalide" +DETAIL_LIEN_RESET = "Lien invalide ou expiré" REPONSES_LOGIN: Reponses = { **REPONSE_VALIDATION, @@ -85,6 +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( response: Response, settings: SettingsDep, session: AuthenticatedSession @@ -267,3 +287,79 @@ async def change_password( logger.info("auth.password_changed user_id=%s", principal.id) return repond(response, settings, session) + + +@router.post( + "/forgot-password", + status_code=status.HTTP_202_ACCEPTED, + summary="Demande un lien de réinitialisation par email", + responses=REPONSES_FORGOT_PASSWORD, +) +async def forgot_password( + payload: ForgotPasswordRequest, + request: Request, + response: Response, + service: AuthServiceDep, + 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) diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 984dd8b..5687b33 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status from app.api.deps import LecteurDep, SiteServiceDep from app.api.openapi import REPONSE_VALIDATION, Reponses from app.schemas.errors import ErrorResponse -from app.schemas.site import SiteResponse +from app.schemas.site import SiteCurrentResponse, SiteResponse from app.services.site import SiteNotFoundError router = APIRouter() @@ -34,3 +34,19 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" ) from erreur return SiteResponse.model_validate(site) + + +@router.get( + "/{site_id}/current", + response_model=SiteCurrentResponse, + summary="Dernière mesure d'un site", + responses=REPONSES_INTROUVABLE, +) +async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse: + try: + actuel = await service.current(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + return SiteCurrentResponse.model_validate(actuel) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index 37e94fd..f713fa5 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -9,6 +9,7 @@ import argparse import asyncio import json import secrets +import string import sys from getpass import getpass from pathlib import Path @@ -22,9 +23,9 @@ from app.core.roles import Role from app.db.session import get_session_factory from app.main import create_app from app.repositories.user import UserRepository +from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite LONGUEUR_MOT_DE_PASSE_GENERE = 24 -LONGUEUR_MINIMALE = 12 CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" @@ -111,15 +112,34 @@ def build_parser() -> argparse.ArgumentParser: return parser +def genere_mot_de_passe() -> str: + tirage = secrets.SystemRandom() + classes = [ + string.ascii_uppercase, + string.ascii_lowercase, + string.digits, + 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: if generate: - mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE) + mot_de_passe = genere_mot_de_passe() print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}") return mot_de_passe mot_de_passe = getpass("Mot de passe : ") - if len(mot_de_passe) < LONGUEUR_MINIMALE: - raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères") + if len(mot_de_passe) < PASSWORD_MIN_LENGTH: + raise SystemExit(f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères") + try: + valide_complexite(mot_de_passe) + except ValueError as erreur: + raise SystemExit(str(erreur)) from erreur if mot_de_passe != getpass("Confirmation : "): raise SystemExit("Les deux saisies diffèrent") return mot_de_passe diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 6733b3a..e374709 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -54,6 +54,19 @@ class Settings(BaseSettings): login_max_failures_per_ip: int = Field(default=20, ge=1) login_max_failures_per_identifier: int = Field(default=50, ge=1) + password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600) + password_reset_window_seconds: int = Field(default=900, ge=60) + password_reset_max_requests_per_identifier: int = Field(default=3, ge=1) + password_reset_max_requests_per_ip: int = Field(default=10, ge=1) + + smtp_host: str = "localhost" + smtp_port: int = Field(default=587, ge=1, le=65535) + smtp_username: str | None = None + smtp_password: SecretStr | None = None + smtp_use_tls: bool = False + smtp_from_address: str = "no-reply@enervision.fr" + frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105 + trust_proxy_headers: bool = False expose_api_docs: bool | None = None metrics_token: SecretStr | None = None diff --git a/apps/backend/app/core/mailer.py b/apps/backend/app/core/mailer.py new file mode 100644 index 0000000..5c09008 --- /dev/null +++ b/apps/backend/app/core/mailer.py @@ -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) diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index de1235e..1380a84 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -1,9 +1,14 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from pathlib import Path from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from fastapi.staticfiles import StaticFiles from prometheus_fastapi_instrumentator import Instrumentator +from starlette.requests import Request +from starlette.responses import HTMLResponse from app.api.errors import register_error_handlers from app.api.middleware import SecurityHeadersMiddleware @@ -18,6 +23,8 @@ logger = get_logger(__name__) METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] EN_TETES_AUTORISES = ["Authorization", "Content-Type"] +STATIC_DIR = Path(__file__).parent / "static" +LOGO_URL = "/static/logo-icon.png" @asynccontextmanager @@ -43,11 +50,41 @@ def create_app(settings: Settings | None = None) -> FastAPI: openapi_tags=TAGS, debug=resolved.debug, lifespan=lifespan, - docs_url="/docs" if documentee else None, - redoc_url="/redoc" if documentee else None, + docs_url=None, + redoc_url=None, openapi_url="/openapi.json" if documentee else None, ) + if documentee: + application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + # ReDoc supporte nativement `info.x-logo` (extension Redocly) pour afficher un logo + # en en-tête ; Swagger UI n'a pas d'equivalent, il ne reprend que le favicon. + openapi_original = application.openapi + + def openapi_avec_logo() -> dict[str, object]: + schema = openapi_original() + schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"} + return schema + + application.openapi = openapi_avec_logo # type: ignore[method-assign] + + @application.get("/docs", include_in_schema=False) + async def docs_swagger(_: Request) -> HTMLResponse: + return get_swagger_ui_html( + openapi_url="/openapi.json", + title=f"{application.title} · Swagger UI", + swagger_favicon_url=LOGO_URL, + ) + + @application.get("/redoc", include_in_schema=False) + async def docs_redoc(_: Request) -> HTMLResponse: + return get_redoc_html( + openapi_url="/openapi.json", + title=f"{application.title} · ReDoc", + redoc_favicon_url=LOGO_URL, + ) + application.add_middleware(SecurityHeadersMiddleware) if resolved.allowed_origins: diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 10a5ecb..167d7ce 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -4,6 +4,8 @@ from app.models.audit_log import AuditLog from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.login_attempt import LoginAttempt +from app.models.password_reset_attempt import PasswordResetAttempt +from app.models.password_reset_token import PasswordResetToken from app.models.refresh_token import RefreshToken from app.models.user import AppUser @@ -13,6 +15,8 @@ __all__ = [ "AuditLog", "Dataset", "LoginAttempt", + "PasswordResetAttempt", + "PasswordResetToken", "Prediction", "Reading", "Recommendation", diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py index 5775f5e..d389880 100644 --- a/apps/backend/app/models/audit_log.py +++ b/apps/backend/app/models/audit_log.py @@ -29,6 +29,8 @@ class AuditAction(StrEnum): COMPTE_ACTIVE = "user.enabled" COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin" COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed" + MOT_DE_PASSE_OUBLIE_DEMANDE = "auth.password_reset_requested" + MOT_DE_PASSE_REINITIALISE_PAR_SOI = "auth.password_reset_self_service" REFRESH_REUTILISE = "auth.refresh_reuse_detected" SESSIONS_REVOQUEES = "auth.all_sessions_revoked" LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled" diff --git a/apps/backend/app/models/password_reset_attempt.py b/apps/backend/app/models/password_reset_attempt.py new file mode 100644 index 0000000..6d2a607 --- /dev/null +++ b/apps/backend/app/models/password_reset_attempt.py @@ -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) diff --git a/apps/backend/app/models/password_reset_token.py b/apps/backend/app/models/password_reset_token.py new file mode 100644 index 0000000..d67d310 --- /dev/null +++ b/apps/backend/app/models/password_reset_token.py @@ -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) diff --git a/apps/backend/app/repositories/password_reset_attempt.py b/apps/backend/app/repositories/password_reset_attempt.py new file mode 100644 index 0000000..ddc2f91 --- /dev/null +++ b/apps/backend/app/repositories/password_reset_attempt.py @@ -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) diff --git a/apps/backend/app/repositories/password_reset_token.py b/apps/backend/app/repositories/password_reset_token.py new file mode 100644 index 0000000..67eafbd --- /dev/null +++ b/apps/backend/app/repositories/password_reset_token.py @@ -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()) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 71352da..d005d16 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -13,14 +13,27 @@ class ReadingRepository: async def latest_by_site(self) -> Sequence[Reading]: # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule - # ligne par site, la plus récente grâce à l'ordre composite qui suit. + # ligne par site, la plus récente grâce à l'ordre composite qui suit. `reading_id` départage + # les égalités de timestamp, que `uq_reading_source` autorise à `source` différente. requete = ( select(Reading) .distinct(Reading.site_id) - .order_by(Reading.site_id, Reading.timestamp.desc()) + .order_by(Reading.site_id, Reading.timestamp.desc(), Reading.reading_id.desc()) ) return (await self._session.execute(requete)).scalars().all() + async def latest_for_site(self, site_id: str) -> Reading | None: + # Piège : `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la + # `source` diffère. Sans `reading_id` en départage, le `LIMIT 1` renverrait au hasard. + requete = ( + select(Reading) + .where(Reading.site_id == site_id) + .order_by(Reading.timestamp.desc(), Reading.reading_id.desc()) + .limit(1) + ) + lecture: Reading | None = await self._session.scalar(requete) + return lecture + async def list_history( self, *, diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py index 522b4c5..f345ab7 100644 --- a/apps/backend/app/schemas/auth.py +++ b/apps/backend/app/schemas/auth.py @@ -1,17 +1,45 @@ # Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix # mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur. +# Contrainte : `SPECIAL_CHARACTERS` doit rester identique à `password.validator.ts` côté +# frontend. `\w`/`\d` divergent entre Python (Unicode) et JavaScript (ASCII) : une classe +# explicite, plutôt qu'une négation, évite qu'un mot de passe soit accepté d'un côté et +# rejeté de l'autre (ex. "Sécurité1", où "é" comptait comme "spécial" pour Python seul). +import re from typing import Literal, Self from uuid import UUID -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from app.core.principal import Principal from app.core.roles import AccountKind, Role -PASSWORD_MIN_LENGTH = 12 +PASSWORD_MIN_LENGTH = 8 PASSWORD_MAX_LENGTH = 128 +SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.?" + +_MAJUSCULE = re.compile(r"[A-ZÀ-ÖØ-Þ]") +_MINUSCULE = re.compile(r"[a-zà-öø-þ]") +_CHIFFRE = re.compile(r"[0-9]") +_SPECIAL = re.compile(r"[" + re.escape(SPECIAL_CHARACTERS) + r"]") + + +def valide_complexite(mot_de_passe: str) -> str: + manquants = [ + nom + for nom, motif in ( + ("une majuscule", _MAJUSCULE), + ("une minuscule", _MINUSCULE), + ("un chiffre", _CHIFFRE), + ("un caractère spécial", _SPECIAL), + ) + if not motif.search(mot_de_passe) + ] + if manquants: + raise ValueError(f"Le mot de passe doit contenir au moins {', '.join(manquants)}") + return mot_de_passe + class LoginRequest(BaseModel): email: EmailStr @@ -22,6 +50,25 @@ class PasswordChangeRequest(BaseModel): current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + @field_validator("new_password") + @classmethod + def _new_password_est_complexe(cls, valeur: str) -> str: + return valide_complexite(valeur) + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + token: str = Field(min_length=1) + new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + + @field_validator("new_password") + @classmethod + def _new_password_est_complexe(cls, valeur: str) -> str: + return valide_complexite(valeur) + class PrincipalResponse(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -37,6 +84,10 @@ class PrincipalResponse(BaseModel): return cls.model_validate(principal) +class ResetTokenValidationResponse(BaseModel): + valid: bool + + class TokenResponse(BaseModel): access_token: str token_type: Literal["bearer"] = "bearer" # noqa: S105 diff --git a/apps/backend/app/schemas/site.py b/apps/backend/app/schemas/site.py index 82035f5..56a61b7 100644 --- a/apps/backend/app/schemas/site.py +++ b/apps/backend/app/schemas/site.py @@ -1,3 +1,6 @@ +from datetime import datetime +from typing import Literal + from pydantic import BaseModel, ConfigDict @@ -10,3 +13,20 @@ class SiteResponse(BaseModel): location: str | None capacity_kw: float | None status: str | None + + +class SiteCurrentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: Literal["good", "partial", "degraded", "critical"] diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 8baf857..9fac1e1 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -14,7 +14,11 @@ from datetime import UTC, datetime, timedelta from typing import NoReturn, Protocol from uuid import UUID, uuid4 +from fastapi import BackgroundTasks + 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.roles import AccountKind, Role 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.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.password_reset_attempt import PasswordResetAttemptRepository +from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository +logger = get_logger(__name__) + class Transaction(Protocol): async def commit(self) -> None: ... @@ -54,6 +62,10 @@ class RateLimitedError(AuthError): self.retry_after = retry_after +class InvalidOrExpiredResetTokenError(AuthError): + pass + + @dataclass(frozen=True, slots=True) class LoginPolicy: window_seconds: int @@ -62,6 +74,15 @@ class LoginPolicy: max_failures_per_identifier: int +@dataclass(frozen=True, slots=True) +class PasswordResetPolicy: + window_seconds: int + max_requests_per_identifier: int + max_requests_per_ip: int + token_ttl: timedelta + frontend_reset_url: str + + @dataclass(frozen=True, slots=True) class AuthenticatedSession: principal: Principal @@ -83,6 +104,10 @@ class AuthService: token_policy: TokenPolicy, login_policy: LoginPolicy, refresh_ttl: timedelta, + reset_tokens: PasswordResetTokenRepository, + reset_attempts: PasswordResetAttemptRepository, + reset_policy: PasswordResetPolicy, + mailer: Mailer, ) -> None: self._users = users self._attempts = attempts @@ -93,6 +118,10 @@ class AuthService: self._token_policy = token_policy self._login_policy = login_policy self._refresh_ttl = refresh_ttl + self._reset_tokens = reset_tokens + self._reset_attempts = reset_attempts + self._reset_policy = reset_policy + self._mailer = mailer async def authenticate( self, *, email: str, password: str, client_ip: str | None, user_agent: str | None @@ -200,6 +229,102 @@ class AuthService: rafraichi = await self._users.get_by_id(principal.id) return self._session(self._en_principal(rafraichi or compte), secret) + async def request_password_reset( + self, + *, + email: str, + client_ip: str | None, + user_agent: str | None, + 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: revoquees = await self._refresh.revoke_all_for_user( principal.id, RevocationReason.DECONNEXION @@ -307,6 +432,23 @@ class AuthService: await self._transaction.commit() raise RateLimitedError(politique.window_seconds) + async def _refuse_si_limite_reset(self, *, email: str, client_ip: str | None) -> None: + politique = self._reset_policy + compteurs = await self._reset_attempts.count_recent( + email=email, client_ip=client_ip, window_seconds=politique.window_seconds + ) + + depasse = ( + compteurs.per_identifier >= politique.max_requests_per_identifier + or compteurs.per_ip >= politique.max_requests_per_ip + ) + if not depasse: + return + + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._transaction.commit() + raise RateLimitedError(politique.window_seconds) + async def _echoue( self, email: str, diff --git a/apps/backend/app/services/data_quality.py b/apps/backend/app/services/data_quality.py new file mode 100644 index 0000000..ae8b43c --- /dev/null +++ b/apps/backend/app/services/data_quality.py @@ -0,0 +1,18 @@ +# Contrainte : `ck_reading_quality` accepte NULL et quatre valeurs seulement, alors que le contrat +# frontend n'a aucune valeur pour l'absence de qualité. `qualite_ou_critique()` replie donc sur +# `critical`, la seule des quatre qui n'induise pas une confiance qu'on n'a pas. `QUALITES_CONNUES` +# reste exposé pour les appelants qui doivent distinguer un `critical` stocké d'un repli. + +from typing import Literal, get_args + +DataQuality = Literal["good", "partial", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset(get_args(DataQuality)) + +_PAR_VALEUR: dict[str, DataQuality] = {valeur: valeur for valeur in get_args(DataQuality)} + + +def qualite_ou_critique(valeur: str | None) -> DataQuality: + if valeur is None: + return "critical" + return _PAR_VALEUR.get(valeur, "critical") diff --git a/apps/backend/app/services/sensor.py b/apps/backend/app/services/sensor.py index 1d707e0..fa1a1ea 100644 --- a/apps/backend/app/services/sensor.py +++ b/apps/backend/app/services/sensor.py @@ -5,12 +5,11 @@ from typing import Literal from app.models.energy import Reading, Site from app.repositories.reading import ReadingRepository from app.repositories.site import SiteRepository +from app.services.data_quality import qualite_ou_critique CapteurStatus = Literal["ok", "failing"] OverallStatus = Literal["ok", "degraded", "critical"] -QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) - RAISON_VERS_CAPTEUR: dict[str, str] = { "consumption_sensor_failure": "consumption", "electrical_sensor_failure": "electrical", @@ -80,7 +79,7 @@ def _sante_site(site: Site, derniere: Reading | None) -> SanteSite: overall="critical", ) - qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" + qualite = qualite_ou_critique(derniere.data_quality) overall = _overall_depuis_qualite(qualite) if overall == "critical": diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 515497a..a438a20 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -1,7 +1,11 @@ from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime from app.models.energy import Site +from app.repositories.reading import ReadingRepository from app.repositories.site import SiteRepository +from app.services.data_quality import DataQuality, qualite_ou_critique class SiteError(Exception): @@ -12,9 +16,26 @@ class SiteNotFoundError(SiteError): pass +@dataclass(frozen=True, slots=True) +class SiteCurrentReading: + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: DataQuality + + class SiteService: - def __init__(self, *, sites: SiteRepository) -> None: + def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None: self._sites = sites + self._readings = readings async def list_all(self) -> Sequence[Site]: return await self._sites.list_all() @@ -24,3 +45,38 @@ class SiteService: if site is None: raise SiteNotFoundError(site_id) return site + + async def current(self, site_id: str) -> SiteCurrentReading: + site = await self.get_by_id(site_id) + derniere = await self._readings.latest_for_site(site_id) + + if derniere is None: + return SiteCurrentReading( + timestamp=None, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=None, + consumption_kwh=None, + voltage_v=None, + current_a=None, + power_factor=None, + temperature_celsius=None, + humidity_percent=None, + null_reasons=[], + data_quality="critical", + ) + + return SiteCurrentReading( + timestamp=derniere.timestamp, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=derniere.consumption_kw, + consumption_kwh=derniere.consumption_kwh, + voltage_v=derniere.voltage_v, + current_a=derniere.current_a, + power_factor=derniere.power_factor, + temperature_celsius=derniere.temperature_celsius, + humidity_percent=derniere.humidity_percent, + null_reasons=derniere.null_reasons or [], + data_quality=qualite_ou_critique(derniere.data_quality), + ) diff --git a/apps/backend/app/services/stats.py b/apps/backend/app/services/stats.py index c98eace..c2b15da 100644 --- a/apps/backend/app/services/stats.py +++ b/apps/backend/app/services/stats.py @@ -1,14 +1,10 @@ from dataclasses import dataclass from datetime import UTC, datetime -from typing import Literal from app.models.energy import Reading, Site from app.repositories.reading import ReadingRepository from app.repositories.site import SiteRepository - -DataQuality = Literal["good", "partial", "degraded", "critical"] - -QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) +from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique @dataclass(frozen=True, slots=True) @@ -58,13 +54,10 @@ class StatsService: @staticmethod def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption: capacite = site.capacity_kw or 0 - # Piège : `data_quality` est nul dès qu'un site n'a jamais reçu de lecture, ou que le - # producteur n'a pas su la qualifier. Le contrat frontend n'a pas de valeur pour ce cas, - # `critical` est la seule des quatre qui n'induit pas une confiance qu'on n'a pas. qualite: DataQuality = "critical" consommation = None if derniere is not None and derniere.data_quality in QUALITES_CONNUES: - qualite = derniere.data_quality # type: ignore[assignment] + qualite = qualite_ou_critique(derniere.data_quality) consommation = derniere.consumption_kw charge = ( diff --git a/apps/backend/app/static/logo-icon.png b/apps/backend/app/static/logo-icon.png new file mode 100644 index 0000000..d3bdc53 Binary files /dev/null and b/apps/backend/app/static/logo-icon.png differ diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index e8525a9..114215b 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -4,7 +4,11 @@ "title": "EnerVision API", "summary": "Collecte, analyse et restitution de séries temporelles énergétiques.", "description": "\nToutes les routes sont préfixées par `/api/v1`.\n\n**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.\nLe jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il\nsuffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un\nnouveau jeton d'accès et fait tourner le cookie.\n\n**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du\nprécédent.\n\n**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut\n`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe\nprovisoire avant toute autre action.\n\nLe parcours de session complet est décrit dans\n`docs/architecture/31-contrat-authentification.md`.\n", - "version": "0.1.0" + "version": "0.1.0", + "x-logo": { + "url": "/static/logo-icon.png", + "altText": "EnerVision" + } }, "paths": { "/api/v1/health/live": { @@ -424,6 +428,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": { "get": { "tags": [ @@ -921,6 +1115,93 @@ } } }, + "/api/v1/sites/{site_id}/current": { + "get": { + "tags": [ + "sites" + ], + "summary": "Dernière mesure d'un site", + "operationId": "get_current_api_v1_sites__site_id__current_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Site Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteCurrentResponse" + } + } + } + }, + "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" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "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" + } + } + } + }, + "404": { + "description": "Aucun site ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/alerts": { "get": { "tags": [ @@ -1587,6 +1868,20 @@ ], "title": "FieldError" }, + "ForgotPasswordRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + } + }, + "type": "object", + "required": [ + "email" + ], + "title": "ForgotPasswordRequest" + }, "InternalErrorResponse": { "properties": { "detail": { @@ -1666,7 +1961,7 @@ "new_password": { "type": "string", "maxLength": 128, - "minLength": 12, + "minLength": 8, "title": "New Password" } }, @@ -1993,6 +2288,40 @@ ], "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": { "type": "string", "enum": [ @@ -2055,6 +2384,140 @@ ], "title": "SensorStatusResponse" }, + "SiteCurrentResponse": { + "properties": { + "timestamp": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Timestamp" + }, + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_type": { + "type": "string", + "title": "Site Type" + }, + "consumption_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kw" + }, + "consumption_kwh": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kwh" + }, + "voltage_v": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Voltage V" + }, + "current_a": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Current A" + }, + "power_factor": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Power Factor" + }, + "temperature_celsius": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Temperature Celsius" + }, + "humidity_percent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Humidity Percent" + }, + "null_reasons": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Null Reasons" + }, + "data_quality": { + "type": "string", + "enum": [ + "good", + "partial", + "degraded", + "critical" + ], + "title": "Data Quality" + } + }, + "type": "object", + "required": [ + "timestamp", + "site_id", + "site_type", + "consumption_kw", + "consumption_kwh", + "voltage_v", + "current_a", + "power_factor", + "temperature_celsius", + "humidity_percent", + "null_reasons", + "data_quality" + ], + "title": "SiteCurrentResponse" + }, "SiteResponse": { "properties": { "site_id": { diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index c330c8d..5336ba6 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pyjwt>=2.10", "argon2-cffi>=23.1", "anyio>=4.0", + "aiosmtplib>=5.1.3", "pandas>=3.0.5", ] diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py index 1d734da..dd8256b 100644 --- a/apps/backend/tests/api/test_auth.py +++ b/apps/backend/tests/api/test_auth.py @@ -11,6 +11,7 @@ from app.core.roles import AccountKind, Role from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, RateLimitedError, SessionRejectedError, ) @@ -27,15 +28,27 @@ PRINCIPAL = Principal( 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._jeton_valide = jeton_valide async def refresh(self, **_: object) -> AuthenticatedSession: return await self.authenticate() + async def is_reset_token_valid(self, **_: object) -> bool: + return self._jeton_valide + async def logout(self, **_: object) -> None: return None + async def request_password_reset(self, **_: object) -> None: + if self._erreur is not None: + raise self._erreur + return None + + async def confirm_password_reset(self, **_: object) -> AuthenticatedSession: + return await self.authenticate() + async def authenticate(self, **_: object) -> AuthenticatedSession: if self._erreur is not None: raise self._erreur @@ -206,3 +219,126 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin( response = await client.post("/api/v1/auth/logout") assert response.status_code != 403 + + +async def test_forgot_password_answers_202_when_the_account_exists( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"} + ) + + assert response.status_code == 202 + assert response.headers["cache-control"] == "no-store" + + +async def test_forgot_password_answers_202_identically_when_the_account_is_unknown( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "inconnu@enervision.fr"} + ) + + assert response.status_code == 202 + + +async def test_forgot_password_returns_429_with_a_retry_after_when_the_rate_limit_is_reached( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = RateLimitedError(900) + + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"} + ) + + assert response.status_code == 429 + assert response.headers["retry-after"] == "900" + + +async def test_forgot_password_rejects_a_malformed_email( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/forgot-password", json={"email": "pas-un-email"}) + + assert response.status_code == 422 + + +@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 diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index aa5546b..85432c4 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -31,6 +31,7 @@ ROUTES_A_ROLE = { ("POST", "/api/v1/users/{id}/password-reset"), ("GET", "/api/v1/sites"), ("GET", "/api/v1/sites/{site_id}"), + ("GET", "/api/v1/sites/{site_id}/current"), ("GET", "/api/v1/alerts"), ("GET", "/api/v1/recommendations"), ("GET", "/api/v1/recommendations/{recommendation_id}"), diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index 9a04338..25e4760 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -18,6 +18,13 @@ ROUTES_PUBLIQUES = frozenset( ("POST", "/api/v1/auth/login"), # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. ("POST", "/api/v1/auth/logout"), + ("POST", "/api/v1/auth/forgot-password"), + # Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune + # authentification préalable ne s'applique, c'est la validité du jeton qui tranche. + ("POST", "/api/v1/auth/reset-password"), + # 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"), } ) @@ -74,3 +81,18 @@ async def test_the_declared_routes_are_actually_reachable(app: FastAPI) -> None: ) def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None: assert ("GET", chemin) in ROUTES_PUBLIQUES + + +# Piège : ni les routes `include_in_schema=False` (/docs, /redoc) ni un `Mount` Starlette +# (/static) n'apparaissent dans `app.openapi()["paths"]`. `routes_declarees()` ne les voit +# donc jamais, et elles échapperaient silencieusement au garde-fou ci-dessus. +@pytest.mark.parametrize( + "chemin", + ["/docs", "/redoc", "/static/logo-icon.png"], + ids=["swagger_ui", "redoc", "logo_statique"], +) +async def test_the_documentation_routes_are_public_by_design( + app: FastAPI, client: AsyncClient, chemin: str +) -> None: + response = await client.get(chemin) + assert response.status_code == 200 diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py index 3692565..dea8850 100644 --- a/apps/backend/tests/api/test_sites.py +++ b/apps/backend/tests/api/test_sites.py @@ -1,4 +1,5 @@ from collections.abc import Callable, Iterator +from datetime import UTC, datetime from uuid import uuid4 import pytest @@ -9,7 +10,9 @@ from app.api.deps import get_current_principal, get_site_service from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.models.energy import Site -from app.services.site import SiteNotFoundError +from app.services.site import SiteCurrentReading, SiteNotFoundError + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) def principal(role: Role = Role.LECTEUR) -> Principal: @@ -33,10 +36,28 @@ def site(site_id: str = "site-1") -> Site: ) +def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading: + return SiteCurrentReading( + timestamp=TIMESTAMP, + site_id=site_id, + site_type="industriel", + consumption_kw=87.34, + consumption_kwh=87.34, + voltage_v=401.2, + current_a=132.5, + power_factor=0.923, + temperature_celsius=22.1, + humidity_percent=58.4, + null_reasons=[], + data_quality="good", + ) + + class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur self.site = site() + self.actuel = lecture_actuelle() async def list_all(self) -> list[Site]: return [self.site] @@ -46,6 +67,11 @@ class FauxService: raise self._erreur return self.site + async def current(self, site_id: str) -> SiteCurrentReading: + if self._erreur is not None: + raise self._erreur + return self.actuel + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: @@ -109,6 +135,30 @@ async def test_get_site_returns_404_for_an_unknown_site( assert response.status_code == 404 +async def test_get_current_returns_the_latest_reading( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + corps = response.json() + assert corps["site_id"] == "site-1" + assert corps["data_quality"] == "good" + assert corps["consumption_kw"] == 87.34 + + +async def test_get_current_returns_404_for_an_unknown_site( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(SiteNotFoundError("site-inconnu")) + + response = await client.get("/api/v1/sites/site-inconnu/current") + + assert response.status_code == 404 + + async def test_list_sites_reaches_the_repository_through_the_session( lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient ) -> None: diff --git a/apps/backend/tests/repositories/test_password_reset_token.py b/apps/backend/tests/repositories/test_password_reset_token.py new file mode 100644 index 0000000..fe99800 --- /dev/null +++ b/apps/backend/tests/repositories/test_password_reset_token.py @@ -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() diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index 150fa29..4f12df0 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -88,6 +88,73 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> assert identifiants == {premier, second} +async def test_latest_by_site_breaks_a_timestamp_tie_on_the_last_written_reading( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + horodatage = datetime(2026, 9, 15, tzinfo=UTC) + await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10 + ) + derniere = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42 + ) + + resultats = await depot.latest_by_site() + retenues = [r.reading_id for r in resultats if r.site_id == site.site_id] + await session.rollback() + + assert retenues == [derniere.reading_id] + + +async def test_latest_for_site_returns_the_most_recent_reading(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)) + recente = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC) + ) + + trouvee = await depot.latest_for_site(site.site_id) + reading_id = trouvee.reading_id if trouvee else None + await session.rollback() + + assert reading_id == recente.reading_id + + +async def test_latest_for_site_breaks_a_timestamp_tie_on_the_last_written_reading( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + horodatage = datetime(2026, 9, 15, tzinfo=UTC) + await creer_lecture(session, site_id=site.site_id, timestamp=horodatage, source="api_history") + derniere = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_current" + ) + + trouvee = await depot.latest_for_site(site.site_id) + reading_id = trouvee.reading_id if trouvee else None + await session.rollback() + + assert reading_id == derniere.reading_id + + +async def test_latest_for_site_ignores_the_readings_of_the_other_sites( + session: AsyncSession, +) -> None: + sans_lecture = await creer_site(session) + autre = await creer_site(session) + depot = ReadingRepository(session) + await creer_lecture(session, site_id=autre.site_id) + + trouvee = await depot.latest_for_site(sans_lecture.site_id) + await session.rollback() + + assert trouvee is None + + async def test_list_history_orders_the_readings_by_timestamp_descending( session: AsyncSession, ) -> None: diff --git a/apps/backend/tests/schemas/__init__.py b/apps/backend/tests/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/schemas/test_auth.py b/apps/backend/tests/schemas/test_auth.py new file mode 100644 index 0000000..956e1e4 --- /dev/null +++ b/apps/backend/tests/schemas/test_auth.py @@ -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) diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 9b8c42c..50a906a 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -5,6 +5,7 @@ from typing import Any from uuid import UUID, uuid4 import pytest +from fastapi import BackgroundTasks from app.core.principal import Principal 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.refresh_token import RevocationReason from app.repositories.login_attempt import FailureCounts +from app.repositories.password_reset_attempt import ResetRequestCounts +from app.repositories.password_reset_token import ConsumedResetToken from app.repositories.refresh_token import ClaimedToken from app.services.auth import ( AuthService, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, LoginPolicy, + PasswordResetPolicy, RateLimitedError, SessionRejectedError, ) @@ -37,6 +42,13 @@ POLITIQUE_CONNEXION = LoginPolicy( max_failures_per_ip=20, max_failures_per_identifier=50, ) +POLITIQUE_RESET = PasswordResetPolicy( + window_seconds=900, + max_requests_per_identifier=3, + max_requests_per_ip=10, + token_ttl=timedelta(minutes=15), + frontend_reset_url="http://localhost:4200/reset-password", +) @dataclass @@ -168,6 +180,49 @@ class FausseTransaction: 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 class Attirail: service: AuthService @@ -176,6 +231,9 @@ class Attirail: jetons: FauxDepotJetons audit: FauxDepotAudit hacheur: FauxHacheur + jetons_reset: FauxDepotJetonsReset + tentatives_reset: FauxDepotTentativesReset + mailer: FauxMailer def fabrique_service( @@ -184,12 +242,17 @@ def fabrique_service( compteurs: FailureCounts | None = None, hacheur: FauxHacheur | None = None, jetons: FauxDepotJetons | None = None, + jetons_reset: FauxDepotJetonsReset | None = None, + compteurs_reset: ResetRequestCounts | None = None, ) -> Attirail: comptes = FauxDepotComptes(compte) tentatives = FauxDepotTentatives(compteurs) depot_jetons = jetons or FauxDepotJetons() audit = FauxDepotAudit() hacheur = hacheur or FauxHacheur() + depot_jetons_reset = jetons_reset or FauxDepotJetonsReset() + tentatives_reset = FauxDepotTentativesReset(compteurs_reset) + mailer = FauxMailer() service = AuthService( users=comptes, # type: ignore[arg-type] attempts=tentatives, # type: ignore[arg-type] @@ -200,8 +263,22 @@ def fabrique_service( token_policy=POLITIQUE_JETON, login_policy=POLITIQUE_CONNEXION, refresh_ttl=timedelta(days=7), + reset_tokens=depot_jetons_reset, # type: ignore[arg-type] + reset_attempts=tentatives_reset, # type: ignore[arg-type] + reset_policy=POLITIQUE_RESET, + mailer=mailer, # type: ignore[arg-type] + ) + return Attirail( + service, + comptes, + tentatives, + depot_jetons, + audit, + hacheur, + depot_jetons_reset, + tentatives_reset, + mailer, ) - return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur) async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object: @@ -493,3 +570,148 @@ async def test_change_password_refuses_a_wrong_current_password() -> None: assert attirail.jetons.revocations_par_compte == [] assert attirail.jetons.crees == [] + + +async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + 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 == [] diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 73ef21f..76584fb 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -1,8 +1,13 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime + import pytest from app.models.energy import Site from app.services.site import SiteNotFoundError, SiteService +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + def site(site_id: str = "site-1") -> Site: return Site( @@ -15,6 +20,21 @@ def site(site_id: str = "site-1") -> Site: ) +@dataclass +class FauxLecture: + site_id: str + timestamp: datetime = TIMESTAMP + consumption_kw: float | None = 87.34 + consumption_kwh: float | None = 87.34 + voltage_v: float | None = 401.2 + current_a: float | None = 132.5 + power_factor: float | None = 0.923 + temperature_celsius: float | None = 22.1 + humidity_percent: float | None = 58.4 + null_reasons: list[str] | None = field(default_factory=list) + data_quality: str | None = "good" + + class FakeRepository: def __init__(self, sites: list[Site]) -> None: self._sites = sites @@ -26,24 +46,77 @@ class FakeRepository: return next((s for s in self._sites if s.site_id == site_id), None) -async def test_list_all_returns_the_repository_sites() -> None: - service = SiteService(sites=FakeRepository([site("a"), site("b")])) +class FauxDepotLectures: + def __init__(self, lectures: dict[str, FauxLecture]) -> None: + self._lectures = lectures - sites = await service.list_all() + async def latest_for_site(self, site_id: str) -> FauxLecture | None: + return self._lectures.get(site_id) + + +def service(sites: list[Site], lectures: dict[str, FauxLecture] | None = None) -> SiteService: + return SiteService( + sites=FakeRepository(sites), # type: ignore[arg-type] + readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type] + ) + + +async def test_list_all_returns_the_repository_sites() -> None: + svc = service([site("a"), site("b")]) + + sites = await svc.list_all() assert [s.site_id for s in sites] == ["a", "b"] async def test_get_by_id_returns_the_matching_site() -> None: - service = SiteService(sites=FakeRepository([site("a")])) + svc = service([site("a")]) - trouve = await service.get_by_id("a") + trouve = await svc.get_by_id("a") assert trouve.site_id == "a" async def test_get_by_id_raises_when_the_site_is_unknown() -> None: - service = SiteService(sites=FakeRepository([])) + svc = service([]) with pytest.raises(SiteNotFoundError): - await service.get_by_id("inconnu") + await svc.get_by_id("inconnu") + + +async def test_current_raises_when_the_site_is_unknown() -> None: + svc = service([]) + + with pytest.raises(SiteNotFoundError): + await svc.current("inconnu") + + +async def test_current_returns_every_field_as_null_when_the_site_has_no_reading() -> None: + svc = service([site("a")]) + + actuel = await svc.current("a") + + assert actuel.timestamp is None + assert actuel.consumption_kw is None + assert actuel.data_quality == "critical" + assert actuel.null_reasons == [] + + +async def test_current_copies_every_field_from_the_latest_reading() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a")}) + + actuel = await svc.current("a") + + assert actuel.timestamp == TIMESTAMP + assert actuel.site_type == "industriel" + assert actuel.consumption_kw == 87.34 + assert actuel.voltage_v == 401.2 + assert actuel.data_quality == "good" + + +async def test_current_treats_an_unknown_data_quality_as_critical() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a", data_quality=None)}) + + actuel = await svc.current("a") + + assert actuel.data_quality == "critical" diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index 40b8317..7344bf7 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from app import cli +from app.schemas.auth import valide_complexite def test_build_parser_reads_the_create_admin_arguments() -> None: @@ -34,26 +35,36 @@ def test_read_password_generates_a_long_secret_when_asked( assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE assert mot_de_passe in capsys.readouterr().out + valide_complexite(mot_de_passe) def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None: - saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"]) + saisies = iter(["Un-mot-de-passe-valide1", "Un-mot-de-passe-valide1"]) monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) - assert cli.read_password(generate=False) == "un-mot-de-passe-valide" + assert cli.read_password(generate=False) == "Un-mot-de-passe-valide1" def test_read_password_refuses_a_password_below_the_minimum_length( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(cli, "getpass", lambda _: "court") + monkeypatch.setattr(cli, "getpass", lambda _: "Court1!") + + with pytest.raises(SystemExit): + cli.read_password(generate=False) + + +def test_read_password_refuses_a_password_missing_a_character_class( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(cli, "getpass", lambda _: "un-mot-de-passe-sans-majuscule-ni-chiffre") with pytest.raises(SystemExit): cli.read_password(generate=False) def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None: - saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"]) + saisies = iter(["Un-mot-de-passe-valide1", "Un-autre-mot-de-passe2"]) monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) with pytest.raises(SystemExit): diff --git a/apps/backend/tests/test_static_assets.py b/apps/backend/tests/test_static_assets.py new file mode 100644 index 0000000..125ecda --- /dev/null +++ b/apps/backend/tests/test_static_assets.py @@ -0,0 +1,13 @@ +# Piège : le logo est committé indépendamment à deux endroits (`app/static/`, servi par +# `/docs`/`/redoc`, et `apps/frontend/public/`, servi au front) faute d'étape de build partagée. +# Sans ce test, une mise à jour d'un seul des deux fichiers dérive silencieusement : rien en CI +# ne le détecte. + +from pathlib import Path + +BACKEND_LOGO = Path(__file__).parent.parent / "app" / "static" / "logo-icon.png" +FRONTEND_LOGO = Path(__file__).parent.parent.parent / "frontend" / "public" / "logo-icon.png" + + +def test_the_backend_logo_stays_in_sync_with_the_frontend_one() -> None: + assert BACKEND_LOGO.read_bytes() == FRONTEND_LOGO.read_bytes() diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index 6836990..59ff61b 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -7,6 +7,15 @@ resolution-markers = [ "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]] name = "alembic" version = "1.20.0" @@ -311,6 +320,7 @@ name = "enervision-backend" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiosmtplib" }, { name = "alembic" }, { name = "anyio" }, { name = "argon2-cffi" }, @@ -339,6 +349,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiosmtplib", specifier = ">=5.1.3" }, { name = "alembic", specifier = ">=1.20.0" }, { name = "anyio", specifier = ">=4.0" }, { name = "argon2-cffi", specifier = ">=23.1" }, diff --git a/apps/frontend/README.md b/apps/frontend/README.md index aeaf788..c5b7484 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -76,6 +76,13 @@ Points à vérifier après toute regénération : côté backend. Le `docker-compose.yml` n'a aucun service frontend. 4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx). +## Design système + +Tokens (couleurs, typo, espacements) et composants partagés (`ev-button`, `ev-card`, +`ev-alert`, `ev-badge`) sont documentés dans +[`docs/architecture/32-design-systeme-frontend.md`](../../docs/architecture/32-design-systeme-frontend.md). +Toute nouvelle page doit les réutiliser plutôt que définir ses propres valeurs. + ## Additional Resources For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index 6cb33fa..981779f 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -86,15 +86,6 @@ "text-summary", "lcov", "html" - ], - "reporters": [ - "default", - [ - "junit", - { - "outputFile": "test-results/junit.xml" - } - ] ] } } diff --git a/apps/frontend/public/favicon.ico b/apps/frontend/public/favicon.ico index 57614f9..2b78d24 100644 Binary files a/apps/frontend/public/favicon.ico and b/apps/frontend/public/favicon.ico differ diff --git a/apps/frontend/public/logo-icon.png b/apps/frontend/public/logo-icon.png new file mode 100644 index 0000000..d3bdc53 Binary files /dev/null and b/apps/frontend/public/logo-icon.png differ diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index b3e97d8..e619268 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -5,9 +5,24 @@ export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { 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: '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', canActivate: [authGuard], loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard), }, + { + path: 'sites', + canActivate: [authGuard], + loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList), + }, + { + path: 'sites/:siteId', + canActivate: [authGuard], + loadComponent: () => + import('./features/sites/site-detail-placeholder/site-detail-placeholder').then( + (m) => m.SiteDetailPlaceholder, + ), + }, ]; diff --git a/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts index 8f74cd8..9064aaf 100644 --- a/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts @@ -41,7 +41,10 @@ describe('authInterceptor', () => { httpMock = TestBed.inject(HttpTestingController); }); - afterEach(() => httpMock.verify()); + afterEach(() => { + httpMock.verify(); + vi.restoreAllMocks(); + }); it('ajoute le header Authorization quand un token est disponible', () => { http.get('/api/v1/stats/summary').subscribe(); @@ -97,6 +100,19 @@ describe('authInterceptor', () => { 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"', () => { authMock.refreshShared.mockReturnValue(of({ access_token: 'new-token' })); authMock.getAccessToken.mockReturnValueOnce('old-token').mockReturnValue('new-token'); diff --git a/apps/frontend/src/app/core/interceptors/auth-interceptor.ts b/apps/frontend/src/app/core/interceptors/auth-interceptor.ts index 46ba124..16f3047 100644 --- a/apps/frontend/src/app/core/interceptors/auth-interceptor.ts +++ b/apps/frontend/src/app/core/interceptors/auth-interceptor.ts @@ -11,6 +11,16 @@ function parseAuthError(response: HttpErrorResponse): string | 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) => { const auth = inject(AuthService); const router = inject(Router); @@ -43,7 +53,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { if (req.url.endsWith('/auth/refresh')) { auth.clearSession(); - router.navigate(['/login']); + if (!surRouteInvitee()) { + router.navigate(['/login']); + } return throwError(() => error); } @@ -51,7 +63,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { if (kind === 'invalid_token') { auth.clearSession(); - router.navigate(['/login']); + if (!surRouteInvitee()) { + router.navigate(['/login']); + } return throwError(() => error); } @@ -65,7 +79,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { }), catchError((refreshError) => { auth.clearSession(); - router.navigate(['/login']); + if (!surRouteInvitee()) { + router.navigate(['/login']); + } return throwError(() => refreshError); }) ); diff --git a/apps/frontend/src/app/core/services/auth.service.spec.ts b/apps/frontend/src/app/core/services/auth.service.spec.ts index bff86c4..c51c8eb 100644 --- a/apps/frontend/src/app/core/services/auth.service.spec.ts +++ b/apps/frontend/src/app/core/services/auth.service.spec.ts @@ -83,4 +83,17 @@ describe('AuthService', () => { 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 }); + }); }); diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts index d27c1db..c2d3e9c 100644 --- a/apps/frontend/src/app/core/services/auth.service.ts +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -1,7 +1,14 @@ import { Service, signal, computed, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; 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'; @Service() @@ -66,4 +73,20 @@ export class AuthService { me(): Observable { return this.http.get(`${environment.apiUrl}/auth/me`); } + + forgotPassword(payload: ForgotPasswordRequest): Observable { + return this.http.post(`${environment.apiUrl}/auth/forgot-password`, payload); + } + + resetPassword(payload: ResetPasswordRequest): Observable { + return this.http + .post(`${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 }, + }); + } } diff --git a/apps/frontend/src/app/core/services/sites.service.spec.ts b/apps/frontend/src/app/core/services/sites.service.spec.ts new file mode 100644 index 0000000..45aee2c --- /dev/null +++ b/apps/frontend/src/app/core/services/sites.service.spec.ts @@ -0,0 +1,41 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { SitesService } from './sites.service'; +import { environment } from '../../../environments/environment'; + +describe('SitesService', () => { + let service: SitesService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(SitesService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('appelle le bon endpoint et retourne la liste des sites', () => { + let result: unknown; + service.getSites().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites`); + expect(req.request.method).toBe('GET'); + + req.flush([ + { + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }, + ]); + + expect((result as { site_id: string }[])[0].site_id).toBe('SITE001'); + }); +}); diff --git a/apps/frontend/src/app/core/services/sites.service.ts b/apps/frontend/src/app/core/services/sites.service.ts new file mode 100644 index 0000000..85754cc --- /dev/null +++ b/apps/frontend/src/app/core/services/sites.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { Site } from '../../shared/models/site.model'; + +@Service() +export class SitesService { + private http = inject(HttpClient); + + getSites() { + return this.http.get(`${environment.apiUrl}/sites`); + } +} diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.html b/apps/frontend/src/app/features/auth/change-password/change-password.html index edf2146..6c3de32 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.html +++ b/apps/frontend/src/app/features/auth/change-password/change-password.html @@ -1,31 +1,38 @@
-
-

Nouveau mot de passe

-

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

+ + + +

Nouveau mot de passe

+

+ Votre mot de passe est provisoire, vous devez le modifier avant de continuer +

- - + + - - - 12 à 128 caractères + + + {{ passwordHint }} - @if (errorMessage()) { -

{{ errorMessage() }}

- } + @if (errorMessage()) { + {{ errorMessage() }} + } - + + {{ isLoading() ? 'Modification...' : 'Valider' }} + +
diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.scss b/apps/frontend/src/app/features/auth/change-password/change-password.scss index f44fcb8..e69de29 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.scss +++ b/apps/frontend/src/app/features/auth/change-password/change-password.scss @@ -1,88 +0,0 @@ -: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; -} diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 63e1872..126e892 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -32,10 +32,19 @@ describe('ChangePassword', () => { 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', () => { const fixture = TestBed.createComponent(ChangePassword); 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' } })); @@ -46,7 +55,7 @@ describe('ChangePassword', () => { it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => { const fixture = TestBed.createComponent(ChangePassword); 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'))); @@ -54,7 +63,7 @@ describe('ChangePassword', () => { fixture.detectChanges(); // rend le bloc @if (errorMessage()) expect(component.errorMessage()).toContain('incorrect'); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('incorrect'); }); @@ -64,13 +73,13 @@ describe('ChangePassword', () => { const button = fixture.nativeElement.querySelector('button[type="submit"]'); expect(button.disabled).toBe(true); - expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); }); it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { const fixture = TestBed.createComponent(ChangePassword); 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(); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); @@ -81,7 +90,7 @@ describe('ChangePassword', () => { expect(authMock.changePassword).toHaveBeenCalledWith({ current_password: 'ancien-mot-de-passe', - new_password: 'un-nouveau-mot-de-passe-valide', + new_password: 'Un-nouveau-mot-de-passe1!', }); }); diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 507af14..06be74d 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -2,11 +2,16 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { AuthService } from '../../../core/services/auth.service'; +import { Button } from '../../../shared/components/ui/button/button'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; +import { Brand } from '../../../shared/components/ui/brand/brand'; +import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; @Component({ selector: 'app-change-password', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, Button, Card, Alert, Brand], templateUrl: './change-password.html', styleUrl: './change-password.scss', }) @@ -17,10 +22,11 @@ export class ChangePassword { errorMessage = signal(null); isLoading = signal(false); + passwordHint = PASSWORD_HINT; form = this.fb.nonNullable.group({ current_password: ['', Validators.required], - new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]], + new_password: ['', passwordValidators], }); onSubmit(): void { @@ -34,7 +40,9 @@ export class ChangePassword { }, error: () => { 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}).`, + ); }, }); } diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html new file mode 100644 index 0000000..2bd7ef9 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html @@ -0,0 +1,37 @@ +
+
+

Mot de passe oublié

+

Recevez un lien de réinitialisation par email

+ + @if (submitted()) { +

+ Si un compte existe pour cet email, un lien de réinitialisation vient d'être envoyé. + Il expire dans 15 minutes. +

+ } @else { + + + + @if (errorMessage()) { +

+ {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } +

+ } + + + } + + +
+
diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss @@ -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; + } +} diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts new file mode 100644 index 0000000..56f7764 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts @@ -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 }; + let routerMock: { navigate: ReturnType }; + + 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); + }); +}); diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts new file mode 100644 index 0000000..6ceef5c --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts @@ -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(null); + retryAfterSeconds = signal(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); + }, + }); + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 0083bd2..cb802ca 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -1,36 +1,43 @@
-
-

Connexion

-

Accédez à votre espace EnerVision

+ + + +

Connexion

+

Accédez à votre espace EnerVision

- - + + - - + + - @if (errorMessage()) { -

- {{ errorMessage() }} - @if (retryAfterSeconds(); as seconds) { - (réessayez dans {{ seconds }}s) - } -

- } + @if (errorMessage()) { + + {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } + + } - + + {{ isLoading() ? 'Connexion...' : 'Se connecter' }} + + + +
diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss index cc415b8..f0ffb17 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -1,81 +1,9 @@ -: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; - } - - 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-error { - margin: 0.75rem 0 0; - color: #dc2626; +.auth-link { + margin-top: 1rem; font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } } diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index 3c9bac1..5c0ac6c 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -1,27 +1,43 @@ import { TestBed } from '@angular/core/testing'; 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 { of, throwError } from 'rxjs'; import { vi } from 'vitest'; import { Login } from './login'; import { AuthService } from '../../../core/services/auth.service'; +import { MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; + +function configure(queryParams: Record = {}) { + 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', () => { let authMock: { login: ReturnType }; let routerMock: { navigate: ReturnType }; beforeEach(async () => { - authMock = { login: vi.fn() }; - routerMock = { navigate: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [Login, ReactiveFormsModule], - providers: [ - { provide: AuthService, useValue: authMock }, - { provide: Router, useValue: routerMock }, - ], - }).compileComponents(); + const attirail = configure(); + authMock = attirail.authMock; + routerMock = attirail.routerMock; + await attirail.testBed.compileComponents(); }); it('ne soumet pas si le formulaire est invalide', () => { @@ -63,7 +79,7 @@ describe('Login', () => { fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.'); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.'); }); @@ -80,17 +96,25 @@ describe('Login', () => { fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds) expect(component.retryAfterSeconds()).toBe(30); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); 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', () => { const fixture = TestBed.createComponent(Login); fixture.detectChanges(); const button = fixture.nativeElement.querySelector('button[type="submit"]'); expect(button.disabled).toBe(true); - expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); }); it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 34b9ff2..22fbe8d 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -1,13 +1,21 @@ import { Component, inject, signal } from '@angular/core'; 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 { AuthService } from '../../../core/services/auth.service'; +import { Button } from '../../../shared/components/ui/button/button'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; +import { Brand } from '../../../shared/components/ui/brand/brand'; +import { + MESSAGE_LIEN_RESET_INVALIDE, + MOTIF_LIEN_RESET_INVALIDE, +} from '../../../shared/models/auth-redirect-reason'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, RouterLink, Button, Card, Alert, Brand], templateUrl: './login.html', styleUrl: './login.scss', }) @@ -15,8 +23,13 @@ export class Login { private fb = inject(FormBuilder); private auth = inject(AuthService); private router = inject(Router); + private route = inject(ActivatedRoute); - errorMessage = signal(null); + errorMessage = signal( + this.route.snapshot.queryParamMap.get('motif') === MOTIF_LIEN_RESET_INVALIDE + ? MESSAGE_LIEN_RESET_INVALIDE + : null, + ); retryAfterSeconds = signal(null); isLoading = signal(false); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.html b/apps/frontend/src/app/features/auth/reset-password/reset-password.html new file mode 100644 index 0000000..fad2f7e --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.html @@ -0,0 +1,32 @@ +
+
+

Nouveau mot de passe

+ + @if (hasToken && !isCheckingToken()) { +

Choisissez votre nouveau mot de passe

+ + + + + + @if (errorMessage()) { +

{{ errorMessage() }}

+ } + + + } + + @if (hasToken && isCheckingToken()) { +

Vérification du lien...

+ } + + + +
diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.scss b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss @@ -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; + } +} diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts new file mode 100644 index 0000000..38e30b5 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts @@ -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 }; + + 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 }; + + 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 }; + + 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 }; + 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 }; + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + 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 }; + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + 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 }; + 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'); + }); +}); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts new file mode 100644 index 0000000..64ad31e --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts @@ -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(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 } }); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 684b444..08b015e 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,19 +1,33 @@
-
-

Vue d'ensemble

-

Consommation instantanée du parc

+
+ + +
+

Vue d'ensemble

+

Consommation instantanée du parc

+
+
+
+ Voir les sites + Déconnexion
-
@if (error(); as message) { - + } @if (stats(); as s) {
-
+ Consommation vs capacité {{ s.total_consumption_kw | number: '1.0-1' }} / {{ s.total_capacity_kw | number }} kW -
+ -
+ Charge moyenne du parc {{ s.average_load_percent }} %
-
+ -
+ Sites suivis {{ s.total_sites }} -
+
@@ -50,8 +64,8 @@

Alertes actives