feat: authentification back-office P2 (login/logout/reset, throttle, audit) (#11)
This commit is contained in:
parent
c8f5370cfd
commit
1b0b20c12d
37 changed files with 3648 additions and 1 deletions
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
328
docs/api/conventions.md
Normal file
328
docs/api/conventions.md
Normal file
|
|
@ -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` |
|
||||
|
|
@ -17,6 +17,10 @@
|
|||
<testsuite name="unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<!-- Tests d'integration DB : auto-skip si WAKDO_DB_TESTS != 1 (CI sans base). -->
|
||||
<testsuite name="integration">
|
||||
<directory>tests/Integration</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
|
|
|
|||
35
src/app/Auth/AuthResult.php
Normal file
35
src/app/Auth/AuthResult.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
/**
|
||||
* Resultat immuable d'une operation d'authentification (login ou confirmation
|
||||
* de reinitialisation). Le controleur mappe ce resultat vers une reponse HTTP
|
||||
* sans re-deriver les branches de securite.
|
||||
*
|
||||
* Le message d'echec par defaut est unique et generique (anti-enumeration) :
|
||||
* identifiants faux, compte inactif et throttle partagent le meme texte.
|
||||
*/
|
||||
final class AuthResult
|
||||
{
|
||||
private function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly ?int $userId,
|
||||
public readonly ?int $roleId,
|
||||
public readonly ?string $redirectTo,
|
||||
public readonly ?string $error,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function success(int $userId, int $roleId, string $redirectTo): self
|
||||
{
|
||||
return new self(true, $userId, $roleId, $redirectTo, null);
|
||||
}
|
||||
|
||||
public static function failure(string $error = 'Email ou mot de passe incorrect'): self
|
||||
{
|
||||
return new self(false, null, null, null, $error);
|
||||
}
|
||||
}
|
||||
280
src/app/Auth/AuthService.php
Normal file
280
src/app/Auth/AuthService.php
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Core\Config;
|
||||
use App\Core\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Authentification back-office : AUTHENTICATE_USER (mlt.md 12.1) et
|
||||
* LOGOUT_USER (12.2). Requetes preparees inline (pas de repository : jeu de
|
||||
* requetes fixe et borne, une seule famille d'operations). Le temps est injecte
|
||||
* (?int $now) pour des comparaisons de verrou deterministes en test.
|
||||
*
|
||||
* Fail-closed : toute exception PDO remonte ; aucune session n'est jamais
|
||||
* ouverte sur une erreur de base de donnees.
|
||||
*/
|
||||
final class AuthService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DatabaseInterface $db,
|
||||
private readonly Config $config,
|
||||
private readonly SessionManager $session,
|
||||
private readonly PasswordHasher $hasher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordre strict (12.1) : RG-1 lookup (toujours, pour payer le cout SELECT sur
|
||||
* hit comme sur miss) -> 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<string, mixed>|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;
|
||||
}
|
||||
}
|
||||
60
src/app/Auth/Csrf.php
Normal file
60
src/app/Auth/Csrf.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
/**
|
||||
* Jeton CSRF synchroniseur stocke en session (RG-T01). Choisi plutot que le
|
||||
* double-submit (plus faible derriere un domaine parent partage) ou un HMAC
|
||||
* stateless (inutile puisqu'on a deja un etat serveur en session).
|
||||
*
|
||||
* Comparaison en temps constant (hash_equals) ; le jeton est re-genere apres
|
||||
* session_regenerate_id pour qu'un jeton plante avant l'authentification ne
|
||||
* puisse pas etre rejoue.
|
||||
*/
|
||||
final class Csrf
|
||||
{
|
||||
private const KEY = '_csrf';
|
||||
|
||||
/**
|
||||
* Jeton stable de la session : genere une fois (32 octets CSPRNG en hex)
|
||||
* puis reutilise tant que la session vit.
|
||||
*/
|
||||
public static function token(SessionManager $session): string
|
||||
{
|
||||
$existing = $session->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;
|
||||
}
|
||||
}
|
||||
22
src/app/Auth/GuardResult.php
Normal file
22
src/app/Auth/GuardResult.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
/**
|
||||
* Resultat immuable d'une verification de garde de session (RG-6 + RG-T02).
|
||||
* $reason documente la cause d'un rejet pour que le controleur appelant (P3)
|
||||
* decide de la suite (redirection login, message). Valeurs possibles :
|
||||
* 'no_session' | 'idle_timeout' | 'absolute_timeout' | 'inactive' | null (OK).
|
||||
*/
|
||||
final class GuardResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $authenticated,
|
||||
public readonly ?int $userId,
|
||||
public readonly ?int $roleId,
|
||||
public readonly ?string $reason,
|
||||
) {
|
||||
}
|
||||
}
|
||||
18
src/app/Auth/LogMailer.php
Normal file
18
src/app/Auth/LogMailer.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
/**
|
||||
* Implementation de developpement : aucune infra mail en P2, on journalise le
|
||||
* lien de reinitialisation (error_log -> 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));
|
||||
}
|
||||
}
|
||||
16
src/app/Auth/Mailer.php
Normal file
16
src/app/Auth/Mailer.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
/**
|
||||
* Seam d'envoi du lien de reinitialisation de mot de passe. Interface justifiee
|
||||
* (contrairement a un repository) car une implementation SMTP reelle est
|
||||
* explicitement prevue pour une phase ulterieure : elle se branchera ici sans
|
||||
* toucher PasswordResetService.
|
||||
*/
|
||||
interface Mailer
|
||||
{
|
||||
public function sendPasswordReset(string $email, string $resetUrl): void;
|
||||
}
|
||||
73
src/app/Auth/PasswordHasher.php
Normal file
73
src/app/Auth/PasswordHasher.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Core\Config;
|
||||
|
||||
/**
|
||||
* Enveloppe argon2id de password_hash / password_verify avec les couts lus dans
|
||||
* l'environnement (.env / docker-compose). Porte aussi le leurre de timing
|
||||
* utilise quand l'email est inconnu (anti-enumeration, mlt.md 12.1 RG-2).
|
||||
*/
|
||||
final class PasswordHasher
|
||||
{
|
||||
// Cache a l'echelle du process (worker PHP-FPM) : le PasswordHasher est
|
||||
// instancie a chaque requete, mais le leurre doit etre calcule une seule fois
|
||||
// par worker (voir decoyHash()).
|
||||
private static ?string $decoy = null;
|
||||
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function hash(string $plain): string
|
||||
{
|
||||
return password_hash($plain, PASSWORD_ARGON2ID, $this->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;
|
||||
}
|
||||
}
|
||||
127
src/app/Auth/PasswordResetService.php
Normal file
127
src/app/Auth/PasswordResetService.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Core\Config;
|
||||
use App\Core\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Reinitialisation de mot de passe (mlt.md 12.3), en deux phases : demande puis
|
||||
* confirmation. Sans fuite d'enumeration (reponse neutre), token CSPRNG hashe au
|
||||
* repos, usage unique, confirmation transactionnelle.
|
||||
*/
|
||||
final class PasswordResetService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DatabaseInterface $db,
|
||||
private readonly Config $config,
|
||||
private readonly PasswordHasher $hasher,
|
||||
private readonly Mailer $mailer,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase demande (RG-1/RG-2). Retour void : la reponse cote controleur est
|
||||
* neutre que l'email existe ou non (anti-enumeration). Si l'email resout un
|
||||
* utilisateur actif : token CSPRNG 32 octets, on stocke son hash SHA-256 et
|
||||
* une expiration NOW()+TTL, et on envoie le token BRUT une seule fois.
|
||||
*/
|
||||
public function requestReset(string $email, string $baseUrl, ?int $now = null): void
|
||||
{
|
||||
$now ??= time();
|
||||
|
||||
$user = $this->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');
|
||||
}
|
||||
}
|
||||
67
src/app/Auth/SessionGuard.php
Normal file
67
src/app/Auth/SessionGuard.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Core\Config;
|
||||
use App\Core\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Garde de session pour les requetes authentifiees (mlt.md 12.1 RG-6 + RG-T02).
|
||||
*
|
||||
* NOTE DE PERIMETRE : concu et teste en P2, mais CABLE en P3. Quand les pages
|
||||
* admin deviendront dynamiques, chaque controleur protege appellera check() en
|
||||
* tete d'action et agira sur le GuardResult (rediriger vers /login si false).
|
||||
*/
|
||||
final class SessionGuard
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionManager $session,
|
||||
private readonly DatabaseInterface $db,
|
||||
private readonly Config $config,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie la session : presence d'identite, borne d'inactivite (idle) et
|
||||
* borne absolue (RG-6), puis re-verification is_active = 1 en base (RG-T02).
|
||||
* Sur succes, rafraichit last_activity (fenetre idle glissante).
|
||||
*/
|
||||
public function check(?int $now = null): GuardResult
|
||||
{
|
||||
$now ??= time();
|
||||
|
||||
$userId = $this->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);
|
||||
}
|
||||
}
|
||||
172
src/app/Auth/SessionManager.php
Normal file
172
src/app/Auth/SessionManager.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Core\Config;
|
||||
|
||||
/**
|
||||
* Seul fichier autorise a toucher $_SESSION, les fonctions session_* et le
|
||||
* cookie de session. Tout le reste de l'auth opere sur cette facade injectee,
|
||||
* ce qui rend les services et le CSRF testables sans session reelle.
|
||||
*
|
||||
* En mode test (testMode = true), aucune session PHP n'est demarree : l'etat
|
||||
* vit dans un sac memoire. Indispensable car PHPUnit tourne avec
|
||||
* beStrictAboutOutputDuringTests : un session_start emettrait un en-tete et
|
||||
* ferait echouer la suite.
|
||||
*/
|
||||
final class SessionManager
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
85
src/app/Auth/ThrottlePolicy.php
Normal file
85
src/app/Auth/ThrottlePolicy.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Core\Config;
|
||||
|
||||
/**
|
||||
* Math pure du throttling anti brute-force (mlt.md 12.1 RG-8). Sans I/O ni
|
||||
* superglobale : c'est le calcul de securite le plus delicat (backoff degressif
|
||||
* + evaluation du verrou), donc isole ici pour etre entierement testable.
|
||||
*
|
||||
* La meme courbe sert aux deux dimensions : par compte (user.lockout_until,
|
||||
* seuil ACCOUNT_LOCKOUT_THRESHOLD) et par IP source (login_throttle.lockout_until,
|
||||
* seuil IP_THROTTLE_MAX_ATTEMPTS), instanciees via fromConfig().
|
||||
*/
|
||||
final class ThrottlePolicy
|
||||
{
|
||||
public function __construct(
|
||||
private readonly int $threshold,
|
||||
private readonly int $baseSeconds,
|
||||
private readonly int $maxSeconds,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff degressif : 0 sous le seuil ; au seuil = base ; puis doublement
|
||||
* base * 2^(tentatives - seuil), plafonne a maxSeconds. Ce n'est pas un
|
||||
* verrou definitif : il ralentit la force brute sans priver de service un
|
||||
* compte legitime victime de fautes de frappe (RG-8).
|
||||
*/
|
||||
public function lockoutSeconds(int $attempts): int
|
||||
{
|
||||
if ($attempts < $this->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);
|
||||
}
|
||||
}
|
||||
127
src/app/Controllers/AuthController.php
Normal file
127
src/app/Controllers/AuthController.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Throwable;
|
||||
use App\Auth\AuthService;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Core\Controller;
|
||||
use App\Core\Response;
|
||||
|
||||
/**
|
||||
* Connexion / deconnexion du back-office (mlt.md 12.1 et 12.2). Rendu serveur :
|
||||
* GET /login affiche le formulaire (jeton CSRF en champ cache), POST /login
|
||||
* authentifie puis redirige (302) vers role.default_route, POST /logout detruit
|
||||
* la session.
|
||||
*
|
||||
* Le Router n'injecte que (Request, Config, Database) ; le controleur fabrique
|
||||
* donc son graphe de services via des hooks proteges, surchargeables en test.
|
||||
*
|
||||
* Non `final` a dessein : les tests sous-classent ce controleur pour surcharger
|
||||
* sessionManager()/authService() et injecter des doubles (seam de testabilite).
|
||||
*/
|
||||
class AuthController extends Controller
|
||||
{
|
||||
private const GENERIC_ERROR = 'Email ou mot de passe incorrect';
|
||||
|
||||
/**
|
||||
* @param array<string, string> $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<string, string> $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<string, string> $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);
|
||||
}
|
||||
}
|
||||
150
src/app/Controllers/PasswordResetController.php
Normal file
150
src/app/Controllers/PasswordResetController.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Throwable;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\LogMailer;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\PasswordResetService;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Core\Controller;
|
||||
use App\Core\Response;
|
||||
|
||||
/**
|
||||
* Reinitialisation de mot de passe (mlt.md 12.3), rendu serveur en deux phases :
|
||||
* demande (GET/POST /forgot_password) puis confirmation (GET/POST /reset_password).
|
||||
* La phase demande renvoie toujours une reponse neutre (anti-enumeration).
|
||||
*
|
||||
* Non `final` a dessein : les tests sous-classent ce controleur pour surcharger
|
||||
* sessionManager()/resetService() et injecter des doubles (seam de testabilite).
|
||||
*/
|
||||
class PasswordResetController extends Controller
|
||||
{
|
||||
private const NEUTRAL_NOTICE = 'Si un compte correspond a cet email, un lien de reinitialisation a ete envoye.';
|
||||
private const INVALID_LINK = 'Lien invalide ou expire.';
|
||||
|
||||
/**
|
||||
* @param array<string, string> $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<string, string> $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<string, string> $params
|
||||
*/
|
||||
public function showConfirm(array $params = []): Response
|
||||
{
|
||||
return $this->renderConfirm($this->request->query('token') ?? '', null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
src/app/Core/DatabaseInterface.php
Normal file
43
src/app/Core/DatabaseInterface.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Contrat d'acces aux donnees consomme par les services applicatifs (auth...).
|
||||
* Il expose uniquement les operations dont ils ont besoin (lecture, ecriture,
|
||||
* transaction atomique) sans la primitive bas niveau query()/PDOStatement.
|
||||
*
|
||||
* Raison d'etre : permettre aux services securite-critiques (AuthService,
|
||||
* PasswordResetService, SessionGuard) d'etre testes unitairement avec un double
|
||||
* en memoire, tout en gardant la classe Database concrete `final`. Le seul autre
|
||||
* implementeur est ce double de test : interface justifiee, pas speculative.
|
||||
*/
|
||||
interface DatabaseInterface
|
||||
{
|
||||
/**
|
||||
* @param array<string|int, mixed> $params
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function fetch(string $sql, array $params = []): ?array;
|
||||
|
||||
/**
|
||||
* @param array<string|int, mixed> $params
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function fetchAll(string $sql, array $params = []): array;
|
||||
|
||||
/**
|
||||
* @param array<string|int, mixed> $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;
|
||||
}
|
||||
|
|
@ -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<string, string>
|
||||
*/
|
||||
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<string, string> (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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
*/
|
||||
public function headers(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
|
|
|
|||
35
src/app/Views/auth/forgot.php
Normal file
35
src/app/Views/auth/forgot.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fragment de la demande de reinitialisation (phase 1 de 12.3), injecte dans
|
||||
* layout.php. La reponse est neutre : aucun indice sur l'existence du compte.
|
||||
*
|
||||
* @var string $csrfToken
|
||||
* @var string|null $notice
|
||||
*/
|
||||
|
||||
$token = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$noticeMessage = isset($notice) && is_string($notice) ? $notice : null;
|
||||
?>
|
||||
<main class="login-page">
|
||||
<h1>Mot de passe oublie</h1>
|
||||
|
||||
<?php if ($noticeMessage !== null): ?>
|
||||
<p role="status"><?= htmlspecialchars($noticeMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/forgot_password">
|
||||
<input type="hidden" name="_csrf" value="<?= $token ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Adresse e-mail</label>
|
||||
<input type="email" id="email" name="email" autocomplete="email" required>
|
||||
</div>
|
||||
|
||||
<button type="submit">Envoyer le lien</button>
|
||||
</form>
|
||||
|
||||
<p><a href="/login">Retour a la connexion</a></p>
|
||||
</main>
|
||||
47
src/app/Views/auth/login.php
Normal file
47
src/app/Views/auth/login.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fragment du formulaire de connexion back-office, injecte dans layout.php.
|
||||
* Tout texte dynamique est echappe (RG-T15). action POST /login, jeton CSRF cache.
|
||||
*
|
||||
* @var string $csrfToken
|
||||
* @var string|null $error
|
||||
* @var string|null $notice
|
||||
*/
|
||||
|
||||
$token = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$errorMessage = isset($error) && is_string($error) ? $error : null;
|
||||
$noticeMessage = isset($notice) && is_string($notice) ? $notice : null;
|
||||
?>
|
||||
<main class="login-page">
|
||||
<h1>Wakdo Admin</h1>
|
||||
<p><small>Back-office de gestion</small></p>
|
||||
|
||||
<?php if ($noticeMessage !== null): ?>
|
||||
<p role="status"><?= htmlspecialchars($noticeMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($errorMessage !== null): ?>
|
||||
<p role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="_csrf" value="<?= $token ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Adresse e-mail</label>
|
||||
<input type="email" id="email" name="email" autocomplete="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Mot de passe</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit">Se connecter</button>
|
||||
</form>
|
||||
|
||||
<p><a href="/forgot_password">Mot de passe oublie ?</a></p>
|
||||
</main>
|
||||
43
src/app/Views/auth/reset.php
Normal file
43
src/app/Views/auth/reset.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fragment de la confirmation de reinitialisation (phase 2 de 12.3), injecte
|
||||
* dans layout.php. Le token brut transite en champ cache (usage unique cote service).
|
||||
*
|
||||
* @var string $csrfToken
|
||||
* @var string $token
|
||||
* @var string|null $error
|
||||
*/
|
||||
|
||||
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$resetToken = htmlspecialchars($token ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$errorMessage = isset($error) && is_string($error) ? $error : null;
|
||||
?>
|
||||
<main class="login-page">
|
||||
<h1>Nouveau mot de passe</h1>
|
||||
|
||||
<?php if ($errorMessage !== null): ?>
|
||||
<p role="alert"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/reset_password">
|
||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||
<input type="hidden" name="token" value="<?= $resetToken ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Nouveau mot de passe</label>
|
||||
<input type="password" id="password" name="password" autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password_confirm">Confirmer le mot de passe</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
|
||||
<button type="submit">Reinitialiser</button>
|
||||
</form>
|
||||
|
||||
<p><a href="/login">Retour a la connexion</a></p>
|
||||
</main>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
189
tests/Integration/AuthServiceDbTest.php
Normal file
189
tests/Integration/AuthServiceDbTest.php
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Throwable;
|
||||
use App\Auth\AuthService;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
|
||||
/**
|
||||
* Test d'integration de AUTHENTICATE_USER contre une vraie MariaDB (schema migre
|
||||
* + seede). Il valide le SQL reel (requetes preparees, transaction, upsert
|
||||
* login_throttle) que les tests unitaires a FakeDatabase ne peuvent pas exercer.
|
||||
*
|
||||
* Auto-skip : ne s'execute que si WAKDO_DB_TESTS=1 ET qu'une base est joignable.
|
||||
* La CI (sans base) le saute donc, et il ne touche jamais la base par defaut.
|
||||
*
|
||||
* Isolation : chaque test cree son propre utilisateur jetable (email .invalid
|
||||
* unique) et le supprime en tearDown, avec sa ligne login_throttle (IP de test
|
||||
* dans le bloc documentation TEST-NET-2) et ses lignes audit_log.
|
||||
*/
|
||||
final class AuthServiceDbTest extends TestCase
|
||||
{
|
||||
private const TEST_IP = '198.51.100.250';
|
||||
private const PASSWORD = 'IntegrationPass1';
|
||||
|
||||
private Database $db;
|
||||
private Config $config;
|
||||
private int $userId = 0;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
if (getenv('WAKDO_DB_TESTS') !== '1') {
|
||||
self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_* pour les activer).');
|
||||
}
|
||||
|
||||
$this->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'] ?? '');
|
||||
}
|
||||
}
|
||||
155
tests/Support/FakeDatabase.php
Normal file
155
tests/Support/FakeDatabase.php
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\Core\DatabaseInterface;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Double de test de DatabaseInterface : aucune connexion reelle. Les lectures
|
||||
* sont scriptees par des "boutons" types (userRow, ipLockoutUntil,
|
||||
* ipFailedAttempts), les ecritures sont enregistrees pour assertion, et les
|
||||
* transactions tracent begin/commit/rollback. Permet de tester les branches de
|
||||
* securite d'AuthService / PasswordResetService sans base de donnees.
|
||||
*/
|
||||
final class FakeDatabase implements DatabaseInterface
|
||||
{
|
||||
/**
|
||||
* Reponse de la recherche utilisateur (RG-1) ; null = email inconnu.
|
||||
*
|
||||
* @var array<string, mixed>|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<string, mixed>|null
|
||||
*/
|
||||
public ?array $throttleRow = null;
|
||||
|
||||
/**
|
||||
* Reponse de la recherche par token de reinitialisation (12.3) ; null = aucun.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $resetUserRow = null;
|
||||
|
||||
/**
|
||||
* Reponse de la recherche par email (phase demande de reinitialisation) ; null = inconnu.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $emailLookupRow = null;
|
||||
|
||||
/**
|
||||
* Reponse de la verification is_active du SessionGuard (RG-T02) ; null = absent.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $guardUserRow = null;
|
||||
|
||||
/** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */
|
||||
public ?RuntimeException $failOnExecute = null;
|
||||
|
||||
/** @var list<array{sql: string, params: array<string|int, mixed>}> */
|
||||
public array $writes = [];
|
||||
|
||||
/** @var list<string> */
|
||||
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<string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
22
tests/Support/SpyMailer.php
Normal file
22
tests/Support/SpyMailer.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\Auth\Mailer;
|
||||
|
||||
/**
|
||||
* Double de Mailer : capture les appels au lieu d'envoyer. Permet d'asserter
|
||||
* qu'un lien de reinitialisation a (ou n'a pas) ete emis et d'en inspecter l'URL.
|
||||
*/
|
||||
final class SpyMailer implements Mailer
|
||||
{
|
||||
/** @var list<array{email: string, resetUrl: string}> */
|
||||
public array $sent = [];
|
||||
|
||||
public function sendPasswordReset(string $email, string $resetUrl): void
|
||||
{
|
||||
$this->sent[] = ['email' => $email, 'resetUrl' => $resetUrl];
|
||||
}
|
||||
}
|
||||
197
tests/Unit/Auth/AuthControllerTest.php
Normal file
197
tests/Unit/Auth/AuthControllerTest.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Auth;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\AuthService;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Controllers\AuthController;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
use App\Core\Request;
|
||||
use App\Tests\Support\FakeDatabase;
|
||||
|
||||
/**
|
||||
* Sous-classe de test : surcharge les hooks de fabrication pour injecter une
|
||||
* session en mode test et un FakeDatabase, sans toucher le Router ni la base.
|
||||
*/
|
||||
final class TestAuthController extends AuthController
|
||||
{
|
||||
public function __construct(
|
||||
Request $request,
|
||||
Config $config,
|
||||
Database $database,
|
||||
private readonly SessionManager $testSession,
|
||||
private readonly FakeDatabase $fakeDb,
|
||||
) {
|
||||
parent::__construct($request, $config, $database);
|
||||
}
|
||||
|
||||
protected function sessionManager(): SessionManager
|
||||
{
|
||||
return $this->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<string> */
|
||||
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<string, string> $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<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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'));
|
||||
}
|
||||
}
|
||||
315
tests/Unit/Auth/AuthServiceTest.php
Normal file
315
tests/Unit/Auth/AuthServiceTest.php
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Auth;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
use App\Auth\AuthService;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Core\Config;
|
||||
use App\Tests\Support\FakeDatabase;
|
||||
|
||||
/**
|
||||
* Branches de securite d'AUTHENTICATE_USER (mlt.md 12.1) testees avec un
|
||||
* FakeDatabase (aucune base), un vrai PasswordHasher a cout reduit et une
|
||||
* session en mode test. Le temps est fige via le parametre $now.
|
||||
*/
|
||||
final class AuthServiceTest extends TestCase
|
||||
{
|
||||
private const NOW = 1_700_000_000;
|
||||
|
||||
/** @var list<string> */
|
||||
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<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string|int, mixed>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
74
tests/Unit/Auth/CsrfTest.php
Normal file
74
tests/Unit/Auth/CsrfTest.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Auth;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Core\Config;
|
||||
|
||||
/**
|
||||
* CSRF synchroniseur teste sur un SessionManager en mode test (sac memoire),
|
||||
* donc sans session PHP reelle ni effet de bord d'en-tete.
|
||||
*/
|
||||
final class CsrfTest extends TestCase
|
||||
{
|
||||
private function session(): SessionManager
|
||||
{
|
||||
return new SessionManager(new Config(), true);
|
||||
}
|
||||
|
||||
public function testTokenIsHighEntropyHex(): void
|
||||
{
|
||||
$token = Csrf::token($this->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));
|
||||
}
|
||||
}
|
||||
87
tests/Unit/Auth/PasswordHasherTest.php
Normal file
87
tests/Unit/Auth/PasswordHasherTest.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Auth;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Core\Config;
|
||||
|
||||
/**
|
||||
* Verifie le hash argon2id (cout pilote par l'environnement) et le leurre de
|
||||
* timing. Les couts sont volontairement abaisses ici pour garder la suite rapide.
|
||||
*/
|
||||
final class PasswordHasherTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
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');
|
||||
}
|
||||
}
|
||||
175
tests/Unit/Auth/PasswordResetControllerTest.php
Normal file
175
tests/Unit/Auth/PasswordResetControllerTest.php
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Auth;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\Csrf;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\PasswordResetService;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Controllers\PasswordResetController;
|
||||
use App\Core\Config;
|
||||
use App\Core\Database;
|
||||
use App\Core\Request;
|
||||
use App\Tests\Support\FakeDatabase;
|
||||
use App\Tests\Support\SpyMailer;
|
||||
|
||||
/**
|
||||
* Sous-classe de test : injecte session test, FakeDatabase et SpyMailer dans le
|
||||
* controleur de reinitialisation.
|
||||
*/
|
||||
final class TestPasswordResetController extends PasswordResetController
|
||||
{
|
||||
public function __construct(
|
||||
Request $request,
|
||||
Config $config,
|
||||
Database $database,
|
||||
private readonly SessionManager $testSession,
|
||||
private readonly FakeDatabase $fakeDb,
|
||||
private readonly SpyMailer $spyMailer,
|
||||
) {
|
||||
parent::__construct($request, $config, $database);
|
||||
}
|
||||
|
||||
protected function sessionManager(): SessionManager
|
||||
{
|
||||
return $this->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<string> */
|
||||
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<string, string> $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'));
|
||||
}
|
||||
}
|
||||
154
tests/Unit/Auth/PasswordResetServiceTest.php
Normal file
154
tests/Unit/Auth/PasswordResetServiceTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Auth;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Auth\PasswordResetService;
|
||||
use App\Core\Config;
|
||||
use App\Tests\Support\FakeDatabase;
|
||||
use App\Tests\Support\SpyMailer;
|
||||
|
||||
/**
|
||||
* RESET_PASSWORD (mlt.md 12.3) : neutralite anti-enumeration, token CSPRNG hashe
|
||||
* au repos, usage unique, confirmation transactionnelle. FakeDatabase + SpyMailer.
|
||||
*/
|
||||
final class PasswordResetServiceTest extends TestCase
|
||||
{
|
||||
private const NOW = 1_700_000_000;
|
||||
|
||||
/** @var list<string> */
|
||||
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<string|int, mixed>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
121
tests/Unit/Auth/SessionGuardTest.php
Normal file
121
tests/Unit/Auth/SessionGuardTest.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Auth;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\SessionGuard;
|
||||
use App\Auth\SessionManager;
|
||||
use App\Core\Config;
|
||||
use App\Tests\Support\FakeDatabase;
|
||||
|
||||
/**
|
||||
* Garde de session (RG-6 + RG-T02) : presence d'identite, bornes idle/absolue,
|
||||
* re-verification is_active. Temps fige, session en mode test, is_active fake.
|
||||
*/
|
||||
final class SessionGuardTest extends TestCase
|
||||
{
|
||||
private const NOW = 1_700_000_000;
|
||||
private const IDLE = 14400; // 4h
|
||||
private const ABSOLUTE = 36000; // 10h
|
||||
|
||||
/** @var list<string> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
133
tests/Unit/Auth/ThrottlePolicyTest.php
Normal file
133
tests/Unit/Auth/ThrottlePolicyTest.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Auth;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Auth\ThrottlePolicy;
|
||||
use App\Core\Config;
|
||||
|
||||
/**
|
||||
* Le backoff degressif est le calcul de securite le plus delicat de l'auth :
|
||||
* on le verrouille par des cas explicites (seuil, doublement, plafond, debordement).
|
||||
*/
|
||||
final class ThrottlePolicyTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
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<array{0: int, 1: int}>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
128
tests/Unit/Core/RequestFormBodyTest.php
Normal file
128
tests/Unit/Core/RequestFormBodyTest.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Core;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Core\Request;
|
||||
|
||||
/**
|
||||
* Couvre les deux accesseurs ajoutes au Request pour l'auth back-office :
|
||||
* formBody() (login = formulaire POST urlencode, pas JSON) et clientIp()
|
||||
* (IP reelle derriere Traefik pour la cle de throttling par IP).
|
||||
*/
|
||||
final class RequestFormBodyTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $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<string, string>.
|
||||
$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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue