From 1b0b20c12d9e801d0534ca9a6d00a703f7438719 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 15 Jun 2026 20:18:59 +0200 Subject: [PATCH] feat: authentification back-office P2 (login/logout/reset, throttle, audit) (#11) --- docker-compose.yml | 16 + docs/api/conventions.md | 328 ++++++++++++++++++ phpunit.xml | 4 + src/app/Auth/AuthResult.php | 35 ++ src/app/Auth/AuthService.php | 280 +++++++++++++++ src/app/Auth/Csrf.php | 60 ++++ src/app/Auth/GuardResult.php | 22 ++ src/app/Auth/LogMailer.php | 18 + src/app/Auth/Mailer.php | 16 + src/app/Auth/PasswordHasher.php | 73 ++++ src/app/Auth/PasswordResetService.php | 127 +++++++ src/app/Auth/SessionGuard.php | 67 ++++ src/app/Auth/SessionManager.php | 172 +++++++++ src/app/Auth/ThrottlePolicy.php | 85 +++++ src/app/Controllers/AuthController.php | 127 +++++++ .../Controllers/PasswordResetController.php | 150 ++++++++ src/app/Core/Database.php | 29 +- src/app/Core/DatabaseInterface.php | 43 +++ src/app/Core/Request.php | 69 ++++ src/app/Core/Response.php | 18 + src/app/Views/auth/forgot.php | 35 ++ src/app/Views/auth/login.php | 47 +++ src/app/Views/auth/reset.php | 43 +++ src/public/admin/index.php | 17 + tests/Integration/AuthServiceDbTest.php | 189 ++++++++++ tests/Support/FakeDatabase.php | 155 +++++++++ tests/Support/SpyMailer.php | 22 ++ tests/Unit/Auth/AuthControllerTest.php | 197 +++++++++++ tests/Unit/Auth/AuthServiceTest.php | 315 +++++++++++++++++ tests/Unit/Auth/CsrfTest.php | 74 ++++ tests/Unit/Auth/PasswordHasherTest.php | 87 +++++ .../Unit/Auth/PasswordResetControllerTest.php | 175 ++++++++++ tests/Unit/Auth/PasswordResetServiceTest.php | 154 ++++++++ tests/Unit/Auth/SessionGuardTest.php | 121 +++++++ tests/Unit/Auth/ThrottlePolicyTest.php | 133 +++++++ tests/Unit/Core/RequestFormBodyTest.php | 128 +++++++ tests/bootstrap.php | 18 + 37 files changed, 3648 insertions(+), 1 deletion(-) create mode 100644 docs/api/conventions.md create mode 100644 src/app/Auth/AuthResult.php create mode 100644 src/app/Auth/AuthService.php create mode 100644 src/app/Auth/Csrf.php create mode 100644 src/app/Auth/GuardResult.php create mode 100644 src/app/Auth/LogMailer.php create mode 100644 src/app/Auth/Mailer.php create mode 100644 src/app/Auth/PasswordHasher.php create mode 100644 src/app/Auth/PasswordResetService.php create mode 100644 src/app/Auth/SessionGuard.php create mode 100644 src/app/Auth/SessionManager.php create mode 100644 src/app/Auth/ThrottlePolicy.php create mode 100644 src/app/Controllers/AuthController.php create mode 100644 src/app/Controllers/PasswordResetController.php create mode 100644 src/app/Core/DatabaseInterface.php create mode 100644 src/app/Views/auth/forgot.php create mode 100644 src/app/Views/auth/login.php create mode 100644 src/app/Views/auth/reset.php create mode 100644 tests/Integration/AuthServiceDbTest.php create mode 100644 tests/Support/FakeDatabase.php create mode 100644 tests/Support/SpyMailer.php create mode 100644 tests/Unit/Auth/AuthControllerTest.php create mode 100644 tests/Unit/Auth/AuthServiceTest.php create mode 100644 tests/Unit/Auth/CsrfTest.php create mode 100644 tests/Unit/Auth/PasswordHasherTest.php create mode 100644 tests/Unit/Auth/PasswordResetControllerTest.php create mode 100644 tests/Unit/Auth/PasswordResetServiceTest.php create mode 100644 tests/Unit/Auth/SessionGuardTest.php create mode 100644 tests/Unit/Auth/ThrottlePolicyTest.php create mode 100644 tests/Unit/Core/RequestFormBodyTest.php diff --git a/docker-compose.yml b/docker-compose.yml index d131af4..589abe9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -135,6 +135,22 @@ services: SESSION_NAME: ${SESSION_NAME} CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN} PASSWORD_ALGO: ${PASSWORD_ALGO} + # Cout argon2id (password_hash) : aligne sur .env.example / OWASP. Sert au + # hash du mot de passe ET du PIN equipier (actions sensibles, P3). + ARGON2_MEMORY_COST: ${ARGON2_MEMORY_COST} + ARGON2_TIME_COST: ${ARGON2_TIME_COST} + ARGON2_THREADS: ${ARGON2_THREADS} + # Anti brute-force : backoff degressif par compte (user.lockout_until) et + # par IP source (table login_throttle). Voir mlt.md 12.1 RG-8/RG-9. + ACCOUNT_LOCKOUT_THRESHOLD: ${ACCOUNT_LOCKOUT_THRESHOLD} + ACCOUNT_LOCKOUT_BASE_SECONDS: ${ACCOUNT_LOCKOUT_BASE_SECONDS} + ACCOUNT_LOCKOUT_MAX_SECONDS: ${ACCOUNT_LOCKOUT_MAX_SECONDS} + IP_THROTTLE_WINDOW_SECONDS: ${IP_THROTTLE_WINDOW_SECONDS} + IP_THROTTLE_MAX_ATTEMPTS: ${IP_THROTTLE_MAX_ATTEMPTS} + # Longueur minimale du PIN equipier (actions sensibles, P3). + STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH} + # Expiration du token de reinitialisation de mot de passe (mlt.md 12.3). + PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB} UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME} diff --git a/docs/api/conventions.md b/docs/api/conventions.md new file mode 100644 index 0000000..3fbe29c --- /dev/null +++ b/docs/api/conventions.md @@ -0,0 +1,328 @@ +# API Wakdo - conventions de nommage, structure et listing + +**Statut** : v0.2 - convention de casse arbitree (snake_case, voir section 4) +**Perimetre** : back-office admin (rendu serveur) + API REST sous `/api/*` +**Auteur methodologie** : BYAN +**A lire avec** : `docs/PROJECT_CONTEXT.md`, `docs/merise/dictionary.md` (source de verite des +noms de champs), `docs/merise/mct.md` + `mlt.md` (operations metier), `db/seeds/0001_rbac_and_reference.sql` +(catalogue des 23 permissions). NB : `docs/api/byan-api.md` documente l'API de la plateforme BYAN, +distincte de l'API Wakdo decrite ici. + +--- + +## 1. Objet + +Fixer les conventions de nommage, la structure des points d'entree HTTP de Wakdo, et tenir le +listing des endpoints (en service et prevus). Objectif : que chaque endpoint ajoute suive le meme +moule. Les choix sont des conventions de projet (coherence, lisibilite), pas des regles universelles ; +une convention peut evoluer, auquel cas ce document est mis a jour en premier. + +--- + +## 2. Par quoi passe une requete + +Deux hotes distincts, un seul conteneur web (Apache), routes par le Traefik de l'hote : + +``` +Client (borne / navigateur back-office) + -> Traefik (TLS, ajoute X-Forwarded-For, route par Host) + -> wakdo-web (Apache, vhost selon le Host) + - vhost kiosk : DocumentRoot src/public/borne (statique + futur appel /api) + - vhost admin : DocumentRoot src/public/admin + - fichier existant (login.html, *.css) : servi tel quel + - sinon RewriteRule -> index.php (front controller) + -> wakdo-app (PHP-FPM, via proxy FastCGI sur *.php) + front controller -> Router -> Controller -> Response + -> wakdo-db (MariaDB, requetes preparees PDO uniquement) +``` + +Consequence de nommage : le DocumentRoot du vhost admin est `src/public/admin`, donc le +`REQUEST_URI` arrive **sans prefixe** `/admin`. Le Router voit `/login`, `/api/health`, etc. +On n'ajoute pas de segment `/admin` dans les chemins de routes. + +Code de reference : routes dans `src/public/admin/index.php`, controleurs dans +`src/app/Controllers/`, enveloppe de reponse dans `src/app/Core/Response.php`, resolution +(404 / 405) dans `src/app/Core/Router.php`. + +--- + +## 3. Deux familles d'endpoints + +| Famille | Prefixe | Rendu | Authentification | Exemple | +|---|---|---|---|---| +| Pages back-office | aucun | HTML (vue serveur + `layout.php`) | session admin | `/login`, `/forgot_password` | +| API REST | `/api/` | JSON (enveloppe section 7) | selon la ressource (section 10) | `/api/health`, `/api/categories` (prevu) | + +La borne (kiosk) consommera l'API REST `/api/*` (P4). En attendant, elle lit un repli JSON +statique sous `src/public/borne/data/` (voir section 8.3). + +--- + +## 4. Nommage des chemins (URL) + +Deux decisions, dont une sourcee et une de coherence : + +- **Minuscules** sur tout le chemin. Sourced : RFC 3986 §6.2.2.1 - seuls le scheme et l'hote sont + insensibles a la casse, le path est sensible a la casse ; le minuscule evite les bugs de casse. +- **Separateur de mots : `_` (snake_case)**. Aucun standard n'impose `-` ou `_` dans un segment + (les deux sont des caracteres `unreserved`, RFC 3986 §2.3). On retient `_` pour n'avoir **qu'une + seule convention de casse** sur tout le projet : colonnes DB, champs JSON (section 8) et chemins + d'URL partagent le snake_case. Cela calque les noms de tables (`order_item` -> `/api/order_items`) + et reduit la charge a memoriser (Rasoir d'Ockham, mantra #37). + +Autres regles : + +- **Noms de ressources au pluriel** pour les collections : `/api/categories`, `/api/products`, + `/api/orders`. +- **Identifiant en segment** pour une ressource unitaire : `/api/orders/{number}`, + `/api/products/{id}`. Parametre dynamique : `{nom}` (groupe nomme cote Router). +- **Sous-ressource** par imbrication : `/api/orders/{id}/items` (prevu). +- **Action non-CRUD** par sous-chemin verbe : `POST /api/orders/{id}/cancel` + (cf. `docs/uml/security-sequence.md`). +- Pas de barre oblique finale signifiante : `Request::normalizePath` aligne `/api/health/` et + `/api/health`. + +--- + +## 5. Listing des endpoints + +### 5.1 En service (P2) + +| Methode | Chemin | Auth | Rendu | Role | +|---|---|---|---|---| +| GET | `/` | (session en P3) | HTML | accueil back-office (squelette) | +| GET | `/api/health` | public | JSON (plat) | sonde de sante (DB reelle) | +| GET | `/login` | public | HTML | formulaire de connexion | +| POST | `/login` | public + CSRF | 302 / HTML | authentification (mlt 12.1) | +| POST | `/logout` | session + CSRF | 302 | deconnexion (mlt 12.2) | +| GET | `/forgot_password` | public | HTML | demande de reinitialisation | +| POST | `/forgot_password` | public + CSRF | HTML (neutre) | envoi du lien (mlt 12.3) | +| GET | `/reset_password` | public (token en query) | HTML | formulaire nouveau mot de passe | +| POST | `/reset_password` | public + CSRF | 302 / HTML | confirmation (mlt 12.3) | + +### 5.2 API kiosk - lecture catalogue + commande (prevu P4, public) + +La borne est publique (aucune session) ; cf. `mlt.md` CREATE_ORDER, declencheur kiosk. + +| Methode | Chemin | Permission | Op MCT | Statut | +|---|---|---|---|---| +| GET | `/api/categories` | (lecture publique) | READ_CATALOGUE | prevu | +| GET | `/api/products` | (lecture publique) | READ_CATALOGUE | prevu | +| GET | `/api/products/{id}` | (lecture publique) | READ_CATALOGUE | prevu | +| GET | `/api/menus` | (lecture publique) | READ_CATALOGUE | prevu | +| GET | `/api/menus/{id}` | (lecture publique) | READ_CATALOGUE | prevu | +| POST | `/api/orders` | (kiosk public) | CREATE_ORDER (mlt 3.3) | prevu (idempotency_key, RG-T19) | + +### 5.3 API / pages back-office (prevu P3-P4, session + permission) + +Provisoire : le choix entre endpoints JSON `/api/*` et pages rendues serveur pour les ecritures +admin est tranche phase par phase (P3 CRUD). Les colonnes Permission renvoient au catalogue fige +des 23 permissions (`db/seeds/0001_rbac_and_reference.sql`) ; l'imputabilite et le PIN suivent +`mlt.md` RG-T13/RG-T14. + +Commandes (cote equipier) : + +| Methode | Chemin | Permission | Op MCT | Note | +|---|---|---|---|---| +| GET | `/api/orders` | `order.read` | READ_ORDERS | filtre par `role_visible_source` (RG-T12) | +| GET | `/api/orders/{number}` | `order.read` | READ_ORDERS | | +| POST | `/api/orders` (comptoir/drive) | `order.create` | CREATE_COUNTER_ORDER (mlt 4.1) | source auto-taggee | +| POST | `/api/orders/{id}/deliver` | `order.deliver` | DELIVER_ORDER (mlt 6.1) | | +| POST | `/api/orders/{id}/cancel` | `order.cancel` | CANCEL_ORDER (mlt 7.1) | PIN + audit_log (RG-T13/14) | + +Catalogue (produits, menus, categories) : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| POST | `/api/products` | `product.create` | CREATE_PRODUCT (mlt 8.1) | +| PUT | `/api/products/{id}` | `product.update` | UPDATE_PRODUCT (mlt 8.2) - PIN sur prix/TVA | +| DELETE | `/api/products/{id}` | `product.delete` | DELETE_PRODUCT (mlt 8.3) - PIN | +| POST | `/api/menus` | `menu.create` | CREATE_MENU | +| PUT | `/api/menus/{id}` | `menu.update` | UPDATE_MENU | +| DELETE | `/api/menus/{id}` | `menu.delete` | DELETE_MENU - PIN | +| POST/PUT/DELETE | `/api/categories[/{id}]` | `category.manage` | MANAGE_CATEGORY | + +Stock et ingredients : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| GET | `/api/ingredients` | `ingredient.manage` | READ_INGREDIENTS | +| GET | `/api/stock` | `stock.read` | READ_STOCK | +| POST | `/api/stock/restock` | `stock.manage` | RESTOCK (mlt 9.1) | +| POST | `/api/stock/count` | `stock.count` | INVENTORY_COUNT (mlt 9.2) - PIN | + +Utilisateurs et RBAC : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| GET | `/api/users` | `user.read` | READ_USERS | +| POST | `/api/users` | `user.create` | CREATE_USER (mlt 10.1) - PIN | +| PUT | `/api/users/{id}` | `user.update` | UPDATE_USER (mlt 10.2) - PIN | +| POST | `/api/users/{id}/deactivate` | `user.deactivate` | DEACTIVATE_USER (mlt 10.3) - PIN | +| GET/PUT | `/api/roles[/{id}/permissions]` | `role.manage` | MANAGE_RBAC (mlt 10.4) - PIN | + +Statistiques : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| GET | `/api/stats` | `stats.read` | READ_STATS (mlt 11.x) | + +> Les chemins exacts en 5.2/5.3 sont une projection a partir des operations MCT et des permissions +> seedees ; ils sont confirmes au moment d'ecrire chaque endpoint. Seule la section 5.1 est en service. + +--- + +## 6. Methodes HTTP + +| Methode | Usage | +|---|---| +| GET | lecture, sans effet de bord | +| POST | creation, ou action de formulaire back-office (login, logout, reset) | +| PUT | mise a jour d'une ressource (prevu, CRUD admin P3) | +| DELETE | suppression d'une ressource (prevu) | + +Le Router fait une correspondance exacte de la methode : methode connue sur chemin connu mais non +enregistree -> `405` ; chemin inconnu -> `404` (`Router::dispatch`). Une requete `HEAD` sur une +route `GET` renvoie aujourd'hui `405` (correspondance exacte) ; un assouplissement reste possible +si un besoin apparait. + +--- + +## 7. Enveloppe de reponse JSON + +L'API enveloppe ses reponses pour qu'un client distingue donnees et erreur de maniere uniforme. + +Succes - ressource unitaire : + +```json +{ "data": { "id": 3, "name": "Big Mac", "price_cents": 590 } } +``` + +Succes - collection (`total` optionnel pour la pagination future) : + +```json +{ "data": [ { "id": 1 }, { "id": 2 } ], "total": 2 } +``` + +Erreur : + +```json +{ "data": null, "error": { "code": "NOT_FOUND", "message": "Resource not found" } } +``` + +Exception documentee : `GET /api/health` renvoie un objet de diagnostic plat (`status`, `app_env`, +`php_version`, `db`, `categories`), hors enveloppe, car il sert le monitoring et non un client +applicatif. + +Type de contenu : `application/json; charset=utf-8` (`Response::json`). Les pages back-office +renvoient `text/html; charset=utf-8`. + +--- + +## 8. Normalisation des noms de champs + +### 8.1 Regle generale : snake_case aligne sur le dictionnaire + +Les champs JSON reprennent les noms du dictionnaire (`docs/merise/dictionary.md`), source de verite, +ce qui evite une couche de traduction entre base, code et contrat HTTP. + +| Categorie | Convention | Exemple | +|---|---|---| +| Champ simple | snake_case, anglais | `display_order`, `image_path` | +| Montant monetaire | entier en centimes, suffixe `_cents` | `price_cents`, `total_ttc_cents` | +| Taux de TVA | entier pour mille | `vat_rate` (55 = 5,5 % ; 100 = 10 %) | +| Booleen | prefixe `is_` | `is_available`, `is_active` | +| Horodatage | suffixe `_at`, ISO 8601 en sortie API | `created_at`, `paid_at` | +| Cle etrangere | suffixe `_id` | `category_id`, `role_id` | +| Valeur d'enumeration | minuscules snake_case | `pending_payment`, `dine_in`, `kiosk` | +| Identifiant | `id` (entier) ou `order_number` (chaine metier) | `id`, `order_number` | + +Les horodatages sont stockes en `DATETIME` ; leur exposition API se fait en ISO 8601 (a cadrer +au moment d'ecrire les endpoints de lecture P4). + +### 8.2 Codes d'erreur + +SCREAMING_SNAKE_CASE, stables (un client peut s'y fier) ; le `message` reste lisible (non garanti +stable). + +| Code | HTTP | Sens | +|---|---|---| +| `NOT_FOUND` | 404 | ressource introuvable | +| `METHOD_NOT_ALLOWED` | 405 | methode non autorisee sur ce chemin | +| `VALIDATION_ERROR` | 422 | entree invalide (champ, longueur, enum) | +| `CONFLICT` | 409 | conflit d'etat (ex. transition de commande concurrente) | +| `AUTH_REQUIRED` | 401 | authentification requise (prevu, API admin) | +| `FORBIDDEN` | 403 | permission insuffisante, ou jeton CSRF invalide cote formulaire | +| `RATE_LIMITED` | 429 | throttling (prevu) | +| `INTERNAL_ERROR` | 500 | erreur interne, message generique (pas de divulgation) | + +Codes specifiques nommes par le MLT, en surcharge du socle : `CANNOT_CANCEL_IN_STATE` (422) et +`INVALID_TRANSITION` (409) pour l'annulation (`mlt.md` 7.1, `security-sequence.md`). Meme format +d'enveloppe. + +### 8.3 Divergence connue : repli JSON de la borne + +Le repli statique de la borne (`src/public/borne/data/categories.json`, `produits.json`) provient +des sources de l'ecole et porte un nommage different et heterogene (`title`/`nom`, `prix`, `image`, +`type`). Ce contrat est fige par le brief ecole et consomme tel quel par le JS de la borne via +`data.js`. + +La convention canonique reste celle de 8.1. Le rapprochement se fait en un point unique : la couche +`data.js` (bascule prevue en P4). Quand l'API exposera `/api/categories` et `/api/products`, elle +servira la forme canonique ; `data.js` mappera vers ce que la borne attend. + +| Repli borne | Canonique API / dictionnaire | +|---|---| +| `title` (categorie) | `name` | +| `nom` (produit) | `name` | +| `prix` | `price_cents` | +| `image` | `image_path` | +| `type` | `item_type` (`product` / `menu`) | + +--- + +## 9. Authentification et sessions + +- **Cookie de session** : `WAKDO_SID` (`SESSION_NAME`), attributs `secure`, `HttpOnly`, + `SameSite=Strict`. Bornes de validite appliquees cote application (idle 4h, absolue 10h), + pas par la duree du cookie. +- **Formulaires back-office** : jeton CSRF synchroniseur en champ cache `_csrf`, verifie sur chaque + POST (`/login`, `/logout`, `/forgot_password`, `/reset_password`). Jeton invalide -> `403`. +- **API REST** : endpoints kiosk de lecture catalogue et creation de commande publics (pas de + session ; `mlt.md` CREATE_ORDER). Endpoints d'administration sous `/api` (P3/P4) : session admin + + verification de permission via `role_permission` ; actions sensibles avec re-autorisation PIN + (`mlt.md` RG-T13). + +Le schema `ApiKey` / `Bearer` de l'API plateforme BYAN (`docs/api/byan-api.md`) ne s'applique pas +ici. + +--- + +## 10. CORS + +L'API admin sous `/api/*` autorise l'origine du kiosk via `CORS_ALLOWED_ORIGIN` (valeur exacte, +sans joker), configuree dans `docker/apache/vhost.conf`. L'origine doit correspondre a +`APP_URL_KIOSK`. + +--- + +## 11. Versionnement + +Demarrage sans segment de version (`/api/...`), ce qui correspond a une v1 implicite. En cas de +changement de contrat non retrocompatible, l'option retenue est un prefixe explicite `/api/v2/...` +introduit a ce moment-la, en gardant `/api/...` pour la v1 tant que des clients en dependent. + +--- + +## 12. Ou est defini quoi (recap code) + +| Element | Fichier | +|---|---| +| Declaration des routes | `src/public/admin/index.php` | +| Resolution / 404 / 405 | `src/app/Core/Router.php` | +| Enveloppe `data` / `error` / contenu JSON | `src/app/Core/Response.php` | +| Lecture de la requete (chemin, query, corps, IP) | `src/app/Core/Request.php` | +| Controleurs | `src/app/Controllers/` | +| Acces base (requetes preparees, transaction) | `src/app/Core/Database.php` | +| Noms de champs (source de verite) | `docs/merise/dictionary.md` | +| Operations metier et permissions | `docs/merise/mct.md`, `mlt.md`, `db/seeds/0001_rbac_and_reference.sql` | diff --git a/phpunit.xml b/phpunit.xml index a1f3799..9b2fbe7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,6 +17,10 @@ tests/Unit + + + tests/Integration + diff --git a/src/app/Auth/AuthResult.php b/src/app/Auth/AuthResult.php new file mode 100644 index 0000000..29c107a --- /dev/null +++ b/src/app/Auth/AuthResult.php @@ -0,0 +1,35 @@ + PRE-3 gate compte+IP -> RG-2 verify (leurre si miss). + * Succes : RG-3 regenerate + rotate CSRF, RG-4 session, RG-5/RG-9 reset+audit + * (une transaction), RG-7 redirection dynamique. Echec : RG-8 backoff degressif + * compte + upsert IP + audit (une transaction). Message d'echec unique (ERR-1/3). + */ + public function authenticate(string $email, string $password, string $ip, ?int $now = null): AuthResult + { + $now ??= time(); + $accountPolicy = ThrottlePolicy::fromConfig($this->config, 'account'); + $ipPolicy = ThrottlePolicy::fromConfig($this->config, 'ip'); + + // RG-1 : recherche systematique (hit ou miss) afin que le cout du SELECT + // soit paye dans les deux cas (limite l'oracle de timing par enumeration). + $user = $this->findActiveUserByEmail($email); + + // PRE-3 : porte de throttling AVANT toute verification de mot de passe. + $accountLockedUntil = $user !== null ? $this->stringOrNull($user['lockout_until'] ?? null) : null; + $accountLocked = $accountPolicy->isLockedUntil($accountLockedUntil, $now); + $ipLocked = $ipPolicy->isLockedUntil($this->ipLockoutUntil($ip), $now); + + if ($accountLocked || $ipLocked) { + // ERR-3 : meme message generique ; ne revele pas l'existence ni le verrou. + // Pas d'increment : le compteur tourne deja, le verrou est actif. + return AuthResult::failure(); + } + + // RG-2 : email inconnu -> verify leurre (timing) puis echec generique. + if ($user === null) { + $this->hasher->verifyDecoy($password); + $this->recordFailure(null, null, 0, $ip, $accountPolicy, $ipPolicy, $now); + + return AuthResult::failure(); + } + + $userId = (int) ($user['id'] ?? 0); + $roleId = (int) ($user['role_id'] ?? 0); + + if (!$this->hasher->verify($password, (string) ($user['password_hash'] ?? ''))) { + $attempts = (int) ($user['failed_login_attempts'] ?? 0); + $this->recordFailure($userId, $roleId, $attempts, $ip, $accountPolicy, $ipPolicy, $now); + + return AuthResult::failure(); + } + + // Succes : RG-3 (anti-fixation) d'abord (change l'ID, pas encore d'identite). + $this->session->regenerate(); + + // RG-5 + RG-9 : reset compteurs + clear IP + audit succes, une transaction. + // Fait AVANT de poser l'identite en session : si la base echoue, aucune + // session authentifiee ne subsiste (fail-closed, D9). + $this->recordSuccess($userId, $roleId, $ip, $now); + + // RG-4 : identite + horodatages pour les bornes idle/absolue (RG-6), + // puis rotation du jeton CSRF anterieur a l'authentification. + $this->session->set('user_id', $userId); + $this->session->set('role_id', $roleId); + $this->session->set('logged_in_at', $now); + $this->session->set('last_activity', $now); + Csrf::rotate($this->session); + + $routeRaw = $user['default_route'] ?? null; + $defaultRoute = is_string($routeRaw) && $routeRaw !== '' ? $routeRaw : '/'; + + return AuthResult::success($userId, $roleId, $defaultRoute); + } + + /** + * LOGOUT_USER (12.2) : efface puis detruit la session. Aucune I/O base. + */ + public function logout(): void + { + $this->session->clear(); + $this->session->destroy(); + } + + /** + * RG-1 : utilisateur actif par email, joint a son role pour la route de + * redirection dynamique (RG-7). Requete preparee (RG-T06). + * + * @return array|null + */ + private function findActiveUserByEmail(string $email): ?array + { + return $this->db->fetch( + 'SELECT u.id, u.password_hash, u.role_id, u.failed_login_attempts, u.lockout_until, r.default_route ' + . 'FROM user u JOIN role r ON r.id = u.role_id ' + . 'WHERE u.email = :email AND u.is_active = 1 LIMIT 1', + ['email' => $email], + ); + } + + private function ipLockoutUntil(string $ip): ?string + { + $row = $this->db->fetch( + 'SELECT lockout_until FROM login_throttle WHERE ip_address = :ip', + ['ip' => $ip], + ); + + return $row === null ? null : $this->stringOrNull($row['lockout_until'] ?? null); + } + + /** + * RG-8 : enregistre l'echec sur les deux dimensions (compte si connu + IP) + * et une ligne audit_log, le tout dans une seule transaction atomique (RG-T08). + */ + private function recordFailure( + ?int $userId, + ?int $roleId, + int $currentAttempts, + string $ip, + ThrottlePolicy $accountPolicy, + ThrottlePolicy $ipPolicy, + int $now, + ): void { + $nowDt = date('Y-m-d H:i:s', $now); + $windowSeconds = $this->config->int('IP_THROTTLE_WINDOW_SECONDS', 900); + + $windowCutoff = date('Y-m-d H:i:s', $now - $windowSeconds); + + $this->db->transaction(function (DatabaseInterface $db) use ( + $userId, + $roleId, + $currentAttempts, + $ip, + $accountPolicy, + $ipPolicy, + $now, + $nowDt, + $windowCutoff, + ): void { + // Dimension compte. Pour ne pas reveler par le timing si l'email existe + // (anti-enumeration, RG-2), on emet la MEME requete dans les deux cas : + // sur email inconnu, un UPDATE sur id = 0 (aucune ligne touchee car les + // PK user sont AUTO_INCREMENT >= 1), donc meme profil d'I/O, effet nul. + if ($userId !== null) { + $newAttempts = $currentAttempts + 1; + $lockSeconds = $accountPolicy->lockoutSeconds($newAttempts); + $lockUntil = $lockSeconds > 0 ? date('Y-m-d H:i:s', $now + $lockSeconds) : null; + + $db->execute( + 'UPDATE user SET failed_login_attempts = :attempts, last_failed_login_at = :now, ' + . 'lockout_until = :lock WHERE id = :id', + ['attempts' => $newAttempts, 'now' => $nowDt, 'lock' => $lockUntil, 'id' => $userId], + ); + } else { + $db->execute( + 'UPDATE user SET failed_login_attempts = :attempts, last_failed_login_at = :now, ' + . 'lockout_until = :lock WHERE id = :id', + ['attempts' => 0, 'now' => $nowDt, 'lock' => null, 'id' => 0], + ); + } + + // Dimension IP : increment ATOMIQUE cote SQL (failed_attempts + 1) pour + // eviter le lost-update sous concurrence ; la fenetre glissante est + // reinitialisee en SQL si elle a expire. Le verrou de ligne pris par + // l'upsert serialise les tentatives concurrentes sur la meme IP. + // Placeholders distincts : en prepare reelle (EMULATE_PREPARES = false) + // un meme nom ne peut pas etre lie plusieurs fois. + $db->execute( + 'INSERT INTO login_throttle (ip_address, failed_attempts, window_started_at, last_attempt_at) ' + . 'VALUES (:ip, 1, :now_i, :now_li) ' + . 'ON DUPLICATE KEY UPDATE ' + . 'failed_attempts = IF(window_started_at < :cutoff, 1, failed_attempts + 1), ' + . 'window_started_at = IF(window_started_at < :cutoff2, :now_w, window_started_at), ' + . 'last_attempt_at = :now_lu', + [ + 'ip' => $ip, + 'now_i' => $nowDt, + 'now_li' => $nowDt, + 'cutoff' => $windowCutoff, + 'cutoff2' => $windowCutoff, + 'now_w' => $nowDt, + 'now_lu' => $nowDt, + ], + ); + + // Relit le compteur post-increment (valeur autoritaire ecrite ci-dessus, + // ligne deja verrouillee par cette transaction) pour calculer le backoff + // IP en PHP via ThrottlePolicy, puis pose le verrou. + $row = $db->fetch('SELECT failed_attempts FROM login_throttle WHERE ip_address = :ip', ['ip' => $ip]); + $ipAttempts = (int) ($row['failed_attempts'] ?? 1); + $ipLockSeconds = $ipPolicy->lockoutSeconds($ipAttempts); + $ipLockUntil = $ipLockSeconds > 0 ? date('Y-m-d H:i:s', $now + $ipLockSeconds) : null; + + $db->execute( + 'UPDATE login_throttle SET lockout_until = :lock WHERE ip_address = :ip', + ['lock' => $ipLockUntil, 'ip' => $ip], + ); + + $this->writeAudit($db, 'auth.login_failed', $userId, $roleId, 'Echec de connexion'); + }); + } + + /** + * RG-9 : remise a zero du compteur compte + clear du throttle IP + audit du + * succes, une seule transaction (RG-T08). + */ + private function recordSuccess(int $userId, int $roleId, string $ip, int $now): void + { + $nowDt = date('Y-m-d H:i:s', $now); + + $this->db->transaction(function (DatabaseInterface $db) use ($userId, $roleId, $ip, $nowDt): void { + $db->execute( + 'UPDATE user SET failed_login_attempts = 0, lockout_until = NULL, last_login_at = :now WHERE id = :id', + ['now' => $nowDt, 'id' => $userId], + ); + + // Clear de la ligne IP : 0 ligne affectee si aucune n'existait (benin). + // Placeholders distincts (cf. recordFailure : prepare reelle, un nom + // ne peut etre lie qu'une fois). + $db->execute( + 'UPDATE login_throttle SET failed_attempts = 0, lockout_until = NULL, ' + . 'window_started_at = :now_w, last_attempt_at = :now_l WHERE ip_address = :ip', + ['now_w' => $nowDt, 'now_l' => $nowDt, 'ip' => $ip], + ); + + $this->writeAudit($db, 'auth.login_success', $userId, $roleId, 'Connexion reussie'); + }); + } + + /** + * RG-T14 : audit_log strictement en INSERT (jamais d'UPDATE/DELETE). summary + * non personnel ; details laisse NULL pour un evenement d'auth (aucune PII). + */ + private function writeAudit( + DatabaseInterface $db, + string $actionCode, + ?int $userId, + ?int $roleId, + string $summary, + ): void { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => $userId, + 'rid' => $roleId, + 'code' => $actionCode, + 'etype' => $userId !== null ? 'user' : null, + 'eid' => $userId, + 'summary' => $summary, + ], + ); + } + + private function stringOrNull(mixed $value): ?string + { + return is_string($value) ? $value : null; + } +} diff --git a/src/app/Auth/Csrf.php b/src/app/Auth/Csrf.php new file mode 100644 index 0000000..6b2c483 --- /dev/null +++ b/src/app/Auth/Csrf.php @@ -0,0 +1,60 @@ +get(self::KEY); + if (is_string($existing) && $existing !== '') { + return $existing; + } + + return self::rotate($session); + } + + /** + * Vrai uniquement si un jeton existe en session et egale (temps constant) le + * jeton soumis. Toute absence (pas de jeton, soumission vide) renvoie false. + */ + public static function validate(SessionManager $session, ?string $submitted): bool + { + $stored = $session->get(self::KEY); + + if (!is_string($stored) || $stored === '' || $submitted === null || $submitted === '') { + return false; + } + + return hash_equals($stored, $submitted); + } + + /** + * Re-genere le jeton (apres regeneration d'ID de session sur login reussi) : + * invalide tout jeton anterieur a l'authentification. + */ + public static function rotate(SessionManager $session): string + { + $token = bin2hex(random_bytes(32)); + $session->set(self::KEY, $token); + + return $token; + } +} diff --git a/src/app/Auth/GuardResult.php b/src/app/Auth/GuardResult.php new file mode 100644 index 0000000..faf58a0 --- /dev/null +++ b/src/app/Auth/GuardResult.php @@ -0,0 +1,22 @@ + logs du conteneur) pour pouvoir le + * recuperer en dev. Le lien contient le token brut, qui n'est jamais persiste. + */ +final class LogMailer implements Mailer +{ + public function sendPasswordReset(string $email, string $resetUrl): void + { + error_log(sprintf('[wakdo][password-reset] %s -> %s', $email, $resetUrl)); + } +} diff --git a/src/app/Auth/Mailer.php b/src/app/Auth/Mailer.php new file mode 100644 index 0000000..6a21a1d --- /dev/null +++ b/src/app/Auth/Mailer.php @@ -0,0 +1,16 @@ +options()); + } + + public function verify(string $plain, string $hash): bool + { + return password_verify($plain, $hash); + } + + /** + * Verifie le mot de passe soumis contre un leurre argon2id de meme cout, et + * jette le resultat. But : egaliser le temps CPU du chemin "email inconnu" + * avec celui du chemin "mauvais mot de passe", pour ne pas reveler par le + * timing si un compte existe (RG-2). Le leurre est calcule une fois par + * process sur un secret jetable ; il ne correspond a aucun mot de passe reel. + */ + public function verifyDecoy(string $plain): void + { + password_verify($plain, $this->decoyHash()); + } + + /** + * @return array{memory_cost: int, time_cost: int, threads: int} + */ + private function options(): array + { + // Defauts alignes sur .env.example / OWASP (64 MiB, 4 iterations, 1 thread). + return [ + 'memory_cost' => $this->config->int('ARGON2_MEMORY_COST', 65536), + 'time_cost' => $this->config->int('ARGON2_TIME_COST', 4), + 'threads' => $this->config->int('ARGON2_THREADS', 1), + ]; + } + + private function decoyHash(): string + { + // Cache statique par process : le hash argon2id du leurre est couteux et + // n'est calcule qu'une fois par worker, puis reutilise. Sans ce cache, + // comme le PasswordHasher est instancie a chaque requete, chaque tentative + // sur email inconnu paierait un password_hash supplementaire absent du + // chemin email connu -> ecart de timing reintroduisant l'oracle d'enumeration. + if (self::$decoy === null) { + self::$decoy = password_hash(bin2hex(random_bytes(16)), PASSWORD_ARGON2ID, $this->options()); + } + + return self::$decoy; + } +} diff --git a/src/app/Auth/PasswordResetService.php b/src/app/Auth/PasswordResetService.php new file mode 100644 index 0000000..46c81e7 --- /dev/null +++ b/src/app/Auth/PasswordResetService.php @@ -0,0 +1,127 @@ +db->fetch( + 'SELECT id FROM user WHERE email = :email AND is_active = 1 LIMIT 1', + ['email' => $email], + ); + + if ($user === null) { + return; + } + + $userId = (int) ($user['id'] ?? 0); + + // Token a haute entropie (256 bits). Stocke en SHA-256 : un hash rapide + // suffit (la robustesse vient de l'entropie, pas d'un KDF lent), et le + // brut n'est jamais persiste. Voir comment de confirmReset(). + $rawToken = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $rawToken); + $ttl = $this->config->int('PASSWORD_RESET_TTL', 3600); + $expiresAt = date('Y-m-d H:i:s', $now + $ttl); + + $this->db->execute( + 'UPDATE user SET password_reset_token_hash = :hash, password_reset_expires_at = :exp WHERE id = :id', + ['hash' => $tokenHash, 'exp' => $expiresAt, 'id' => $userId], + ); + + $resetUrl = rtrim($baseUrl, '/') . '/reset_password?token=' . $rawToken; + $this->mailer->sendPasswordReset($email, $resetUrl); + } + + /** + * Phase confirmation (RG-3/RG-4). Hash du token soumis, recherche par hash + + * expiration future (la recherche par egalite sur un token 256 bits EST la + * comparaison ; pas de souci de temps constant car ce n'est pas un secret a + * faible entropie et la colonne n'est jamais renvoyee au client). Min 8 + * caracteres, nouveau hash argon2id, token efface (usage unique), compteurs + * remis a zero, audit_log : le tout dans une transaction. + */ + public function confirmReset(string $rawToken, string $newPassword, ?int $now = null): AuthResult + { + $now ??= time(); + + if (strlen($newPassword) < 8) { + return AuthResult::failure('Le mot de passe doit contenir au moins 8 caracteres.'); + } + + if ($rawToken === '') { + return AuthResult::failure('Lien invalide ou expire.'); + } + + $tokenHash = hash('sha256', $rawToken); + $nowDt = date('Y-m-d H:i:s', $now); + + $user = $this->db->fetch( + 'SELECT id, role_id, password_reset_token_hash FROM user ' + . 'WHERE password_reset_token_hash = :hash AND password_reset_expires_at > :now ' + . 'AND is_active = 1 LIMIT 1', + ['hash' => $tokenHash, 'now' => $nowDt], + ); + + if ($user === null) { + return AuthResult::failure('Lien invalide ou expire.'); + } + + $userId = (int) ($user['id'] ?? 0); + $roleId = (int) ($user['role_id'] ?? 0); + $newHash = $this->hasher->hash($newPassword); + + $this->db->transaction(function (DatabaseInterface $db) use ($userId, $roleId, $newHash): void { + // Usage unique : on efface token + expiration et on remet les + // compteurs anti brute-force a zero (le compte redevient utilisable). + $db->execute( + 'UPDATE user SET password_hash = :hash, password_reset_token_hash = NULL, ' + . 'password_reset_expires_at = NULL, failed_login_attempts = 0, lockout_until = NULL ' + . 'WHERE id = :id', + ['hash' => $newHash, 'id' => $userId], + ); + + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => $userId, + 'rid' => $roleId, + 'code' => 'auth.password_reset', + 'etype' => 'user', + 'eid' => $userId, + 'summary' => 'Reinitialisation du mot de passe', + ], + ); + }); + + return AuthResult::success($userId, $roleId, '/login?reset=ok'); + } +} diff --git a/src/app/Auth/SessionGuard.php b/src/app/Auth/SessionGuard.php new file mode 100644 index 0000000..1f7f45b --- /dev/null +++ b/src/app/Auth/SessionGuard.php @@ -0,0 +1,67 @@ +session->getInt('user_id'); + $roleId = $this->session->getInt('role_id'); + $loggedInAt = $this->session->getInt('logged_in_at'); + $lastActivity = $this->session->getInt('last_activity'); + + if ($userId === null || $roleId === null || $loggedInAt === null) { + return new GuardResult(false, null, null, 'no_session'); + } + + $idleLimit = $this->config->int('SESSION_LIFETIME_IDLE', 14400); + $absoluteLimit = $this->config->int('SESSION_LIFETIME_ABSOLUTE', 36000); + + if ($lastActivity === null || ($now - $lastActivity) > $idleLimit) { + return new GuardResult(false, null, null, 'idle_timeout'); + } + + if (($now - $loggedInAt) > $absoluteLimit) { + return new GuardResult(false, null, null, 'absolute_timeout'); + } + + // RG-T02 : is_active re-verifie a chaque requete (un compte desactive en + // cours de session perd l'acces des la requete suivante). + $row = $this->db->fetch('SELECT is_active FROM user WHERE id = :id', ['id' => $userId]); + + if ($row === null || (int) ($row['is_active'] ?? 0) !== 1) { + return new GuardResult(false, null, null, 'inactive'); + } + + $this->session->set('last_activity', $now); + + return new GuardResult(true, $userId, $roleId, null); + } +} diff --git a/src/app/Auth/SessionManager.php b/src/app/Auth/SessionManager.php new file mode 100644 index 0000000..9ad2f9c --- /dev/null +++ b/src/app/Auth/SessionManager.php @@ -0,0 +1,172 @@ + */ + private array $bag = []; + + public function __construct( + private readonly Config $config, + private readonly bool $testMode = false, + ) { + } + + /** + * Demarre la session du vhost admin avec des cookies durcis. Idempotent : + * le front controller peut l'avoir deja demarree avant le dispatch. + */ + public function start(): void + { + if ($this->testMode) { + return; + } + + if (session_status() === PHP_SESSION_ACTIVE) { + return; + } + + // Defense : ne pas tenter de poser le cookie si la sortie a commence. + if (headers_sent()) { + return; + } + + // lifetime=0 : cookie de session ; les bornes idle 4h / absolue 10h sont + // appliquees applicativement par SessionGuard (RG-6), pas par le cookie. + // secure+httponly+SameSite=Strict : back-office, aucune entree cross-site. + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ]); + session_name($this->config->get('SESSION_NAME', 'WAKDO_SID') ?? 'WAKDO_SID'); + session_start(); + } + + /** + * Regenere l'identifiant de session (RG-3) : protege contre la fixation de + * session apres une authentification reussie. + */ + public function regenerate(): void + { + if ($this->testMode) { + return; + } + + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + } + + public function get(string $key): mixed + { + if ($this->testMode) { + return $this->bag[$key] ?? null; + } + + return $_SESSION[$key] ?? null; + } + + /** + * Accesseur type : evite qu'une valeur mixed de session ne file dans un + * parametre lie PDO ou un calcul d'entier (friction PHPStan L6). + * Les identifiants et timestamps stockes sont des entiers positifs. + */ + public function getInt(string $key): ?int + { + $value = $this->get($key); + + if (is_int($value)) { + return $value; + } + + if (is_string($value) && ctype_digit($value)) { + return (int) $value; + } + + return null; + } + + public function set(string $key, mixed $value): void + { + if ($this->testMode) { + $this->bag[$key] = $value; + + return; + } + + $_SESSION[$key] = $value; + } + + /** + * Efface les donnees de session (RG-1 de LOGOUT_USER). + */ + public function clear(): void + { + if ($this->testMode) { + $this->bag = []; + + return; + } + + $_SESSION = []; + } + + /** + * Expire le cookie de session cote client puis detruit la session serveur + * (RG-2 + RG-3 de LOGOUT_USER). Le cookie reprend les memes attributs durcis. + */ + public function destroy(): void + { + if ($this->testMode) { + $this->bag = []; + + return; + } + + if (ini_get('session.use_cookies') !== false) { + $name = session_name(); + if ($name !== false) { + setcookie($name, '', [ + 'expires' => time() - 3600, + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ]); + } + } + + if (session_status() === PHP_SESSION_ACTIVE) { + session_destroy(); + } + } + + public function id(): string + { + if ($this->testMode) { + return 'test-session'; + } + + $id = session_id(); + + return $id === false ? '' : $id; + } +} diff --git a/src/app/Auth/ThrottlePolicy.php b/src/app/Auth/ThrottlePolicy.php new file mode 100644 index 0000000..e513cb3 --- /dev/null +++ b/src/app/Auth/ThrottlePolicy.php @@ -0,0 +1,85 @@ +threshold) { + return 0; + } + + $exponent = $attempts - $this->threshold; + + // Garde anti-debordement : au-dela d'un exposant raisonnable, 2^exposant + // depasserait PHP_INT_MAX. Comme le resultat est de toute facon plafonne, + // on court-circuite des que la valeur ne peut que depasser le plafond. + if ($exponent >= 31) { + return $this->maxSeconds; + } + + $seconds = $this->baseSeconds * (2 ** $exponent); + + return (int) min($seconds, $this->maxSeconds); + } + + /** + * Vrai si le verrou ($lockoutUntil, datetime 'Y-m-d H:i:s' ou null) est + * strictement dans le futur a l'instant $now (timestamp Unix injecte pour + * des comparaisons deterministes en test). null/vide/illisible => pas de verrou. + */ + public function isLockedUntil(?string $lockoutUntil, int $now): bool + { + if ($lockoutUntil === null || $lockoutUntil === '') { + return false; + } + + $until = strtotime($lockoutUntil); + + return $until !== false && $until > $now; + } + + /** + * Construit la politique pour la dimension 'account' (par compte) ou 'ip' + * (par IP source). RG-8 precise "le meme backoff degressif" pour l'IP, donc + * la dimension IP reutilise base/max et prend IP_THROTTLE_MAX_ATTEMPTS comme seuil. + */ + public static function fromConfig(Config $config, string $dimension): self + { + $base = $config->int('ACCOUNT_LOCKOUT_BASE_SECONDS', 60); + $max = $config->int('ACCOUNT_LOCKOUT_MAX_SECONDS', 900); + + if ($dimension === 'ip') { + return new self($config->int('IP_THROTTLE_MAX_ATTEMPTS', 20), $base, $max); + } + + return new self($config->int('ACCOUNT_LOCKOUT_THRESHOLD', 5), $base, $max); + } +} diff --git a/src/app/Controllers/AuthController.php b/src/app/Controllers/AuthController.php new file mode 100644 index 0000000..6a7aee2 --- /dev/null +++ b/src/app/Controllers/AuthController.php @@ -0,0 +1,127 @@ + $params + */ + public function showLogin(array $params = []): Response + { + $notice = $this->request->query('reset') === 'ok' + ? 'Mot de passe reinitialise. Vous pouvez vous connecter.' + : null; + + return $this->renderLogin(null, $notice); + } + + /** + * @param array $params + */ + public function login(array $params = []): Response + { + $form = $this->request->formBody(); + + // PRE-2 / ERR-2 : jeton CSRF valide sinon 403, avant tout traitement. + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->renderLogin('Session expiree, merci de reessayer.', null, 403); + } + + // RG-T18 : validation et bornes de longueur cote serveur. + $email = trim($form['email'] ?? ''); + $password = $form['password'] ?? ''; + + if ($email === '' || $password === '' || strlen($email) > 254 || strlen($password) > 4096) { + return $this->renderLogin(self::GENERIC_ERROR); + } + + try { + $result = $this->authService()->authenticate($email, $password, $this->request->clientIp()); + } catch (Throwable $exception) { + // Fail-closed : une panne base ne doit jamais authentifier. On ne + // divulgue rien, on re-affiche le formulaire avec le message generique. + error_log('[wakdo][auth] login failure: ' . $exception->getMessage()); + + return $this->renderLogin(self::GENERIC_ERROR); + } + + if ($result->success && $result->redirectTo !== null) { + return $this->redirect($result->redirectTo); + } + + return $this->renderLogin($result->error ?? self::GENERIC_ERROR); + } + + /** + * @param array $params + */ + public function logout(array $params = []): Response + { + $form = $this->request->formBody(); + + // D11 : deconnexion en POST garde par CSRF (un GET forgeable pourrait + // deconnecter un poste en plein service). CSRF invalide -> 403, pas de destroy. + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } + + $this->authService()->logout(); + + return $this->redirect('/login'); + } + + protected function sessionManager(): SessionManager + { + return new SessionManager($this->config); + } + + protected function authService(): AuthService + { + return new AuthService( + $this->database, + $this->config, + $this->sessionManager(), + new PasswordHasher($this->config), + ); + } + + private function redirect(string $location, int $status = 302): Response + { + return Response::make('', $status, ['Location' => $location]); + } + + private function renderLogin(?string $error, ?string $notice = null, int $status = 200): Response + { + return $this->view('auth/login', [ + 'title' => 'Connexion - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'error' => $error, + 'notice' => $notice, + ], $status); + } +} diff --git a/src/app/Controllers/PasswordResetController.php b/src/app/Controllers/PasswordResetController.php new file mode 100644 index 0000000..efa0580 --- /dev/null +++ b/src/app/Controllers/PasswordResetController.php @@ -0,0 +1,150 @@ + $params + */ + public function showRequest(array $params = []): Response + { + return $this->view('auth/forgot', [ + 'title' => 'Mot de passe oublie - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'notice' => null, + ]); + } + + /** + * @param array $params + */ + public function submitRequest(array $params = []): Response + { + $form = $this->request->formBody(); + + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->view('auth/forgot', [ + 'title' => 'Mot de passe oublie - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'notice' => null, + ], 403); + } + + $email = trim($form['email'] ?? ''); + + // Reponse neutre quoi qu'il arrive (existence, validite, meme panne base). + if ($email !== '' && strlen($email) <= 254) { + try { + $this->resetService()->requestReset($email, $this->baseUrl()); + } catch (Throwable $exception) { + error_log('[wakdo][auth] reset request failure: ' . $exception->getMessage()); + } + } + + return $this->view('auth/forgot', [ + 'title' => 'Mot de passe oublie - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'notice' => self::NEUTRAL_NOTICE, + ]); + } + + /** + * @param array $params + */ + public function showConfirm(array $params = []): Response + { + return $this->renderConfirm($this->request->query('token') ?? '', null); + } + + /** + * @param array $params + */ + public function submitConfirm(array $params = []): Response + { + $form = $this->request->formBody(); + $token = $form['token'] ?? ''; + + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->renderConfirm($token, 'Session expiree, merci de reessayer.', 403); + } + + $password = $form['password'] ?? ''; + $confirm = $form['password_confirm'] ?? ''; + + if ($password !== $confirm) { + return $this->renderConfirm($token, 'Les mots de passe ne correspondent pas.'); + } + + try { + $result = $this->resetService()->confirmReset($token, $password); + } catch (Throwable $exception) { + error_log('[wakdo][auth] reset confirm failure: ' . $exception->getMessage()); + + return $this->renderConfirm($token, self::INVALID_LINK); + } + + if ($result->success && $result->redirectTo !== null) { + return $this->redirect($result->redirectTo); + } + + return $this->renderConfirm($token, $result->error ?? self::INVALID_LINK); + } + + protected function sessionManager(): SessionManager + { + return new SessionManager($this->config); + } + + protected function resetService(): PasswordResetService + { + return new PasswordResetService( + $this->database, + $this->config, + new PasswordHasher($this->config), + new LogMailer(), + ); + } + + private function baseUrl(): string + { + return $this->config->get('APP_URL_ADMIN', '') ?? ''; + } + + private function redirect(string $location, int $status = 302): Response + { + return Response::make('', $status, ['Location' => $location]); + } + + private function renderConfirm(string $token, ?string $error, int $status = 200): Response + { + return $this->view('auth/reset', [ + 'title' => 'Nouveau mot de passe - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'token' => $token, + 'error' => $error, + ], $status); + } +} diff --git a/src/app/Core/Database.php b/src/app/Core/Database.php index d149d5e..4ab5c67 100644 --- a/src/app/Core/Database.php +++ b/src/app/Core/Database.php @@ -6,6 +6,7 @@ namespace App\Core; use PDO; use PDOStatement; +use Throwable; /** * Enveloppe PDO MariaDB, requetes preparees exclusivement (anti-SQLi, Cr 4.e.1). @@ -14,7 +15,7 @@ use PDOStatement; * routes sans BDD (ex : la home back-office) fonctionnent meme si la base est * indisponible. */ -final class Database +final class Database implements DatabaseInterface { private ?PDO $pdo = null; @@ -91,4 +92,30 @@ final class Database { return $this->query($sql, $params)->rowCount(); } + + /** + * Execute $fn dans une transaction atomique (RG-T08) : begin -> $fn -> commit. + * Tout Throwable declenche un rollback complet puis est repropage : jamais + * d'ecriture partielle, jamais d'echec silencieux. Le retour est void (et non + * mixed) pour rester strictement type sous PHPStan ; $fn ecrit via le $this + * qui lui est passe (memes requetes preparees, meme connexion). + * + * @param callable(DatabaseInterface): void $fn + */ + public function transaction(callable $fn): void + { + $pdo = $this->pdo(); + $pdo->beginTransaction(); + + try { + $fn($this); + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } + } } diff --git a/src/app/Core/DatabaseInterface.php b/src/app/Core/DatabaseInterface.php new file mode 100644 index 0000000..b2b7710 --- /dev/null +++ b/src/app/Core/DatabaseInterface.php @@ -0,0 +1,43 @@ + $params + * @return array|null + */ + public function fetch(string $sql, array $params = []): ?array; + + /** + * @param array $params + * @return array> + */ + public function fetchAll(string $sql, array $params = []): array; + + /** + * @param array $params + */ + public function execute(string $sql, array $params = []): int; + + /** + * Execute $fn dans une transaction atomique : commit si succes, rollback + * complet sur tout Throwable (puis repropagation). + * + * @param callable(DatabaseInterface): void $fn + */ + public function transaction(callable $fn): void; +} diff --git a/src/app/Core/Request.php b/src/app/Core/Request.php index 0e124b9..eb5e1cf 100644 --- a/src/app/Core/Request.php +++ b/src/app/Core/Request.php @@ -22,6 +22,10 @@ final class Request private readonly array $query, private readonly array $headers, private readonly string $rawBody, + // Adresse de la connexion TCP entrante (le proxy Traefik en frontal). + // Defaut vide pour conserver la compatibilite des appels a 5 arguments + // (tests existants). clientIp() s'en sert comme repli derriere X-Forwarded-For. + private readonly string $remoteAddr = '', ) { } @@ -44,6 +48,7 @@ final class Request $query, self::extractHeaders(), (string) file_get_contents('php://input'), + (string) ($_SERVER['REMOTE_ADDR'] ?? ''), ); } @@ -142,4 +147,68 @@ final class Request return is_array($decoded) ? $decoded : []; } + + /** + * Decode un corps application/x-www-form-urlencoded en map cle => valeur. + * Symetrique de json() : renvoie [] si le content-type n'est pas un + * formulaire urlencode, pour laisser la validation metier decider (pas de + * fatale ici). Le back-office se connecte par formulaire POST, pas par JSON. + * + * @return array + */ + public function formBody(): array + { + $contentType = $this->header('content-type') ?? ''; + + if (!str_starts_with($contentType, 'application/x-www-form-urlencoded')) { + return []; + } + + parse_str($this->rawBody, $parsed); + + // parse_str peut produire des valeurs tableau (cle[]=...) ; on ne retient + // que les scalaires convertis en chaine pour tenir le contrat strict + // array (et neutraliser une cle de type "champ[]"). + $form = []; + foreach ($parsed as $key => $value) { + if (is_scalar($value)) { + $form[(string) $key] = (string) $value; + } + } + + return $form; + } + + /** + * IP client reelle derriere le reverse proxy Traefik. REMOTE_ADDR est ici + * toujours l'adresse du proxy, donc on lit X-Forwarded-For et on retient le + * DERNIER hop : c'est celui ajoute par Traefik (proxy de confiance), tandis + * que les entrees de gauche sont fournies par le client et donc falsifiables. + * La valeur est validee par FILTER_VALIDATE_IP et bornee a 45 caracteres + * (taille de login_throttle.ip_address). Repli sur REMOTE_ADDR si l'en-tete + * est absent ou invalide ; sentinelle 0.0.0.0 en dernier recours. + * + * Hypothese de deploiement : un unique proxy de confiance (Traefik) est + * toujours en frontal. Sans lui, X-Forwarded-For serait falsifiable ; le + * verrou par compte (failed_login_attempts) reste alors le garde-fou. + */ + public function clientIp(): string + { + $forwarded = $this->header('x-forwarded-for'); + + if ($forwarded !== null && $forwarded !== '') { + $hops = explode(',', $forwarded); + $candidate = trim((string) end($hops)); + + if (filter_var($candidate, FILTER_VALIDATE_IP) !== false) { + return substr($candidate, 0, 45); + } + } + + if ($this->remoteAddr !== '' && filter_var($this->remoteAddr, FILTER_VALIDATE_IP) !== false) { + return substr($this->remoteAddr, 0, 45); + } + + return '0.0.0.0'; + } } diff --git a/src/app/Core/Response.php b/src/app/Core/Response.php index 294e370..49a11ea 100644 --- a/src/app/Core/Response.php +++ b/src/app/Core/Response.php @@ -47,6 +47,24 @@ final class Response return $this; } + public function body(): string + { + return $this->body; + } + + public function header(string $name): ?string + { + return $this->headers[$name] ?? null; + } + + /** + * @return array + */ + public function headers(): array + { + return $this->headers; + } + /** * @param array $headers */ diff --git a/src/app/Views/auth/forgot.php b/src/app/Views/auth/forgot.php new file mode 100644 index 0000000..fde146b --- /dev/null +++ b/src/app/Views/auth/forgot.php @@ -0,0 +1,35 @@ + +
+

Mot de passe oublie

+ + +

+ + +
+ + +
+ + +
+ + +
+ +

Retour a la connexion

+
diff --git a/src/app/Views/auth/login.php b/src/app/Views/auth/login.php new file mode 100644 index 0000000..187b1d3 --- /dev/null +++ b/src/app/Views/auth/login.php @@ -0,0 +1,47 @@ + +
+

Wakdo Admin

+

Back-office de gestion

+ + +

+ + + +

+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ +

Mot de passe oublie ?

+
diff --git a/src/app/Views/auth/reset.php b/src/app/Views/auth/reset.php new file mode 100644 index 0000000..e8b8b41 --- /dev/null +++ b/src/app/Views/auth/reset.php @@ -0,0 +1,43 @@ + +
+

Nouveau mot de passe

+ + +

+ + +
+ + + +
+ + +
+ +
+ + +
+ + +
+ +

Retour a la connexion

+
diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 50279f4..dd44d61 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -10,8 +10,11 @@ declare(strict_types=1); * "/", "/api/health", etc. */ +use App\Auth\SessionManager; +use App\Controllers\AuthController; use App\Controllers\HealthController; use App\Controllers\HomeController; +use App\Controllers\PasswordResetController; use App\Core\Autoloader; use App\Core\Config; use App\Core\Database; @@ -36,10 +39,24 @@ try { // donc la home back-office reste servie meme base indisponible. $database = new Database($config); + // Demarre la session du vhost admin avant le dispatch (effet de bord global, + // hors du Core stateless). Les controleurs y rattachent leur SessionManager. + (new SessionManager($config))->start(); + $router = new Router($config, $database); $router->add('GET', '/', [HomeController::class, 'index']); $router->add('GET', '/api/health', [HealthController::class, 'index']); + // Authentification back-office (mlt.md section 12). Le docroot du vhost admin + // etant src/public/admin, le Router voit "/login" (pas de prefixe "/admin"). + $router->add('GET', '/login', [AuthController::class, 'showLogin']); + $router->add('POST', '/login', [AuthController::class, 'login']); + $router->add('POST', '/logout', [AuthController::class, 'logout']); + $router->add('GET', '/forgot_password', [PasswordResetController::class, 'showRequest']); + $router->add('POST', '/forgot_password', [PasswordResetController::class, 'submitRequest']); + $router->add('GET', '/reset_password', [PasswordResetController::class, 'showConfirm']); + $router->add('POST', '/reset_password', [PasswordResetController::class, 'submitConfirm']); + $response = $router->dispatch(Request::fromGlobals()); $response->send(); } catch (Throwable $exception) { diff --git a/tests/Integration/AuthServiceDbTest.php b/tests/Integration/AuthServiceDbTest.php new file mode 100644 index 0000000..e726ea3 --- /dev/null +++ b/tests/Integration/AuthServiceDbTest.php @@ -0,0 +1,189 @@ +config = new Config(); + $this->db = new Database($this->config); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base de donnees injoignable: ' . $exception->getMessage()); + } + + $this->cleanupThrottle(); + $this->userId = $this->createDisposableUser(); + } + + protected function tearDown(): void + { + if ($this->userId === 0) { + return; + } + + // Ordre compatible FK : audit (actor SET NULL mais on retire nos lignes), + // throttle (par IP), puis l'utilisateur jetable. + $this->db->execute('DELETE FROM audit_log WHERE actor_user_id = :id', ['id' => $this->userId]); + $this->cleanupThrottle(); + $this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]); + $this->userId = 0; + } + + private function service(): AuthService + { + return new AuthService( + $this->db, + $this->config, + new SessionManager($this->config, true), + new PasswordHasher($this->config), + ); + } + + public function testSuccessfulLoginPersistsResetCountersAndAuditSuccess(): void + { + $result = $this->service()->authenticate($this->email(), self::PASSWORD, self::TEST_IP); + + self::assertTrue($result->success); + + $user = $this->db->fetch( + 'SELECT failed_login_attempts, lockout_until, last_login_at FROM user WHERE id = :id', + ['id' => $this->userId], + ); + self::assertNotNull($user); + self::assertSame(0, (int) ($user['failed_login_attempts'] ?? -1)); + self::assertNull($user['lockout_until']); + self::assertNotNull($user['last_login_at']); + + self::assertSame('auth.login_success', $this->lastAuditAction()); + } + + public function testFailedLoginIncrementsAccountAndCreatesThrottleAndAuditFailure(): void + { + $result = $this->service()->authenticate($this->email(), 'WRONG-PASSWORD', self::TEST_IP); + + self::assertFalse($result->success); + + $user = $this->db->fetch( + 'SELECT failed_login_attempts FROM user WHERE id = :id', + ['id' => $this->userId], + ); + self::assertNotNull($user); + self::assertSame(1, (int) ($user['failed_login_attempts'] ?? -1)); + + $throttle = $this->db->fetch( + 'SELECT failed_attempts FROM login_throttle WHERE ip_address = :ip', + ['ip' => self::TEST_IP], + ); + self::assertNotNull($throttle); + self::assertSame(1, (int) ($throttle['failed_attempts'] ?? -1)); + + self::assertSame('auth.login_failed', $this->lastAuditAction()); + } + + public function testThrottleGateRejectsWhenAccountLocked(): void + { + // Pose un verrou compte dans le futur, puis tente avec le BON mot de passe : + // la porte PRE-3 doit refuser avant toute verification. + $future = date('Y-m-d H:i:s', time() + 600); + $this->db->execute( + 'UPDATE user SET lockout_until = :lock WHERE id = :id', + ['lock' => $future, 'id' => $this->userId], + ); + + $result = $this->service()->authenticate($this->email(), self::PASSWORD, self::TEST_IP); + + self::assertFalse($result->success); + // last_login_at reste nul : aucune authentification n'a abouti. + $user = $this->db->fetch('SELECT last_login_at FROM user WHERE id = :id', ['id' => $this->userId]); + self::assertNotNull($user); + self::assertNull($user['last_login_at']); + } + + private function email(): string + { + return 'it-auth-' . $this->userId . '@wakdo.invalid'; + } + + private function createDisposableUser(): int + { + $roleRow = $this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1'); + $roleId = (int) ($roleRow['id'] ?? 0); + self::assertGreaterThan(0, $roleId, 'aucun role seede: migration/seed requis'); + + $hash = (new PasswordHasher($this->config))->hash(self::PASSWORD); + // Email provisoire pour obtenir l'id, puis on le rend unique par id. + $this->db->execute( + 'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) ' + . 'VALUES (:email, :hash, :fn, :ln, :role, 1)', + [ + 'email' => 'it-auth-pending-' . bin2hex(random_bytes(6)) . '@wakdo.invalid', + 'hash' => $hash, + 'fn' => 'Integration', + 'ln' => 'Test', + 'role' => $roleId, + ], + ); + + $row = $this->db->fetch('SELECT LAST_INSERT_ID() AS id'); + $id = (int) ($row['id'] ?? 0); + + $this->db->execute( + 'UPDATE user SET email = :email WHERE id = :id', + ['email' => 'it-auth-' . $id . '@wakdo.invalid', 'id' => $id], + ); + + return $id; + } + + private function cleanupThrottle(): void + { + $this->db->execute('DELETE FROM login_throttle WHERE ip_address = :ip', ['ip' => self::TEST_IP]); + } + + private function lastAuditAction(): ?string + { + $row = $this->db->fetch( + 'SELECT action_code FROM audit_log WHERE actor_user_id = :id ORDER BY id DESC LIMIT 1', + ['id' => $this->userId], + ); + + return $row === null ? null : (string) ($row['action_code'] ?? ''); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php new file mode 100644 index 0000000..3009c38 --- /dev/null +++ b/tests/Support/FakeDatabase.php @@ -0,0 +1,155 @@ +|null + */ + public ?array $userRow = null; + + /** lockout_until renvoye pour la porte de throttling IP ; null = pas de verrou. */ + public ?string $ipLockoutUntil = null; + + /** + * Compteur login_throttle relu apres l'upsert atomique (sert au calcul du + * backoff IP en PHP) ; null => 1 par defaut cote service. + * + * @var array|null + */ + public ?array $throttleRow = null; + + /** + * Reponse de la recherche par token de reinitialisation (12.3) ; null = aucun. + * + * @var array|null + */ + public ?array $resetUserRow = null; + + /** + * Reponse de la recherche par email (phase demande de reinitialisation) ; null = inconnu. + * + * @var array|null + */ + public ?array $emailLookupRow = null; + + /** + * Reponse de la verification is_active du SessionGuard (RG-T02) ; null = absent. + * + * @var array|null + */ + public ?array $guardUserRow = null; + + /** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */ + public ?RuntimeException $failOnExecute = null; + + /** @var list}> */ + public array $writes = []; + + /** @var list */ + public array $transactionEvents = []; + + public function fetch(string $sql, array $params = []): ?array + { + if (str_contains($sql, 'FROM user u JOIN role')) { + return $this->userRow; + } + + if (str_contains($sql, 'password_reset_token_hash')) { + return $this->resetUserRow; + } + + if (str_contains($sql, 'SELECT id FROM user WHERE email')) { + return $this->emailLookupRow; + } + + if (str_contains($sql, 'SELECT is_active FROM user WHERE id')) { + return $this->guardUserRow; + } + + if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) { + return ['lockout_until' => $this->ipLockoutUntil]; + } + + if (str_contains($sql, 'SELECT failed_attempts FROM login_throttle')) { + return $this->throttleRow; + } + + return null; + } + + public function fetchAll(string $sql, array $params = []): array + { + return []; + } + + public function execute(string $sql, array $params = []): int + { + if ($this->failOnExecute !== null) { + throw $this->failOnExecute; + } + + $this->writes[] = ['sql' => $sql, 'params' => $params]; + + return 1; + } + + public function transaction(callable $fn): void + { + $this->transactionEvents[] = 'begin'; + + try { + $fn($this); + $this->transactionEvents[] = 'commit'; + } catch (\Throwable $exception) { + $this->transactionEvents[] = 'rollback'; + + throw $exception; + } + } + + public function wrote(string $needle): bool + { + foreach ($this->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return true; + } + } + + return false; + } + + /** + * Codes d'action audit_log inseres (dans l'ordre). + * + * @return list + */ + public function auditActions(): array + { + $codes = []; + + foreach ($this->writes as $write) { + if (str_contains($write['sql'], 'INSERT INTO audit_log')) { + $code = $write['params']['code'] ?? null; + $codes[] = is_string($code) ? $code : ''; + } + } + + return $codes; + } +} diff --git a/tests/Support/SpyMailer.php b/tests/Support/SpyMailer.php new file mode 100644 index 0000000..ed981a2 --- /dev/null +++ b/tests/Support/SpyMailer.php @@ -0,0 +1,22 @@ + */ + public array $sent = []; + + public function sendPasswordReset(string $email, string $resetUrl): void + { + $this->sent[] = ['email' => $email, 'resetUrl' => $resetUrl]; + } +} diff --git a/tests/Unit/Auth/AuthControllerTest.php b/tests/Unit/Auth/AuthControllerTest.php new file mode 100644 index 0000000..b62ccd0 --- /dev/null +++ b/tests/Unit/Auth/AuthControllerTest.php @@ -0,0 +1,197 @@ +testSession; + } + + protected function authService(): AuthService + { + return new AuthService($this->fakeDb, $this->config, $this->testSession, new PasswordHasher($this->config)); + } +} + +final class AuthControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + protected function setUp(): void + { + $this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '5'); + $this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60'); + $this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900'); + $this->setEnv('IP_THROTTLE_MAX_ATTEMPTS', '20'); + $this->setEnv('IP_THROTTLE_WINDOW_SECONDS', '900'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + /** + * @param array $form + */ + private function postRequest(array $form, string $path = '/login'): Request + { + return new Request( + 'POST', + $path, + [], + ['content-type' => 'application/x-www-form-urlencoded'], + http_build_query($form), + '203.0.113.5', + ); + } + + private function getRequest(string $path = '/login'): Request + { + return new Request('GET', $path, [], [], '', '203.0.113.5'); + } + + private function controller(Request $request, SessionManager $session, FakeDatabase $db): TestAuthController + { + return new TestAuthController($request, new Config(), new Database(new Config()), $session, $db); + } + + /** + * @param array $overrides + * @return array + */ + private function userRow(string $password, array $overrides = []): array + { + return array_merge([ + 'id' => 7, + 'role_id' => 3, + 'password_hash' => (new PasswordHasher(new Config()))->hash($password), + 'failed_login_attempts' => 0, + 'lockout_until' => null, + 'default_route' => '/admin/dashboard', + ], $overrides); + } + + public function testShowLoginRendersCsrfField(): void + { + $session = new SessionManager(new Config(), true); + $response = $this->controller($this->getRequest(), $session, new FakeDatabase())->showLogin(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('name="_csrf"', $response->body()); + } + + public function testLoginRejectsInvalidCsrfWith403(): void + { + $session = new SessionManager(new Config(), true); + Csrf::token($session); + $db = new FakeDatabase(); + + $request = $this->postRequest(['_csrf' => 'wrong', 'email' => 'admin@wakdo.local', 'password' => 'x']); + $response = $this->controller($request, $session, $db)->login(); + + self::assertSame(403, $response->status()); + // L'authentification n'a pas tourne : aucune ecriture base. + self::assertSame([], $db->writes); + } + + public function testLoginBadCredentialsRendersGenericErrorWithoutRedirect(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + $db = new FakeDatabase(); + $db->userRow = $this->userRow('right-password'); + + $request = $this->postRequest(['_csrf' => $token, 'email' => 'admin@wakdo.local', 'password' => 'WRONG']); + $response = $this->controller($request, $session, $db)->login(); + + self::assertSame(200, $response->status()); + self::assertNull($response->header('Location')); + self::assertStringContainsString('Email ou mot de passe incorrect', $response->body()); + } + + public function testLoginSuccessRedirectsToDefaultRoute(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + $db = new FakeDatabase(); + $db->userRow = $this->userRow('correct-password'); + + $request = $this->postRequest(['_csrf' => $token, 'email' => 'admin@wakdo.local', 'password' => 'correct-password']); + $response = $this->controller($request, $session, $db)->login(); + + self::assertSame(302, $response->status()); + self::assertSame('/admin/dashboard', $response->header('Location')); + self::assertSame(7, $session->getInt('user_id')); + } + + public function testLogoutRequiresValidCsrf(): void + { + $session = new SessionManager(new Config(), true); + Csrf::token($session); + $session->set('user_id', 7); + + $request = $this->postRequest(['_csrf' => 'wrong'], '/logout'); + $response = $this->controller($request, $session, new FakeDatabase())->logout(); + + self::assertSame(403, $response->status()); + // Session intacte : la deconnexion forgee est refusee. + self::assertSame(7, $session->getInt('user_id')); + } + + public function testLogoutWithValidCsrfClearsSessionAndRedirects(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + $session->set('user_id', 7); + + $request = $this->postRequest(['_csrf' => $token], '/logout'); + $response = $this->controller($request, $session, new FakeDatabase())->logout(); + + self::assertSame(302, $response->status()); + self::assertSame('/login', $response->header('Location')); + self::assertNull($session->getInt('user_id')); + } +} diff --git a/tests/Unit/Auth/AuthServiceTest.php b/tests/Unit/Auth/AuthServiceTest.php new file mode 100644 index 0000000..3f60412 --- /dev/null +++ b/tests/Unit/Auth/AuthServiceTest.php @@ -0,0 +1,315 @@ + */ + private array $touchedKeys = []; + + private FakeDatabase $db; + private SessionManager $session; + private PasswordHasher $hasher; + + protected function setUp(): void + { + // Politique de throttling deterministe + argon2id a cout reduit. + $this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '5'); + $this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60'); + $this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900'); + $this->setEnv('IP_THROTTLE_MAX_ATTEMPTS', '20'); + $this->setEnv('IP_THROTTLE_WINDOW_SECONDS', '900'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->db = new FakeDatabase(); + $this->session = new SessionManager(new Config(), true); + $this->hasher = new PasswordHasher(new Config()); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function service(): AuthService + { + return new AuthService($this->db, new Config(), $this->session, $this->hasher); + } + + /** + * @param array $overrides + * @return array + */ + private function userRow(array $overrides = []): array + { + return array_merge([ + 'id' => 7, + 'password_hash' => $this->hasher->hash('correct horse'), + 'role_id' => 3, + 'failed_login_attempts' => 0, + 'lockout_until' => null, + 'default_route' => '/admin/dashboard', + ], $overrides); + } + + public function testUnknownEmailFailsAndRecordsIpFailure(): void + { + $this->db->userRow = null; + + $result = $this->service()->authenticate('ghost@wakdo.local', 'whatever', '203.0.113.1', self::NOW); + + self::assertFalse($result->success); + self::assertSame('Email ou mot de passe incorrect', $result->error); + self::assertNull($this->session->getInt('user_id')); + self::assertTrue($this->db->wrote('INSERT INTO login_throttle')); + self::assertSame(['auth.login_failed'], $this->db->auditActions()); + self::assertSame(['begin', 'commit'], $this->db->transactionEvents); + // Anti-enumeration : meme profil d'I/O que le chemin email connu, via un + // UPDATE user no-op sur id = 0 (ne touche aucune ligne, ne revele rien). + self::assertTrue($this->db->wrote('UPDATE user SET failed_login_attempts')); + self::assertSame(0, $this->firstWrite('UPDATE user SET failed_login_attempts')['params']['id'] ?? null); + } + + public function testFailureWriteProfileIsIdenticalForKnownAndUnknownEmail(): void + { + // Email inconnu. + $this->db->userRow = null; + $this->service()->authenticate('ghost@wakdo.local', 'whatever', '203.0.113.9', self::NOW); + $unknownWrites = count($this->db->writes); + + // Email connu, mauvais mot de passe (instances neuves pour isoler le compteur). + $db2 = new FakeDatabase(); + $db2->userRow = $this->userRow(); + $service2 = new AuthService($db2, new Config(), new SessionManager(new Config(), true), $this->hasher); + $service2->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.9', self::NOW); + $knownWrites = count($db2->writes); + + self::assertSame($knownWrites, $unknownWrites, 'meme nombre d ecritures (anti-enumeration)'); + } + + public function testAccountLockedIsRejectedBeforeAnyWrite(): void + { + // lockout_until dans le futur : porte PRE-3, aucun increment ni ecriture. + $this->db->userRow = $this->userRow([ + 'lockout_until' => date('Y-m-d H:i:s', self::NOW + 120), + ]); + + $result = $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW); + + self::assertFalse($result->success); + self::assertSame([], $this->db->writes); + self::assertSame([], $this->db->transactionEvents); + self::assertNull($this->session->getInt('user_id')); + } + + public function testIpLockedIsRejectedBeforeAnyWrite(): void + { + $this->db->userRow = $this->userRow(); + $this->db->ipLockoutUntil = date('Y-m-d H:i:s', self::NOW + 300); + + $result = $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW); + + self::assertFalse($result->success); + self::assertSame([], $this->db->writes); + self::assertNull($this->session->getInt('user_id')); + } + + public function testWrongPasswordRecordsAccountAndIpFailure(): void + { + $this->db->userRow = $this->userRow(['failed_login_attempts' => 0]); + + $result = $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + self::assertFalse($result->success); + self::assertTrue($this->db->wrote('UPDATE user SET failed_login_attempts')); + self::assertTrue($this->db->wrote('INSERT INTO login_throttle')); + self::assertSame(['auth.login_failed'], $this->db->auditActions()); + self::assertSame(['begin', 'commit'], $this->db->transactionEvents); + self::assertNull($this->session->getInt('user_id')); + } + + public function testWrongPasswordSetsLockoutOnceThresholdReached(): void + { + // 4 echecs deja enregistres : le 5e (= seuil) doit poser un lockout_until. + $this->db->userRow = $this->userRow(['failed_login_attempts' => 4]); + + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + $userUpdate = $this->firstWrite('UPDATE user SET failed_login_attempts'); + self::assertSame(5, $userUpdate['params']['attempts'] ?? null); + self::assertSame(date('Y-m-d H:i:s', self::NOW + 60), $userUpdate['params']['lock'] ?? null); + } + + public function testWrongPasswordBelowThresholdLeavesLockoutNull(): void + { + $this->db->userRow = $this->userRow(['failed_login_attempts' => 0]); + + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + $userUpdate = $this->firstWrite('UPDATE user SET failed_login_attempts'); + self::assertSame(1, $userUpdate['params']['attempts'] ?? null); + self::assertArrayHasKey('lock', $userUpdate['params']); + self::assertNull($userUpdate['params']['lock']); + } + + public function testIpUpsertUsesAtomicIncrementAndSqlWindowReset(): void + { + $this->db->userRow = $this->userRow(['failed_login_attempts' => 0]); + + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + $upsert = $this->firstWrite('INSERT INTO login_throttle'); + // Increment atomique cote SQL (pas un literal PHP) -> immunise au lost-update. + self::assertStringContainsString('failed_attempts + 1', $upsert['sql']); + // Reset de fenetre decide en SQL, borne stricte sur window_started_at. + self::assertStringContainsString('IF(window_started_at < :cutoff', $upsert['sql']); + } + + public function testIpThrottleSetsLockWhenThresholdReached(): void + { + // La relecture post-upsert renvoie 20 (= IP_THROTTLE_MAX_ATTEMPTS) : verrou pose. + $this->db->userRow = $this->userRow(['failed_login_attempts' => 0]); + $this->db->throttleRow = ['failed_attempts' => 20]; + + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + $lockWrite = $this->firstWrite('UPDATE login_throttle SET lockout_until = :lock'); + self::assertSame(date('Y-m-d H:i:s', self::NOW + 60), $lockWrite['params']['lock'] ?? null); + } + + public function testIpThrottleLeavesLockNullBelowThreshold(): void + { + $this->db->userRow = $this->userRow(['failed_login_attempts' => 0]); + $this->db->throttleRow = ['failed_attempts' => 3]; + + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + + $lockWrite = $this->firstWrite('UPDATE login_throttle SET lockout_until = :lock'); + self::assertArrayHasKey('lock', $lockWrite['params']); + self::assertNull($lockWrite['params']['lock']); + } + + public function testCorrectCredentialsSucceedAndOpenSession(): void + { + $this->db->userRow = $this->userRow(); + + $result = $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW); + + self::assertTrue($result->success); + self::assertSame(7, $result->userId); + self::assertSame(3, $result->roleId); + self::assertSame('/admin/dashboard', $result->redirectTo); + + self::assertSame(7, $this->session->getInt('user_id')); + self::assertSame(3, $this->session->getInt('role_id')); + self::assertSame(self::NOW, $this->session->getInt('logged_in_at')); + self::assertSame(self::NOW, $this->session->getInt('last_activity')); + + // RG-5/RG-9 : reset compteur + clear throttle + audit succes, 1 transaction. + self::assertTrue($this->db->wrote('UPDATE user SET failed_login_attempts = 0')); + self::assertTrue($this->db->wrote('UPDATE login_throttle SET failed_attempts = 0')); + self::assertSame(['auth.login_success'], $this->db->auditActions()); + self::assertSame(['begin', 'commit'], $this->db->transactionEvents); + + // RG-5 : last_login_at pose a l'instant fige (assertion explicite, pas + // seulement le prefixe de la requete). + self::assertSame(date('Y-m-d H:i:s', self::NOW), $this->firstWrite('last_login_at')['params']['now'] ?? null); + } + + public function testSuccessRotatesCsrfToken(): void + { + $this->db->userRow = $this->userRow(); + $before = Csrf::token($this->session); + + $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW); + + self::assertFalse(Csrf::validate($this->session, $before)); + } + + public function testFailClosedWhenDatabaseThrowsOnFailurePath(): void + { + $this->db->userRow = $this->userRow(); + $this->db->failOnExecute = new RuntimeException('db down'); + + $threw = false; + try { + $this->service()->authenticate('admin@wakdo.local', 'WRONG', '203.0.113.1', self::NOW); + } catch (RuntimeException) { + $threw = true; + } + + self::assertTrue($threw, 'une panne DB doit remonter, pas etre avalee'); + self::assertSame(['begin', 'rollback'], $this->db->transactionEvents); + self::assertNull($this->session->getInt('user_id')); + } + + public function testFailClosedOnSuccessPathDoesNotOpenSession(): void + { + // Mot de passe correct mais la base echoue pendant recordSuccess : + // l'identite ne doit jamais etre posee en session (ecriture avant identite). + $this->db->userRow = $this->userRow(); + $this->db->failOnExecute = new RuntimeException('db down'); + + $threw = false; + try { + $this->service()->authenticate('admin@wakdo.local', 'correct horse', '203.0.113.1', self::NOW); + } catch (RuntimeException) { + $threw = true; + } + + self::assertTrue($threw); + self::assertNull($this->session->getInt('user_id')); + } + + public function testLogoutClearsSession(): void + { + $this->session->set('user_id', 7); + + $this->service()->logout(); + + self::assertNull($this->session->getInt('user_id')); + } + + /** + * @return array{sql: string, params: array} + */ + private function firstWrite(string $needle): array + { + foreach ($this->db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write; + } + } + + self::fail('aucune ecriture ne contient: ' . $needle); + } +} diff --git a/tests/Unit/Auth/CsrfTest.php b/tests/Unit/Auth/CsrfTest.php new file mode 100644 index 0000000..7f7693a --- /dev/null +++ b/tests/Unit/Auth/CsrfTest.php @@ -0,0 +1,74 @@ +session()); + + // 32 octets CSPRNG en hexadecimal => 64 caracteres. + self::assertSame(64, strlen($token)); + self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $token); + } + + public function testTokenIsStableAcrossCalls(): void + { + $session = $this->session(); + + self::assertSame(Csrf::token($session), Csrf::token($session)); + } + + public function testValidateAcceptsCorrectToken(): void + { + $session = $this->session(); + $token = Csrf::token($session); + + self::assertTrue(Csrf::validate($session, $token)); + } + + public function testValidateRejectsWrongOrEmptyToken(): void + { + $session = $this->session(); + Csrf::token($session); + + self::assertFalse(Csrf::validate($session, 'wrong')); + self::assertFalse(Csrf::validate($session, '')); + self::assertFalse(Csrf::validate($session, null)); + } + + public function testValidateFalseWhenNoTokenYet(): void + { + // Aucun token genere en session : meme une soumission non vide echoue. + self::assertFalse(Csrf::validate($this->session(), 'anything')); + } + + public function testRotateChangesTokenAndInvalidatesOld(): void + { + $session = $this->session(); + $old = Csrf::token($session); + + $new = Csrf::rotate($session); + + self::assertNotSame($old, $new); + self::assertFalse(Csrf::validate($session, $old)); + self::assertTrue(Csrf::validate($session, $new)); + } +} diff --git a/tests/Unit/Auth/PasswordHasherTest.php b/tests/Unit/Auth/PasswordHasherTest.php new file mode 100644 index 0000000..4ebe4d7 --- /dev/null +++ b/tests/Unit/Auth/PasswordHasherTest.php @@ -0,0 +1,87 @@ + */ + private array $touchedKeys = []; + + protected function setUp(): void + { + // Cout reduit : les tests ne valident pas la robustesse cryptographique + // (couverte par les valeurs de prod) mais la mecanique hash/verify/cout. + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function hasher(): PasswordHasher + { + return new PasswordHasher(new Config()); + } + + public function testHashIsVerifiable(): void + { + $hasher = $this->hasher(); + $hash = $hasher->hash('WakdoAdmin2026!'); + + self::assertTrue($hasher->verify('WakdoAdmin2026!', $hash)); + } + + public function testWrongPasswordIsRejected(): void + { + $hasher = $this->hasher(); + $hash = $hasher->hash('correct horse'); + + self::assertFalse($hasher->verify('battery staple', $hash)); + } + + public function testHashUsesArgon2idAlgorithm(): void + { + $info = password_get_info($this->hasher()->hash('x')); + + self::assertSame('argon2id', $info['algoName']); + } + + public function testHashEmbedsConfiguredCost(): void + { + $info = password_get_info($this->hasher()->hash('x')); + + self::assertSame(1024, $info['options']['memory_cost'] ?? null); + self::assertSame(1, $info['options']['time_cost'] ?? null); + self::assertSame(1, $info['options']['threads'] ?? null); + } + + public function testVerifyDecoyRunsWithoutThrowing(): void + { + // Le leurre ne doit jamais lever ni valider un mot de passe : il ne sert + // qu'a consommer un temps CPU comparable au chemin nominal. + $this->expectNotToPerformAssertions(); + $this->hasher()->verifyDecoy('any-submitted-password'); + } +} diff --git a/tests/Unit/Auth/PasswordResetControllerTest.php b/tests/Unit/Auth/PasswordResetControllerTest.php new file mode 100644 index 0000000..2f8d25c --- /dev/null +++ b/tests/Unit/Auth/PasswordResetControllerTest.php @@ -0,0 +1,175 @@ +testSession; + } + + protected function resetService(): PasswordResetService + { + return new PasswordResetService($this->fakeDb, $this->config, new PasswordHasher($this->config), $this->spyMailer); + } +} + +final class PasswordResetControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + + protected function setUp(): void + { + $this->setEnv('PASSWORD_RESET_TTL', '3600'); + $this->setEnv('APP_URL_ADMIN', 'https://admin.wakdo.test'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + /** + * @param array $form + */ + private function post(array $form, string $path): Request + { + return new Request( + 'POST', + $path, + [], + ['content-type' => 'application/x-www-form-urlencoded'], + http_build_query($form), + '203.0.113.5', + ); + } + + private function controller( + Request $request, + SessionManager $session, + FakeDatabase $db, + SpyMailer $mailer, + ): TestPasswordResetController { + return new TestPasswordResetController($request, new Config(), new Database(new Config()), $session, $db, $mailer); + } + + public function testShowRequestRendersCsrfField(): void + { + $session = new SessionManager(new Config(), true); + $request = new Request('GET', '/forgot_password', [], [], '', '203.0.113.5'); + + $response = $this->controller($request, $session, new FakeDatabase(), new SpyMailer())->showRequest(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('name="_csrf"', $response->body()); + } + + public function testSubmitRequestRejectsInvalidCsrf(): void + { + $session = new SessionManager(new Config(), true); + Csrf::token($session); + $mailer = new SpyMailer(); + + $request = $this->post(['_csrf' => 'wrong', 'email' => 'admin@wakdo.local'], '/forgot_password'); + $response = $this->controller($request, $session, new FakeDatabase(), $mailer)->submitRequest(); + + self::assertSame(403, $response->status()); + self::assertSame([], $mailer->sent); + } + + public function testSubmitRequestUnknownEmailIsNeutralAndSilent(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + $db = new FakeDatabase(); + $db->emailLookupRow = null; + $mailer = new SpyMailer(); + + $request = $this->post(['_csrf' => $token, 'email' => 'ghost@wakdo.local'], '/forgot_password'); + $response = $this->controller($request, $session, $db, $mailer)->submitRequest(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('Si un compte', $response->body()); + self::assertSame([], $mailer->sent); + self::assertSame([], $db->writes); + } + + public function testSubmitConfirmPasswordMismatchRendersError(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + + $request = $this->post([ + '_csrf' => $token, + 'token' => 'raw-token', + 'password' => 'longenough1', + 'password_confirm' => 'different01', + ], '/reset_password'); + $response = $this->controller($request, $session, new FakeDatabase(), new SpyMailer())->submitConfirm(); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('ne correspondent pas', $response->body()); + } + + public function testSubmitConfirmValidTokenRedirectsToLogin(): void + { + $session = new SessionManager(new Config(), true); + $token = Csrf::token($session); + $db = new FakeDatabase(); + $db->resetUserRow = ['id' => 7, 'role_id' => 3, 'password_reset_token_hash' => hash('sha256', 'raw-token')]; + + $request = $this->post([ + '_csrf' => $token, + 'token' => 'raw-token', + 'password' => 'brandnewpassword', + 'password_confirm' => 'brandnewpassword', + ], '/reset_password'); + $response = $this->controller($request, $session, $db, new SpyMailer())->submitConfirm(); + + self::assertSame(302, $response->status()); + self::assertSame('/login?reset=ok', $response->header('Location')); + } +} diff --git a/tests/Unit/Auth/PasswordResetServiceTest.php b/tests/Unit/Auth/PasswordResetServiceTest.php new file mode 100644 index 0000000..ada4e15 --- /dev/null +++ b/tests/Unit/Auth/PasswordResetServiceTest.php @@ -0,0 +1,154 @@ + */ + private array $touchedKeys = []; + + private FakeDatabase $db; + private SpyMailer $mailer; + private PasswordHasher $hasher; + + protected function setUp(): void + { + $this->setEnv('PASSWORD_RESET_TTL', '3600'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); + + $this->db = new FakeDatabase(); + $this->mailer = new SpyMailer(); + $this->hasher = new PasswordHasher(new Config()); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function service(): PasswordResetService + { + return new PasswordResetService($this->db, new Config(), $this->hasher, $this->mailer); + } + + public function testRequestUnknownEmailWritesNothingAndSendsNoMail(): void + { + $this->db->emailLookupRow = null; + + $this->service()->requestReset('ghost@wakdo.local', 'https://admin.wakdo.test', self::NOW); + + self::assertSame([], $this->db->writes); + self::assertSame([], $this->mailer->sent); + } + + public function testRequestActiveUserStoresHashAndMailsRawTokenOnce(): void + { + $this->db->emailLookupRow = ['id' => 7]; + + $this->service()->requestReset('admin@wakdo.local', 'https://admin.wakdo.test', self::NOW); + + self::assertCount(1, $this->mailer->sent); + $url = $this->mailer->sent[0]['resetUrl']; + self::assertStringStartsWith('https://admin.wakdo.test/reset_password?token=', $url); + + $query = (string) parse_url($url, PHP_URL_QUERY); + parse_str($query, $parsed); + $rawToken = is_string($parsed['token'] ?? null) ? $parsed['token'] : ''; + self::assertSame(64, strlen($rawToken)); + + $write = $this->firstWrite('password_reset_token_hash = :hash'); + $storedHash = $write['params']['hash'] ?? null; + // Le brut n'est jamais persiste : ce qui est stocke est son SHA-256. + self::assertSame(hash('sha256', $rawToken), $storedHash); + self::assertNotSame($rawToken, $storedHash); + self::assertSame(date('Y-m-d H:i:s', self::NOW + 3600), $write['params']['exp'] ?? null); + } + + public function testConfirmShortPasswordIsRejectedBeforeAnyWrite(): void + { + $this->db->resetUserRow = ['id' => 7, 'role_id' => 3, 'password_reset_token_hash' => hash('sha256', 'tok')]; + + $result = $this->service()->confirmReset('tok', 'short', self::NOW); + + self::assertFalse($result->success); + self::assertSame([], $this->db->writes); + } + + public function testConfirmUnknownOrExpiredTokenFails(): void + { + // resetUserRow null = aucune ligne (token inconnu, expire, ou deja consomme). + $this->db->resetUserRow = null; + + $result = $this->service()->confirmReset('whatever', 'newpassword123', self::NOW); + + self::assertFalse($result->success); + self::assertSame('Lien invalide ou expire.', $result->error); + self::assertSame([], $this->db->writes); + } + + public function testConfirmValidTokenResetsPassword(): void + { + $raw = 'a-valid-raw-token'; + $this->db->resetUserRow = [ + 'id' => 7, + 'role_id' => 3, + 'password_reset_token_hash' => hash('sha256', $raw), + ]; + + $result = $this->service()->confirmReset($raw, 'brandnewpassword', self::NOW); + + self::assertTrue($result->success); + self::assertSame(7, $result->userId); + self::assertSame('/login?reset=ok', $result->redirectTo); + + // Nouveau mot de passe argon2id verifiable + token efface (usage unique). + $write = $this->firstWrite('SET password_hash = :hash'); + $newHash = $write['params']['hash'] ?? ''; + self::assertIsString($newHash); + self::assertTrue($this->hasher->verify('brandnewpassword', $newHash)); + self::assertStringContainsString('password_reset_token_hash = NULL', $write['sql']); + + self::assertSame(['auth.password_reset'], $this->db->auditActions()); + self::assertSame(['begin', 'commit'], $this->db->transactionEvents); + } + + /** + * @return array{sql: string, params: array} + */ + private function firstWrite(string $needle): array + { + foreach ($this->db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write; + } + } + + self::fail('aucune ecriture ne contient: ' . $needle); + } +} diff --git a/tests/Unit/Auth/SessionGuardTest.php b/tests/Unit/Auth/SessionGuardTest.php new file mode 100644 index 0000000..956a367 --- /dev/null +++ b/tests/Unit/Auth/SessionGuardTest.php @@ -0,0 +1,121 @@ + */ + private array $touchedKeys = []; + + private FakeDatabase $db; + private SessionManager $session; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', (string) self::IDLE); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', (string) self::ABSOLUTE); + + $this->db = new FakeDatabase(); + $this->session = new SessionManager(new Config(), true); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function guard(): SessionGuard + { + return new SessionGuard($this->session, $this->db, new Config()); + } + + private function seedSession(int $loggedInAt, int $lastActivity): void + { + $this->session->set('user_id', 7); + $this->session->set('role_id', 3); + $this->session->set('logged_in_at', $loggedInAt); + $this->session->set('last_activity', $lastActivity); + } + + public function testNoSessionIsRejected(): void + { + $result = $this->guard()->check(self::NOW); + + self::assertFalse($result->authenticated); + self::assertSame('no_session', $result->reason); + } + + public function testValidSessionWithinWindowsRefreshesActivity(): void + { + $this->seedSession(self::NOW - 100, self::NOW - 50); + $this->db->guardUserRow = ['is_active' => 1]; + + $result = $this->guard()->check(self::NOW); + + self::assertTrue($result->authenticated); + self::assertSame(7, $result->userId); + self::assertSame(3, $result->roleId); + self::assertNull($result->reason); + // Fenetre idle glissante : last_activity rafraichi a now. + self::assertSame(self::NOW, $this->session->getInt('last_activity')); + } + + public function testIdleTimeoutIsRejected(): void + { + $this->seedSession(self::NOW - 200, self::NOW - (self::IDLE + 1)); + $this->db->guardUserRow = ['is_active' => 1]; + + $result = $this->guard()->check(self::NOW); + + self::assertFalse($result->authenticated); + self::assertSame('idle_timeout', $result->reason); + } + + public function testAbsoluteTimeoutIsRejected(): void + { + // Activite recente (idle OK) mais session ouverte depuis plus de 10h. + $this->seedSession(self::NOW - (self::ABSOLUTE + 1), self::NOW - 10); + $this->db->guardUserRow = ['is_active' => 1]; + + $result = $this->guard()->check(self::NOW); + + self::assertFalse($result->authenticated); + self::assertSame('absolute_timeout', $result->reason); + } + + public function testInactiveUserIsRejected(): void + { + $this->seedSession(self::NOW - 100, self::NOW - 50); + $this->db->guardUserRow = ['is_active' => 0]; + + $result = $this->guard()->check(self::NOW); + + self::assertFalse($result->authenticated); + self::assertSame('inactive', $result->reason); + } +} diff --git a/tests/Unit/Auth/ThrottlePolicyTest.php b/tests/Unit/Auth/ThrottlePolicyTest.php new file mode 100644 index 0000000..a93702f --- /dev/null +++ b/tests/Unit/Auth/ThrottlePolicyTest.php @@ -0,0 +1,133 @@ + */ + private array $touchedKeys = []; + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function policy(int $threshold = 5, int $base = 60, int $max = 900): ThrottlePolicy + { + return new ThrottlePolicy($threshold, $base, $max); + } + + public function testNoLockoutBelowThreshold(): void + { + $policy = $this->policy(); + + self::assertSame(0, $policy->lockoutSeconds(0)); + self::assertSame(0, $policy->lockoutSeconds(4)); + } + + public function testBaseDelayAtThreshold(): void + { + self::assertSame(60, $this->policy()->lockoutSeconds(5)); + } + + /** + * @return list + */ + public static function degressiveCurveProvider(): array + { + // threshold=5, base=60, max=900 : 60, 120, 240, 480, puis plafond 900. + return [ + [5, 60], + [6, 120], + [7, 240], + [8, 480], + [9, 900], // 60*2^4 = 960 -> plafonne a 900 + [10, 900], // au-dela : reste plafonne + [20, 900], + ]; + } + + #[DataProvider('degressiveCurveProvider')] + public function testDegressiveCurveIsCappedAtMax(int $attempts, int $expected): void + { + self::assertSame($expected, $this->policy()->lockoutSeconds($attempts)); + } + + public function testNoIntegerOverflowForHugeAttemptCount(): void + { + // Un compteur enorme ne doit jamais deborder en negatif ni lever : on + // reste plafonne au maximum configure. + self::assertSame(900, $this->policy()->lockoutSeconds(1000)); + self::assertSame(900, $this->policy()->lockoutSeconds(PHP_INT_MAX)); + } + + public function testIsLockedUntilFutureIsTrue(): void + { + $now = 1_000_000; + $future = date('Y-m-d H:i:s', $now + 120); + + self::assertTrue($this->policy()->isLockedUntil($future, $now)); + } + + public function testIsLockedUntilPastOrNullIsFalse(): void + { + $now = 1_000_000; + $past = date('Y-m-d H:i:s', $now - 1); + + self::assertFalse($this->policy()->isLockedUntil($past, $now)); + self::assertFalse($this->policy()->isLockedUntil(null, $now)); + self::assertFalse($this->policy()->isLockedUntil('', $now)); + } + + public function testIsLockedUntilUnparseableIsFalse(): void + { + self::assertFalse($this->policy()->isLockedUntil('not-a-date', 1_000_000)); + } + + public function testFromConfigAccountReadsAccountKeys(): void + { + $this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '3'); + $this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '30'); + $this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '600'); + + $policy = ThrottlePolicy::fromConfig(new Config(), 'account'); + + self::assertSame(0, $policy->lockoutSeconds(2)); + self::assertSame(30, $policy->lockoutSeconds(3)); + self::assertSame(60, $policy->lockoutSeconds(4)); + self::assertSame(600, $policy->lockoutSeconds(99)); + } + + public function testFromConfigIpUsesIpThresholdWithSharedCurve(): void + { + $this->setEnv('IP_THROTTLE_MAX_ATTEMPTS', '20'); + $this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60'); + $this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900'); + + $policy = ThrottlePolicy::fromConfig(new Config(), 'ip'); + + self::assertSame(0, $policy->lockoutSeconds(19)); + self::assertSame(60, $policy->lockoutSeconds(20)); + self::assertSame(120, $policy->lockoutSeconds(21)); + } +} diff --git a/tests/Unit/Core/RequestFormBodyTest.php b/tests/Unit/Core/RequestFormBodyTest.php new file mode 100644 index 0000000..ee3667a --- /dev/null +++ b/tests/Unit/Core/RequestFormBodyTest.php @@ -0,0 +1,128 @@ + $headers + */ + private function request(string $method, string $rawBody, array $headers = [], string $remoteAddr = ''): Request + { + return new Request($method, '/login', [], $headers, $rawBody, $remoteAddr); + } + + public function testFormBodyParsesUrlencodedBody(): void + { + $request = $this->request( + 'POST', + 'email=admin%40wakdo.local&password=secret+pass', + ['content-type' => 'application/x-www-form-urlencoded'], + ); + + self::assertSame( + ['email' => 'admin@wakdo.local', 'password' => 'secret pass'], + $request->formBody(), + ); + } + + public function testFormBodyToleratesCharsetSuffixOnContentType(): void + { + $request = $this->request( + 'POST', + 'a=1', + ['content-type' => 'application/x-www-form-urlencoded; charset=UTF-8'], + ); + + self::assertSame(['a' => '1'], $request->formBody()); + } + + public function testFormBodyReturnsEmptyForJsonContentType(): void + { + $request = $this->request('POST', '{"email":"x"}', ['content-type' => 'application/json']); + + self::assertSame([], $request->formBody()); + } + + public function testFormBodyReturnsEmptyWhenContentTypeAbsent(): void + { + $request = $this->request('POST', 'email=x'); + + self::assertSame([], $request->formBody()); + } + + public function testFormBodyDropsArrayShapedValues(): void + { + // parse_str transforme "tags[]=a&tags[]=b" en tableau : on ne garde que + // les scalaires pour tenir le contrat array. + $request = $this->request( + 'POST', + 'name=ok&tags%5B%5D=a&tags%5B%5D=b', + ['content-type' => 'application/x-www-form-urlencoded'], + ); + + self::assertSame(['name' => 'ok'], $request->formBody()); + } + + public function testClientIpUsesLastForwardedHop(): void + { + // Seul le dernier hop (ajoute par Traefik) est de confiance ; les entrees + // de gauche sont fournies par le client et donc falsifiables. + $request = $this->request( + 'POST', + '', + ['x-forwarded-for' => '10.0.0.9, 203.0.113.7'], + '172.18.0.2', + ); + + self::assertSame('203.0.113.7', $request->clientIp()); + } + + public function testClientIpFallsBackToRemoteAddrWhenNoForwardedHeader(): void + { + $request = $this->request('POST', '', [], '198.51.100.4'); + + self::assertSame('198.51.100.4', $request->clientIp()); + } + + public function testClientIpFallsBackWhenForwardedHopIsMalformed(): void + { + $request = $this->request( + 'POST', + '', + ['x-forwarded-for' => 'not-an-ip'], + '198.51.100.4', + ); + + self::assertSame('198.51.100.4', $request->clientIp()); + } + + public function testClientIpAcceptsIpv6(): void + { + $request = $this->request( + 'POST', + '', + ['x-forwarded-for' => '2001:db8::1'], + '172.18.0.2', + ); + + self::assertSame('2001:db8::1', $request->clientIp()); + } + + public function testClientIpReturnsSentinelWhenNothingResolvable(): void + { + $request = $this->request('POST', '', [], ''); + + self::assertSame('0.0.0.0', $request->clientIp()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 5810c5f..f390e82 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -11,3 +11,21 @@ declare(strict_types=1); require __DIR__ . '/../src/app/Core/Autoloader.php'; App\Core\Autoloader::register(); + +// Autoloader PSR-4 dedie aux classes de support de test (doubles, helpers) : +// App\Tests\... -> tests/... . Permet de partager un FakeDatabase entre suites +// sans le dupliquer dans chaque fichier de test. +spl_autoload_register(static function (string $class): void { + $prefix = 'App\\Tests\\'; + + if (!str_starts_with($class, $prefix)) { + return; + } + + $relative = substr($class, strlen($prefix)); + $path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php'; + + if (is_file($path)) { + require $path; + } +});