first commit

This commit is contained in:
julien
2026-03-20 22:13:41 +01:00
commit 41f8b3afb4
323 changed files with 27222 additions and 0 deletions

15
.editorconfig Normal file
View File

@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
[{package.json,package-lock.json}]
indent_size = 2

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# ============================================
# Environnement & Configuration
# ============================================
.env
# ============================================
# Dépendances Composer
# ============================================
vendor/
# ============================================
# Base de données
# ============================================
database/*.sqlite
database/*.sqlite-shm
database/*.sqlite-wal
database/.provision.lock
# ============================================
# Cache & Logs
# ============================================
coverage/
var/
.php-cs-fixer.cache
.phpstan/
.phpunit.result.cache
# ============================================
# IDE & OS
# ============================================
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db

36
.php-cs-fixer.dist.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
$directories = array_values(array_filter([
__DIR__ . '/src',
__DIR__ . '/tests',
__DIR__ . '/config',
], static fn (string $directory): bool => is_dir($directory)));
$finder = PhpCsFixer\Finder::create()
->in($directories)
->name('*.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setFinder($finder)
->setRules([
'@PSR12' => true,
'array_indentation' => true,
'binary_operator_spaces' => ['default' => 'single_space'],
'blank_line_after_opening_tag' => true,
'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try']],
'class_attributes_separation' => ['elements' => ['method' => 'one', 'property' => 'one']],
'concat_space' => ['spacing' => 'one'],
'declare_strict_types' => true,
'final_class' => false,
'no_unused_imports' => true,
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'ordered_types' => ['sort_algorithm' => 'alpha'],
'single_import_per_statement' => true,
'single_line_empty_body' => true,
'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
]);

110
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,110 @@
# Contribuer au projet
Ce dépôt fournit un socle partagé consommé par plusieurs applications. Une contribution doit préserver la stabilité du package, la lisibilité de son organisation et la clarté de son API publique.
## Avant de contribuer
Lire au minimum :
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) avant une évolution structurelle ;
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) pour le workflow quotidien ;
## Prérequis
Les mêmes que pour le développement (voir [README.md](README.md)).
## Vérifications attendues
Lancer au minimum :
```bash
composer test
composer stan
composer cs:check
```
Avec rapport de couverture (nécessite Xdebug ou PCOV) :
```bash
vendor/bin/phpunit --coverage-text
```
## Règles de contribution
### Mocks
Les services applicatifs utilisent les **interfaces** comme type des dépendances. Mocker l'interface plutôt que la classe concrète :
```php
// Correct
$repo = $this->createMock(UserRepositoryInterface::class);
// À éviter
$repo = $this->createMock(UserRepository::class);
```
Les tests de repository (`PdoUserRepository`, etc.) testent volontairement l'implémentation concrète avec un mock PDO. Ils doivent vérifier l'intention générale des requêtes et les valeurs retournées, sans figer inutilement les détails internes.
### Exceptions métier
Les erreurs métier doivent lever l'exception la plus spécifique disponible :
| Situation | Exception | Namespace |
|---|---|---|
| Nom d'utilisateur déjà pris | `DuplicateUsernameException` | `Netig\Netslim\Identity\Domain\Exception` |
| Email déjà utilisé | `DuplicateEmailException` | `Netig\Netslim\Identity\Domain\Exception` |
| Mot de passe trop court | `WeakPasswordException` | `Netig\Netslim\Identity\Domain\Exception` |
| Entité introuvable en base | `NotFoundException` | `Netig\Netslim\Kernel\Support\Exception` |
### Ajouter un test
- **Nommage** : `test` + description camelCase
- **Structure** : Arrange / Act / Assert
- **Isolation** : dépendances mockées via leurs interfaces
- **PHPDoc** : une ligne décrivant le comportement attendu quand cela apporte une vraie valeur
### Frontières à respecter
- **DI stricte dans `Application/` et `UI/`** : ne pas instancier directement de use case, policy, repository concret ou implémentation infrastructure dans un service applicatif ou un controller ;
- **Controllers minces** : un controller traduit HTTP vers l'application, puis convertit le résultat en réponse ou flash ;
- **UI → service uniquement** : un controller, request object ou composant UI ne référence ni `Application/UseCase/`, ni `Application/Command/` ;
- **ApplicationService = façade** : garder les lectures simples et la pagination dans le service applicatif, déléguer les mutations et workflows sensibles à des use cases ;
- **UseCase = action explicite** : un use case doit rester `final readonly`, porter un verbe clair et exposer une seule méthode publique `handle(...)` ;
- **Command = entrée immuable** : une command est un DTO `final readonly` placé dans `Application/Command/` ;
- **Commentaires et PHPDoc** : documenter les shapes, invariants et choix de runtime utiles ; garder les anglicismes techniques déjà utilisés par le projet au lieu de les retraduire ;
- **Modules auto-déclaratifs** : chaque module déclare ses définitions DI, ses routes, ses namespaces Twig et ses extensions Twig via son `<Domaine>Module.php` ;
- **Découplage inter-domaines** : lorsqu'un domaine expose un port à un autre, éviter de faire remonter ses structures internes.
### Checklist de review architecturale
Avant de valider une PR backend, vérifier rapidement :
- la `UI` parle bien au service applicatif, pas à un use case ;
- une mutation nouvelle ou sensible a bien été extraite en `UseCase` ;
- une `Command` a été introduite si elle clarifie l'entrée du workflow ;
- `Kernel/` n'a pas servi de raccourci pour ranger du code mono-domaine.
## Placement du code
Avant d'ajouter une classe dans `src/Kernel`, appliquer cette règle : **domain-first, kernel-last**.
Un élément a sa place dans `Kernel` uniquement s'il est réellement transverse, technique, ou réutilisé par plusieurs domaines. Si son sens reste principalement lié à un domaine métier, il doit rester dans ce domaine.
`Kernel/Support` est gelé par défaut : toute nouvelle entrée doit être justifiée comme réellement transverse, documentée dans `docs/ARCHITECTURE.md` et ajoutée explicitement à l'allow list des tests d'architecture. En cas d'hésitation, garder le code dans le domaine concerné.
Le bootstrap et l'infrastructure runtime doivent aussi rester découpés en étapes simples. Les composants transverses doivent respecter la séparation `Runtime / Http / Persistence|Mail|Html|Pagination|Support` : démarrage et composition dans `Runtime`, web dans `Http`, briques techniques dans les sous-modules transverses dédiés. Si un fichier de démarrage commence à porter plusieurs responsabilités distinctes, préférer extraire un composant ciblé plutôt que d'empiler de la logique.
## Formatage
Appliquer automatiquement :
```bash
composer cs:fix
```
Prévisualiser sans appliquer :
```bash
composer cs:check
```

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 NETig
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

107
README.md Normal file
View File

@@ -0,0 +1,107 @@
# netslim-core
`netslim-core` est le socle réutilisable de la plateforme Netslim.
Il contient :
- le `Kernel` technique ;
- les modules partageables `Identity`, `Settings`, `AuditLog`, `Notifications`, `Taxonomy` et `Media` ;
- les tests d'architecture et de modules du socle.
Ce dépôt est conçu pour être consommé par des projets applicatifs séparés via Composer.
## Installation depuis le dépôt Git en HTTPS
### Pendant le développement du core
```json
{
"repositories": [
{
"type": "vcs",
"url": "https://git.netig.net/netig/netslim-core.git"
}
],
"require": {
"netig/netslim-core": "^0.3@dev"
}
}
```
### Après la première release taguée
```json
{
"repositories": [
{
"type": "vcs",
"url": "https://git.netig.net/netig/netslim-core.git"
}
],
"require": {
"netig/netslim-core": "^0.1"
}
}
```
## Option locale pendant le développement
Pour développer le core et une application consommatrice côte à côte, un `path` repository local reste pratique, mais ce n'est pas le mode de consommation par défaut :
```json
{
"repositories": [
{
"type": "path",
"url": "../netslim-core"
}
],
"require": {
"netig/netslim-core": "^0.3@dev"
}
}
```
## Ce que doit fournir une application consommatrice
Le package ne porte pas d'application concrète. Un projet consommateur doit fournir au minimum :
- son propre `config/modules.php` ;
- son point d'entrée HTTP (`public/index.php`) ;
- ses templates applicatifs ;
- son pipeline d'assets.
Si l'application active `Identity` et exécute le provisionnement initial, elle doit aussi définir `ADMIN_USERNAME`, `ADMIN_EMAIL` et `ADMIN_PASSWORD` dans son `.env`. Ces variables ne sont plus exigées par le bootstrap du noyau seul.
Si l'application active `Notifications`, elle doit configurer `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_ENCRYPTION`, `MAIL_FROM` et `MAIL_FROM_NAME` pour permettre l'envoi effectif des emails transactionnels.
Les templates du socle supposent en particulier :
- une feuille de styles servie sous `/assets/css/main.css` pour les layouts `@Kernel` ;
- si l'UI admin du module `Media` est utilisée, un script servi sous `/assets/js/media-admin.js` ;
- les caches, logs, médias et la base SQLite sont toujours résolus depuis le projet consommateur (`var/`, `public/media/`, `database/`), jamais depuis le package installé dans `vendor/` ;
- la destination du back-office peut être redéfinie via `ADMIN_HOME_PATH` si l'application consommatrice n'utilise pas `/admin`.
## Surface publique
Le socle expose principalement :
- `Netig\Netslim\Kernel\...` pour le runtime public ;
- les interfaces applicatives documentées des modules partagés (`Netig\Netslim\Identity\Application\*ServiceInterface`, `Netig\Netslim\Settings\Application\SettingsServiceInterface`, `Netig\Netslim\AuditLog\Application\AuditLogServiceInterface`, `Netig\Netslim\Notifications\Application\NotificationServiceInterface`, `Netig\Netslim\Taxonomy\Application\TaxonomyServiceInterface`, `Netig\Netslim\Media\Application\MediaServiceInterface`) ;
- `Netig\Netslim\Settings\Contracts\...` ;
- `Netig\Netslim\AuditLog\Contracts\...` ;
- `Netig\Netslim\Notifications\Contracts\...` ;
- `Netig\Netslim\Taxonomy\Contracts\...` ;
- `Netig\Netslim\Media\Contracts\...` ;
- les classes `*Module` des modules partagés.
La frontière détaillée entre API publique et API interne est documentée dans [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md).
## Vérifications locales
```bash
composer install
composer qa
```
## Gouvernance du package
- `docs/PUBLIC_API.md` définit la frontière supportée entre le package et les projets consommateurs ;
> Quand `netslim-core` est installé via Composer, les chemins runtime détectent automatiquement la racine du projet consommateur pour les scripts CLI et les suites de tests qui n'appellent pas explicitement `Bootstrap::create()`.

69
composer.json Normal file
View File

@@ -0,0 +1,69 @@
{
"name": "netig/netslim-core",
"description": "Reusable kernel and shared modules for NETslim based applications.",
"license": "MIT",
"type": "library",
"require": {
"php": "^8.4",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"ext-session": "*",
"ext-simplexml": "*",
"slim/slim": "^4.13",
"slim/psr7": "^1.6",
"php-di/php-di": "^7.0",
"slim/twig-view": "^3.3",
"monolog/monolog": "^3.0",
"slim/csrf": "^1.3",
"ezyang/htmlpurifier": "^4.16",
"vlucas/phpdotenv": "^5.6",
"phpmailer/phpmailer": "^6.7"
},
"require-dev": {
"phpunit/phpunit": "^13.0",
"phpstan/phpstan": "^1.10",
"friendsofphp/php-cs-fixer": "^3.50"
},
"autoload": {
"psr-4": {
"Netig\\Netslim\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"test": "@php vendor/bin/phpunit",
"test:coverage": "@php -d xdebug.mode=coverage vendor/bin/phpunit --coverage-text",
"stan": "@php vendor/bin/phpstan analyse",
"cs:check": "@php vendor/bin/php-cs-fixer fix --dry-run --diff",
"cs:fix": "@php vendor/bin/php-cs-fixer fix",
"qa": [
"@test",
"@stan",
"@cs:check"
]
},
"scripts-descriptions": {
"test": "Run PHPUnit test suite",
"test:coverage": "Run PHPUnit with text coverage output (requires Xdebug)",
"stan": "Run PHPStan static analysis",
"cs:check": "Check coding style with PHP CS Fixer",
"cs:fix": "Fix coding style issues with PHP CS Fixer",
"qa": "Run quality checks"
},
"config": {
"sort-packages": true,
"optimize-autoloader": true,
"preferred-install": {
"*": "dist"
}
}
}

5892
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

5
config/modules.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
return require __DIR__ . '/../tests/Fixtures/Application/config/modules.php';

51
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,51 @@
# Architecture de netslim-core
`netslim-core` fournit un noyau technique et des modules réutilisables pour plusieurs applications.
## Contenu du package
- `src/Kernel/` : bootstrap, runtime, container DI, routing, migrations, Twig, checks de démarrage.
- `src/Identity/` : identité, authentification, comptes, autorisation et reset de mot de passe.
- `src/Settings/` : paramètres applicatifs clé / valeur typés.
- `src/AuditLog/` : journalisation des actions transverses.
- `src/Notifications/` : envoi et suivi des emails transactionnels.
- `src/Taxonomy/` : taxonomie réutilisable et contrats publics associés.
- `src/Media/` : médiathèque partagée et contrats publics associés.
## Principes
- un seul runtime applicatif par projet consommateur ;
- les projets consommateurs possèdent leurs templates, assets, points dentrée HTTP et leurs modules métier propres ;
- tous les chemins runtime persistants (logs, cache, base SQLite, médias) sont résolus depuis le projet consommateur ;
- les dépendances inter-modules passent par des contrats publics minimaux ;
- le noyau ne connaît pas le métier applicatif des projets consommateurs ;
- les exigences d'environnement métier (par exemple le provisionnement admin d'`Identity`) restent portées par les modules concernés, pas par le bootstrap du noyau.
## Surface publique et frontière package / application
Le package expose comme points d'intégration :
- `Netig\Netslim\Kernel\Runtime\...` pour le bootstrap et le runtime ;
- les interfaces applicatives publiques des modules partagés ;
- les contrats publics sous `*/Contracts/` ;
- les classes `*Module`.
Les implémentations `Infrastructure/`, les repositories PDO, les détails de wiring et les templates internes doivent être considérés comme des détails d'implémentation. Voir aussi `docs/PUBLIC_API.md`.
## Responsabilités d'une application consommatrice
Une application qui consomme `netslim-core` doit fournir :
- un manifeste de modules (`config/modules.php`) ;
- un point d'entrée HTTP ;
- les templates réellement applicatifs ;
- les assets attendus par les layouts et écrans qu'elle utilise.
Contrats implicites actuels côté UI :
- `@Kernel/layout.twig` attend `/assets/css/main.css` ;
- l'écran admin de `Media` attend `/assets/js/media-admin.js` côté projet consommateur ;
- `Identity` ne suppose pas la présence d'un module éditorial : la destination back-office se règle via `ADMIN_HOME_PATH` (défaut : `/admin`).
Contraintes d'environnement côté modules :
- `Identity` requiert `ADMIN_*` uniquement si le projet exécute le provisionnement initial du compte administrateur ;
- `Notifications` requiert les variables `MAIL_*` seulement si l'application active l'envoi réel d'emails transactionnels.
> Quand `netslim-core` est installé via Composer, les chemins runtime détectent automatiquement la racine du projet consommateur pour les scripts CLI et les suites de tests qui n'appellent pas explicitement `Bootstrap::create()`.

95
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,95 @@
# Développement
Ce document sert de guide quotidien pour travailler sur `netslim-core`.
## Démarrage local
Prérequis : PHP 8.4+, Composer, extensions `pdo`, `pdo_sqlite`, `fileinfo`, `gd`, `json`, `mbstring`, `session`, `dom`, `simplexml`.
```bash
composer install
composer qa
```
## Ce que contient le dépôt
- `Kernel/` : runtime, HTTP, persistence, mail, html, pagination, support
- `Identity/` : authentification, utilisateurs et autorisation transverse
- `Settings/` : paramètres applicatifs typés
- `AuditLog/` : journal d'audit partagé
- `Notifications/` : notifications et emails transactionnels
- `Taxonomy/` : taxons réutilisables
- `Media/` : médiathèque transverse
Le dépôt ne contient pas d'application concrète : c'est le socle partagé.
Une application consommatrice fournit son propre `config/modules.php`, son point d'entrée HTTP, ses templates applicatifs et ses assets.
Les répertoires runtime (`var/`, `database/`, `public/media/`) appartiennent toujours au projet consommateur, même quand le core est installé sous `vendor/`.
## Où placer une modification ?
### Backend
- `Domain/` : entités, règles métier, contrats métier
- `Application/` : cas d'usage, services applicatifs, commandes, ports
- `Infrastructure/` : implémentations concrètes, PDO, stockage, mail, bindings DI
- `UI/` : contrôleurs, request objects, templates, extensions Twig
### Code transverse
`Kernel/` reste limité au transverse et se découpe en sous-modules explicites : `Runtime/`, `Http/`, `Persistence/`, `Mail/`, `Html/`, `Pagination/` et `Support/`.
En cas d'hésitation, commencer dans le domaine concerné puis extraire vers `Kernel` à la deuxième vraie réutilisation.
### Checklist avant d'ajouter quelque chose dans `Kernel/Support`
Valider les trois points suivants :
- ce code sert déjà à au moins deux domaines ou représente une primitive transverse évidente ;
- il ne dépend ni d'un domaine métier, ni de Slim, Twig, PSR-7 ou d'une implémentation d'infrastructure ;
- la PR met à jour à la fois la documentation et l'allow list des tests d'architecture.
## Faire évoluer un domaine existant
Ordre recommandé :
1. adapter le contrat ou l'interface de service si nécessaire ;
2. décider si la modification reste une lecture simple ou mérite un `UseCase` + éventuelle `Command` ;
3. faire évoluer le repository ou le port technique ;
4. adapter le contrôleur et les vues sans court-circuiter le service applicatif ;
5. mettre à jour les tests et la documentation utile.
## Ajouter un nouveau domaine
Créer systématiquement :
1. l'entité et les contrats du domaine ;
2. l'interface de service ;
3. le repository interface ;
4. l'implémentation `Infrastructure/` ;
5. le contrôleur et les templates ;
6. `Infrastructure/dependencies.php` ;
7. `UI/Http/Routes.php` ;
8. `<Domaine>Module.php` ;
9. les tests dans `tests/<Domaine>/`.
Pour les frontières et dépendances autorisées, se reporter à [ARCHITECTURE.md](ARCHITECTURE.md).
## Vérifications avant push
```bash
composer test
composer stan
composer cs:check
```
Raccourci utile :
```bash
composer qa
```
## Variables d'environnement et provisionnement
Le bootstrap du noyau requiert seulement l'environnement technique commun (comme `APP_URL`). Les variables `ADMIN_*` sont nécessaires quand une application active `Identity` et exécute le provisionnement initial du compte administrateur. Les variables `MAIL_*` deviennent nécessaires uniquement si l'application active `Notifications` et envoie réellement des emails transactionnels.
> Quand `netslim-core` est installé via Composer, les chemins runtime détectent automatiquement la racine du projet consommateur pour les scripts CLI et les suites de tests qui n'appellent pas explicitement `Bootstrap::create()`.

35
docs/MODULES.md Normal file
View File

@@ -0,0 +1,35 @@
# Modules fournis par netslim-core
## Kernel
Socle technique : bootstrap, DI, routing, Twig, migrations, checks de démarrage.
Les layouts `@Kernel` fournissent la structure HTML partagée, mais l'application consommatrice reste responsable de servir ses assets front (`/assets/css/main.css`).
## Identity
Gestion des comptes, authentification, administration des utilisateurs et autorisation fine basée sur des permissions.
## Settings
Paramètres applicatifs clé / valeur typés, utilisables par plusieurs projets sans recréer un mini back-office de configuration dans chaque application.
## AuditLog
Journal d'audit transversal pour tracer les actions sensibles ou structurantes sur des ressources métier.
## Notifications
Envoi et suivi des emails transactionnels. Le module s'appuie sur le service mail du noyau et conserve un historique des envois réussis ou échoués.
Une application consommatrice qui active ce module doit fournir les variables d'environnement `MAIL_*` nécessaires au transport SMTP.
## Taxonomy
Gestion de termes de taxonomie réutilisables. Les modules consommateurs gardent la propriété des relations dusage.
## Media
Gestion de la médiathèque. Les modules consommateurs utilisent les contrats publics pour exposer les usages des médias.
L'UI admin de `Media` suppose qu'un projet consommateur serve un script applicatif sous `/assets/js/media-admin.js`.
Le module `Identity` peut être intégré sans module éditorial : la redirection vers le back-office après connexion ou refus d'autorisation est pilotée par `ADMIN_HOME_PATH` (défaut : `/admin`).
## Frontière publique
Les points d'intégration supportés pour une application consommatrice sont détaillés dans `docs/PUBLIC_API.md`.

44
docs/PUBLIC_API.md Normal file
View File

@@ -0,0 +1,44 @@
# API publique et API interne de netslim-core
Ce document définit la frontière de support du package `netslim-core`.
## API publique
Les applications consommatrices peuvent dépendre des éléments suivants :
- le namespace `Netig\Netslim\Kernel\Runtime\...` nécessaire au bootstrap, au runtime et à la découverte des modules ;
- les classes `*Module` exposées par les modules partagés (`IdentityModule`, `SettingsModule`, `AuditLogModule`, `NotificationsModule`, `TaxonomyModule`, `MediaModule`, `KernelModule`) ;
- les interfaces applicatives explicitement exposées par les modules (`Identity\Application\*ServiceInterface`, `Settings\Application\SettingsServiceInterface`, `AuditLog\Application\AuditLogServiceInterface`, `Notifications\Application\NotificationServiceInterface`, `Taxonomy\Application\TaxonomyServiceInterface`, `Media\Application\MediaServiceInterface`) ;
- les contrats publics sous `Netig\Netslim\*/Contracts/` ;
- les conventions documentées de `config/modules.php`, `public/index.php` et des chemins runtime résolus côté projet consommateur ;
- les layouts et partials Twig documentés sous `@Kernel/...` quand l'application choisit de les réutiliser.
## API interne
Les éléments suivants doivent être considérés comme des détails d'implémentation du package :
- tout `Infrastructure/` (repositories PDO, stockage local, wiring DI, maintenance post-migration) ;
- tout `Application/UseCase/` et les services applicatifs non documentés comme contrats ;
- les entités de domaine utilisées en interne par les modules ;
- les vérifications de démarrage propres aux modules ;
- les templates Twig non documentés comme points d'intégration.
Une application consommatrice ne devrait pas dépendre directement de ces éléments internes.
## Règle pratique
Quand un projet applicatif a besoin d'une capacité partagée, il doit préférer :
1. un contrat public existant dans `Contracts/` ;
2. à défaut, une extension documentée du runtime ou d'un module ;
3. en dernier recours, une évolution du core qui ajoute un nouveau point d'intégration public.
Éviter de se brancher directement sur des classes internes permet de garder le core versionnable et évolutif.
## Stabilité attendue
- l'API publique est la cible de compatibilité entre versions ;
- l'API interne peut évoluer à tout moment tant que le comportement public documenté reste cohérent ;
- tout nouveau point d'extension réutilisable doit être documenté ici ou dans `README.md` / `MODULES.md`.
## Versionnement

29
docs/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Documentation
Ce dossier contient les documents de référence du socle `netslim-core`.
## Par où commencer ?
- **Je découvre le dépôt** → [../README.md](../README.md)
- **Je veux comprendre les frontières et dépendances** → [ARCHITECTURE.md](ARCHITECTURE.md)
- **Je veux connaître les conventions de module** → [MODULES.md](MODULES.md)
- **Je veux savoir ce qui est public ou interne** → [PUBLIC_API.md](PUBLIC_API.md)
- **Je travaille au quotidien sur le dépôt** → [DEVELOPMENT.md](DEVELOPMENT.md)
- **Je contribue au dépôt** → [../CONTRIBUTING.md](../CONTRIBUTING.md)
## Si tu ne lis que trois documents
1. [../README.md](../README.md) pour comprendre ce que contient le dépôt
2. [ARCHITECTURE.md](ARCHITECTURE.md) avant toute évolution structurelle
3. [PUBLIC_API.md](PUBLIC_API.md) avant d'utiliser le core comme dépendance applicative
## Rôle de chaque document
| Document | Rôle |
|---|---|
| [../README.md](../README.md) | Vue d'ensemble et mode d'installation du socle |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Référence sur les modules, les couches et les dépendances autorisées |
| [MODULES.md](MODULES.md) | Charte de module et conventions de frontière |
| [PUBLIC_API.md](PUBLIC_API.md) | Délimitation de l'API publique et de l'API interne |
| [DEVELOPMENT.md](DEVELOPMENT.md) | Guide de travail quotidien, variables d'environnement et checklist avant push |
| [../CONTRIBUTING.md](../CONTRIBUTING.md) | Règles de contribution et attentes sur les tests |

6
phpstan.neon Normal file
View File

@@ -0,0 +1,6 @@
parameters:
level: 8
paths:
- src
excludePaths:
- src/Kernel/Runtime/Bootstrap.php

29
phpunit.xml Normal file
View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnPhpunitDeprecations="true"
displayDetailsOnPhpunitNotices="true"
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true">
<testsuites>
<testsuite name="netslim">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
<exclude>
<file>src/Kernel/Runtime/Bootstrap.php</file>
<file>src/Kernel/Runtime/Routing/Routes.php</file>
</exclude>
</source>
</phpunit>

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\AuditLog\Application;
use Netig\Netslim\AuditLog\Contracts\AuditEntryView;
use Netig\Netslim\AuditLog\Domain\Entity\AuditEntry;
use Netig\Netslim\AuditLog\Domain\Repository\AuditLogRepositoryInterface;
/**
* Façade applicative du module AuditLog.
*/
final class AuditLogApplicationService implements AuditLogServiceInterface
{
public function __construct(private readonly AuditLogRepositoryInterface $repository) {}
public function record(
string $action,
string $resourceType,
string $resourceId,
?int $actorUserId = null,
array $context = [],
): void {
$this->repository->create(new AuditEntry(
id: null,
action: $action,
resourceType: $resourceType,
resourceId: $resourceId,
actorUserId: $actorUserId,
context: $context,
createdAt: new \DateTimeImmutable(),
));
}
/**
* @return list<AuditEntryView>
*/
public function listRecent(int $limit = 50, ?string $resourceType = null, ?string $resourceId = null): array
{
return array_map(
static fn (AuditEntry $entry): AuditEntryView => new AuditEntryView(
id: $entry->getId() ?? 0,
action: $entry->getAction(),
resourceType: $entry->getResourceType(),
resourceId: $entry->getResourceId(),
actorUserId: $entry->getActorUserId(),
context: $entry->getContext(),
createdAt: $entry->getCreatedAt()->format(DATE_ATOM),
),
$this->repository->findRecent($limit, $resourceType, $resourceId),
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\AuditLog\Application;
use Netig\Netslim\AuditLog\Contracts\AuditLoggerInterface;
use Netig\Netslim\AuditLog\Contracts\AuditLogReaderInterface;
/**
* Façade applicative du module AuditLog.
*/
interface AuditLogServiceInterface extends AuditLoggerInterface, AuditLogReaderInterface {}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\AuditLog;
use Netig\Netslim\Kernel\Runtime\Module\ModuleInterface;
use Netig\Netslim\Kernel\Runtime\Module\ProvidesSchemaInterface;
use Psr\Container\ContainerInterface;
use Slim\App;
/**
* Journal d'audit des actions métier et techniques.
*/
final class AuditLogModule implements ModuleInterface, ProvidesSchemaInterface
{
public function definitions(): array
{
return require __DIR__ . '/Infrastructure/dependencies.php';
}
/** @param App<ContainerInterface> $app */
public function registerRoutes(App $app): void {}
public function templateNamespaces(): array
{
return [];
}
public function twigExtensions(): array
{
return [];
}
public function migrationDirectories(): array
{
return [__DIR__ . '/Migrations'];
}
public function requiredTables(): array
{
return ['audit_log'];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\AuditLog\Contracts;
final readonly class AuditEntryView
{
/** @param array<string, mixed> $context */
public function __construct(
public int $id,
public string $action,
public string $resourceType,
public string $resourceId,
public ?int $actorUserId,
public array $context,
public string $createdAt,
) {}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\AuditLog\Contracts;
/**
* Lecture publique du journal d'audit.
*/
interface AuditLogReaderInterface
{
/** @return list<AuditEntryView> */
public function listRecent(int $limit = 50, ?string $resourceType = null, ?string $resourceId = null): array;
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\AuditLog\Contracts;
/**
* Écriture publique dans le journal d'audit.
*/
interface AuditLoggerInterface
{
/**
* @param array<string, mixed> $context
*/
public function record(
string $action,
string $resourceType,
string $resourceId,
?int $actorUserId = null,
array $context = [],
): void;
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\AuditLog\Domain\Entity;
/**
* Entrée du journal d'audit.
*/
final class AuditEntry
{
/**
* @param array<string, mixed> $context
*/
public function __construct(
private readonly ?int $id,
private readonly string $action,
private readonly string $resourceType,
private readonly string $resourceId,
private readonly ?int $actorUserId,
private readonly array $context,
private readonly \DateTimeImmutable $createdAt,
) {
if (trim($this->action) === '' || trim($this->resourceType) === '' || trim($this->resourceId) === '') {
throw new \InvalidArgumentException('Une entrée daudit doit définir action, type de ressource et identifiant de ressource.');
}
}
/**
* @param array{id:int,action:string,resource_type:string,resource_id:string,actor_user_id:int|null,context_json:string|null,created_at:string} $row
*/
public static function fromRow(array $row): self
{
return new self(
id: (int) $row['id'],
action: $row['action'],
resourceType: $row['resource_type'],
resourceId: $row['resource_id'],
actorUserId: isset($row['actor_user_id']) ? (int) $row['actor_user_id'] : null,
context: $row['context_json'] !== null && $row['context_json'] !== ''
? json_decode($row['context_json'], true, flags: JSON_THROW_ON_ERROR)
: [],
createdAt: new \DateTimeImmutable($row['created_at']),
);
}
public function getId(): ?int
{
return $this->id;
}
public function getAction(): string
{
return $this->action;
}
public function getResourceType(): string
{
return $this->resourceType;
}
public function getResourceId(): string
{
return $this->resourceId;
}
public function getActorUserId(): ?int
{
return $this->actorUserId;
}
/**
* @return array<string, mixed>
*/
public function getContext(): array
{
return $this->context;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\AuditLog\Domain\Repository;
use Netig\Netslim\AuditLog\Domain\Entity\AuditEntry;
interface AuditLogRepositoryInterface
{
public function create(AuditEntry $entry): void;
/** @return list<AuditEntry> */
public function findRecent(int $limit = 50, ?string $resourceType = null, ?string $resourceId = null): array;
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\AuditLog\Infrastructure;
use Netig\Netslim\AuditLog\Domain\Entity\AuditEntry;
use Netig\Netslim\AuditLog\Domain\Repository\AuditLogRepositoryInterface;
use PDO;
/**
* Repository PDO du journal d'audit.
*/
final class PdoAuditLogRepository implements AuditLogRepositoryInterface
{
public function __construct(private readonly PDO $db) {}
public function create(AuditEntry $entry): void
{
$stmt = $this->db->prepare(
'INSERT INTO audit_log (action, resource_type, resource_id, actor_user_id, context_json, created_at)
VALUES (:action, :resource_type, :resource_id, :actor_user_id, :context_json, :created_at)',
);
$stmt->execute([
':action' => $entry->getAction(),
':resource_type' => $entry->getResourceType(),
':resource_id' => $entry->getResourceId(),
':actor_user_id' => $entry->getActorUserId(),
':context_json' => $entry->getContext() === [] ? null : json_encode($entry->getContext(), JSON_THROW_ON_ERROR),
':created_at' => $entry->getCreatedAt()->format('Y-m-d H:i:s'),
]);
}
/**
* @return list<AuditEntry>
*/
public function findRecent(int $limit = 50, ?string $resourceType = null, ?string $resourceId = null): array
{
$sql = 'SELECT id, action, resource_type, resource_id, actor_user_id, context_json, created_at FROM audit_log';
$conditions = [];
$params = [];
if ($resourceType !== null) {
$conditions[] = 'resource_type = :resource_type';
$params[':resource_type'] = $resourceType;
}
if ($resourceId !== null) {
$conditions[] = 'resource_id = :resource_id';
$params[':resource_id'] = $resourceId;
}
if ($conditions !== []) {
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}
$sql .= ' ORDER BY id DESC LIMIT :limit';
$stmt = $this->db->prepare($sql);
foreach ($params as $name => $value) {
$stmt->bindValue($name, $value);
}
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$stmt->execute();
/** @var list<array{id:int,action:string,resource_type:string,resource_id:string,actor_user_id:int|null,context_json:string|null,created_at:string}> $rows */
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(static fn (array $row): AuditEntry => AuditEntry::fromRow($row), $rows);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use function DI\autowire;
use Netig\Netslim\AuditLog\Application\AuditLogApplicationService;
use Netig\Netslim\AuditLog\Application\AuditLogServiceInterface;
use Netig\Netslim\AuditLog\Contracts\AuditLoggerInterface;
use Netig\Netslim\AuditLog\Contracts\AuditLogReaderInterface;
use Netig\Netslim\AuditLog\Domain\Repository\AuditLogRepositoryInterface;
use Netig\Netslim\AuditLog\Infrastructure\PdoAuditLogRepository;
return [
AuditLogServiceInterface::class => autowire(AuditLogApplicationService::class),
AuditLoggerInterface::class => autowire(AuditLogApplicationService::class),
AuditLogReaderInterface::class => autowire(AuditLogApplicationService::class),
AuditLogRepositoryInterface::class => autowire(PdoAuditLogRepository::class),
];

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
actor_user_id INTEGER DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL,
context_json TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id);
",
'down' => "
DROP INDEX IF EXISTS idx_audit_log_actor;
DROP INDEX IF EXISTS idx_audit_log_resource;
DROP TABLE IF EXISTS audit_log;
",
];

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application;
use Netig\Netslim\Identity\Application\Command\AuthenticateUserCommand;
use Netig\Netslim\Identity\Application\Command\ChangePasswordCommand;
use Netig\Netslim\Identity\Application\UseCase\AuthenticateUser;
use Netig\Netslim\Identity\Application\UseCase\ChangePassword;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
use Netig\Netslim\Identity\Domain\Repository\LoginAttemptRepositoryInterface;
/**
* Façade applicative de l'authentification.
*
* Elle conserve les lectures de session et de rate limit, puis délègue les
* workflows sensibles (`authenticate`, `changePassword`) à des use cases dédiés.
*/
final class AuthApplicationService implements AuthServiceInterface
{
private const PASSWORD_RESET_IP_SCOPE = 'password_reset_ip';
public function __construct(
private readonly AuthSessionInterface $authSession,
private readonly LoginAttemptRepositoryInterface $loginAttemptRepository,
private readonly LoginRateLimitPolicy $rateLimitPolicy,
private readonly AuthenticateUser $authenticateUser,
private readonly ChangePassword $changePassword,
) {}
public function checkRateLimit(string $ip): int
{
$this->loginAttemptRepository->deleteExpired();
return $this->remainingMinutesForRow($this->loginAttemptRepository->findByIp($ip));
}
public function recordFailure(string $ip): void
{
$this->loginAttemptRepository->recordFailure($ip, $this->rateLimitPolicy->maxAttempts(), $this->rateLimitPolicy->lockMinutes());
}
public function resetRateLimit(string $ip): void
{
$this->loginAttemptRepository->resetForIp($ip);
}
public function checkPasswordResetRateLimit(string $ip): int
{
$this->loginAttemptRepository->deleteExpired();
return $this->remainingMinutesForRow(
$this->loginAttemptRepository->findByScopeAndKey(self::PASSWORD_RESET_IP_SCOPE, $ip),
);
}
public function recordPasswordResetAttempt(string $ip): void
{
$this->loginAttemptRepository->recordFailureForScope(
self::PASSWORD_RESET_IP_SCOPE,
$ip,
$this->rateLimitPolicy->passwordResetMaxAttemptsPerIp(),
$this->rateLimitPolicy->passwordResetLockMinutesPerIp(),
);
}
public function authenticate(string $username, string $plainPassword): ?User
{
return $this->authenticateUser->handle(new AuthenticateUserCommand($username, $plainPassword));
}
public function changePassword(int $userId, string $currentPassword, string $newPassword): void
{
$this->changePassword->handle(new ChangePasswordCommand($userId, $currentPassword, $newPassword));
}
public function isLoggedIn(): bool
{
return $this->authSession->isAuthenticated();
}
public function login(User $user): void
{
$this->authSession->startForUser($user);
}
public function logout(): void
{
$this->authSession->clear();
}
/**
* Convertit une ligne de verrouillage en nombre de minutes restantes.
*
* @param array{locked_until:string|null}|null $row
*/
private function remainingMinutesForRow(?array $row): int
{
if ($row === null || $row['locked_until'] === null) {
return 0;
}
$lockedUntil = new \DateTime($row['locked_until']);
$now = new \DateTime();
if ($lockedUntil <= $now) {
return 0;
}
$secondsLeft = $lockedUntil->getTimestamp() - $now->getTimestamp();
return max(1, (int) ceil($secondsLeft / 60));
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
/**
* Contrat du service d'authentification.
*
* Gère l'ouverture et la fermeture de session, la gestion du verrouillage par adresse IP
* et le changement de mot de passe de l'utilisateur authentifié.
*/
interface AuthServiceInterface
{
/**
* Retourne l'état du verrouillage de connexion pour une adresse IP.
*
* @return int Nombre de minutes restantes avant déverrouillage, ou `0` si l'IP peut tenter une connexion.
*/
public function checkRateLimit(string $ip): int;
/**
* Enregistre une tentative de connexion échouée pour une adresse IP.
*/
public function recordFailure(string $ip): void;
/**
* Réinitialise le compteur de tentatives de connexion pour une adresse IP.
*/
public function resetRateLimit(string $ip): void;
/**
* Retourne l'état du verrouillage de demande de réinitialisation pour une adresse IP.
*
* @return int Nombre de minutes restantes avant déverrouillage, ou `0` si l'IP peut faire une nouvelle demande.
*/
public function checkPasswordResetRateLimit(string $ip): int;
/**
* Enregistre une demande de réinitialisation pour une adresse IP.
*/
public function recordPasswordResetAttempt(string $ip): void;
/**
* Tente d'authentifier un utilisateur à partir de son identifiant et de son mot de passe.
*
* @return User|null L'utilisateur authentifié, ou `null` si les identifiants sont invalides.
*/
public function authenticate(string $username, string $plainPassword): ?User;
/**
* Change le mot de passe d'un utilisateur après vérification du mot de passe actuel.
*
* @throws NotFoundException Si l'utilisateur n'existe pas.
* @throws \InvalidArgumentException Si le mot de passe actuel ne correspond pas.
* @throws WeakPasswordException Si le nouveau mot de passe ne respecte pas la politique minimale.
*/
public function changePassword(int $userId, string $currentPassword, string $newPassword): void;
/**
* Indique si une session authentifiée est actuellement active.
*/
public function isLoggedIn(): bool;
/**
* Ouvre une session pour l'utilisateur fourni.
*/
public function login(User $user): void;
/**
* Déconnecte l'utilisateur courant et détruit la session associée.
*/
public function logout(): void;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application;
use Netig\Netslim\Identity\Domain\Entity\User;
/**
* Port d'accès à la session d'authentification.
*
* Il permet à la couche applicative de gérer la connexion sans dépendre du
* mécanisme HTTP concret utilisé pour stocker la session.
*/
interface AuthSessionInterface
{
/**
* Indique si une session authentifiée est active.
*/
public function isAuthenticated(): bool;
/**
* Ouvre la session pour l'utilisateur fourni.
*/
public function startForUser(User $user): void;
/**
* Supprime la session authentifiée courante.
*/
public function clear(): void;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Policy\RolePermissionMatrix;
/**
* Façade applicative de l'autorisation fine.
*
* Le service encapsule la matrice rôle -> permissions partagée par le core et
* constitue le point d'entrée recommandé pour les applications consommatrices.
*/
final class AuthorizationApplicationService implements AuthorizationServiceInterface
{
public function __construct(private readonly RolePermissionMatrix $permissions) {}
public function canRole(string $role, string $permission): bool
{
return $this->permissions->allows($role, $permission);
}
public function canUser(User $user, string $permission): bool
{
return $this->permissions->allows($user->getRole(), $permission);
}
/**
* @return list<string>
*/
public function permissionsForRole(string $role): array
{
return $this->permissions->permissionsForRole($role);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application;
use Netig\Netslim\Identity\Domain\Entity\User;
/**
* Service d'autorisation à base de permissions.
*/
interface AuthorizationServiceInterface
{
public function canRole(string $role, string $permission): bool;
public function canUser(User $user, string $permission): bool;
/** @return list<string> */
public function permissionsForRole(string $role): array;
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\Command;
final readonly class AdminDeleteUserCommand
{
public function __construct(
public int $actorUserId,
public int $targetUserId,
) {}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\Command;
final readonly class AdminUpdateUserRoleCommand
{
public function __construct(
public int $actorUserId,
public int $targetUserId,
public string $role,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\Command;
/**
* DTO d'entrée du use case AuthenticateUser.
*/
final readonly class AuthenticateUserCommand
{
public function __construct(
public string $username,
public string $plainPassword,
) {}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\Command;
/**
* DTO d'entrée du use case ChangePassword.
*/
final readonly class ChangePasswordCommand
{
public function __construct(
public int $userId,
public string $currentPassword,
public string $newPassword,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\Command;
use Netig\Netslim\Identity\Domain\Entity\User;
/**
* Commande applicative décrivant la création d'un utilisateur.
*/
final readonly class CreateUserCommand
{
public function __construct(
public string $username,
public string $email,
public string $plainPassword,
public string $role = User::ROLE_USER,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\Command;
/**
* DTO d'entrée du use case RequestPasswordReset.
*/
final readonly class RequestPasswordResetCommand
{
public function __construct(
public string $email,
public string $baseUrl,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\Command;
/**
* DTO d'entrée du use case ResetPassword.
*/
final readonly class ResetPasswordCommand
{
public function __construct(
public string $tokenRaw,
public string $newPassword,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\Command;
/**
* Commande applicative de changement de rôle utilisateur.
*/
final readonly class UpdateUserRoleCommand
{
public function __construct(
public int $id,
public string $role,
) {}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application;
use Netig\Netslim\Identity\Application\Command\RequestPasswordResetCommand;
use Netig\Netslim\Identity\Application\Command\ResetPasswordCommand;
use Netig\Netslim\Identity\Application\UseCase\RequestPasswordReset;
use Netig\Netslim\Identity\Application\UseCase\ResetPassword;
use Netig\Netslim\Identity\Application\UseCase\ValidatePasswordResetToken;
use Netig\Netslim\Identity\Domain\Entity\User;
/**
* Façade applicative du flux de réinitialisation de mot de passe.
*
* Le service expose la lecture simple (`validateToken`) et délègue les
* workflows sensibles de génération et de consommation de token à des use cases.
*/
final class PasswordResetApplicationService implements PasswordResetServiceInterface
{
public function __construct(
private readonly RequestPasswordReset $requestPasswordReset,
private readonly ValidatePasswordResetToken $validatePasswordResetToken,
private readonly ResetPassword $resetPassword,
) {}
public function requestReset(string $email, string $baseUrl): void
{
$this->requestPasswordReset->handle(new RequestPasswordResetCommand($email, $baseUrl));
}
public function validateToken(string $tokenRaw): ?User
{
return $this->validatePasswordResetToken->handle($tokenRaw);
}
public function resetPassword(string $tokenRaw, string $newPassword): void
{
$this->resetPassword->handle(new ResetPasswordCommand($tokenRaw, $newPassword));
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\InvalidResetTokenException;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
/**
* Contrat applicatif du flux de réinitialisation de mot de passe.
*/
interface PasswordResetServiceInterface
{
/**
* Génère un token actif et envoie le lien de réinitialisation par e-mail.
*
* L'opération reste silencieuse si l'adresse e-mail ne correspond à aucun compte,
* afin de ne pas révéler l'existence d'un utilisateur.
*
* @throws \RuntimeException Si l'e-mail ne peut pas être envoyé.
*/
public function requestReset(string $email, string $baseUrl): void;
/**
* Valide un token brut reçu depuis l'URL de réinitialisation.
*
* @return User|null L'utilisateur associé à un token valide, ou `null` si le token est invalide, expiré ou déjà consommé.
*/
public function validateToken(string $tokenRaw): ?User;
/**
* Réinitialise le mot de passe associé à un token valide et consomme ce token.
*
* @throws InvalidResetTokenException Si le token est invalide, expiré ou déjà consommé.
* @throws WeakPasswordException Si le nouveau mot de passe ne respecte pas la politique minimale.
*/
public function resetPassword(string $tokenRaw, string $newPassword): void;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\UseCase;
use Netig\Netslim\Identity\Application\Command\AdminDeleteUserCommand;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\CannotDeleteOwnAccountException;
use Netig\Netslim\Identity\Domain\Exception\ProtectedAdministratorDeletionException;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
final readonly class AdminDeleteUser
{
public function __construct(private UserRepositoryInterface $userRepository) {}
public function handle(AdminDeleteUserCommand $command): User
{
$user = $this->userRepository->findById($command->targetUserId);
if ($user === null) {
throw new NotFoundException('Utilisateur', $command->targetUserId);
}
if ($user->isAdmin()) {
throw new ProtectedAdministratorDeletionException();
}
if ($command->targetUserId === $command->actorUserId) {
throw new CannotDeleteOwnAccountException();
}
$this->userRepository->delete($command->targetUserId);
return $user;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\UseCase;
use Netig\Netslim\Identity\Application\Command\AdminUpdateUserRoleCommand;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\CannotModifyOwnRoleException;
use Netig\Netslim\Identity\Domain\Exception\ProtectedAdministratorRoleChangeException;
use Netig\Netslim\Identity\Domain\Policy\RolePolicy;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
final readonly class AdminUpdateUserRole
{
public function __construct(
private UserRepositoryInterface $userRepository,
private RolePolicy $rolePolicy,
) {}
public function handle(AdminUpdateUserRoleCommand $command): User
{
$user = $this->userRepository->findById($command->targetUserId);
if ($user === null) {
throw new NotFoundException('Utilisateur', $command->targetUserId);
}
if ($command->targetUserId === $command->actorUserId) {
throw new CannotModifyOwnRoleException();
}
if ($user->isAdmin()) {
throw new ProtectedAdministratorRoleChangeException();
}
$this->rolePolicy->assertAssignable($command->role);
$this->userRepository->updateRole($command->targetUserId, $command->role);
return $user;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\UseCase;
use Netig\Netslim\Identity\Application\Command\AuthenticateUserCommand;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
/**
* Use case d'authentification avec normalisation de l'identifiant et rehash transparent.
*/
final readonly class AuthenticateUser
{
public function __construct(
private UserRepositoryInterface $userRepository,
private PasswordPolicy $passwordPolicy,
) {}
/**
* Retourne l'utilisateur authentifié, ou null si le couple identifiant/mot de passe est invalide.
*/
public function handle(AuthenticateUserCommand $command): ?User
{
$user = $this->userRepository->findByUsername(mb_strtolower(trim($command->username)));
if ($user === null) {
return null;
}
if (!password_verify($command->plainPassword, $user->getPasswordHash())) {
return null;
}
if ($this->passwordPolicy->needsRehash($user->getPasswordHash())) {
$this->userRepository->updatePassword(
$user->getId(),
$this->passwordPolicy->hash($command->plainPassword),
false,
);
}
return $user;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\UseCase;
use Netig\Netslim\Identity\Application\Command\ChangePasswordCommand;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
/**
* Use case de changement de mot de passe pour un utilisateur existant.
*/
final readonly class ChangePassword
{
public function __construct(
private UserRepositoryInterface $userRepository,
private PasswordPolicy $passwordPolicy,
) {}
/**
* @throws NotFoundException
* @throws \InvalidArgumentException
*/
public function handle(ChangePasswordCommand $command): void
{
$user = $this->userRepository->findById($command->userId);
if ($user === null) {
throw new NotFoundException('Utilisateur', $command->userId);
}
if (!password_verify($command->currentPassword, $user->getPasswordHash())) {
throw new \InvalidArgumentException('Mot de passe actuel incorrect');
}
$this->passwordPolicy->assert($command->newPassword);
$this->userRepository->updatePassword(
$command->userId,
$this->passwordPolicy->hash($command->newPassword),
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\UseCase;
use Netig\Netslim\Identity\Application\Command\CreateUserCommand;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\DuplicateEmailException;
use Netig\Netslim\Identity\Domain\Exception\DuplicateUsernameException;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use Netig\Netslim\Identity\Domain\Policy\RolePolicy;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
/**
* Cas d'usage de création d'un utilisateur avec validations métier.
*/
final readonly class CreateUser
{
public function __construct(
private UserRepositoryInterface $userRepository,
private RolePolicy $rolePolicy,
private PasswordPolicy $passwordPolicy,
) {}
/**
* Crée un utilisateur et retourne l'entité persistée en mémoire.
*
* @throws DuplicateUsernameException
* @throws DuplicateEmailException
*/
public function handle(CreateUserCommand $command): User
{
$username = mb_strtolower(trim($command->username));
$email = mb_strtolower(trim($command->email));
$plainPassword = $command->plainPassword;
$this->rolePolicy->assertAssignable($command->role);
if ($this->userRepository->findByUsername($username)) {
throw new DuplicateUsernameException($username);
}
if ($this->userRepository->findByEmail($email)) {
throw new DuplicateEmailException($email);
}
$this->passwordPolicy->assert($plainPassword);
$passwordHash = $this->passwordPolicy->hash($plainPassword);
$user = new User(0, $username, $email, $passwordHash, $command->role);
$this->userRepository->create($user);
return $user;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\UseCase;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
/**
* Cas d'usage de suppression d'un utilisateur existant.
*/
final readonly class DeleteUser
{
public function __construct(private UserRepositoryInterface $userRepository) {}
public function handle(int $id): void
{
if ($this->userRepository->findById($id) === null) {
throw new NotFoundException('Utilisateur', $id);
}
$this->userRepository->delete($id);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\UseCase;
use Netig\Netslim\Identity\Application\Command\RequestPasswordResetCommand;
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
use Netig\Netslim\Identity\Domain\Policy\PasswordResetTokenPolicy;
use Netig\Netslim\Identity\Domain\Repository\LoginAttemptRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\PasswordResetRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Mail\Application\MailServiceInterface;
/**
* Use case de demande de réinitialisation avec anti-énumération et cooldown e-mail.
*/
final readonly class RequestPasswordReset
{
private const PASSWORD_RESET_EMAIL_SCOPE = 'password_reset_email';
public function __construct(
private PasswordResetRepositoryInterface $passwordResetRepository,
private UserRepositoryInterface $userRepository,
private MailServiceInterface $mailService,
private PasswordResetTokenPolicy $tokenPolicy,
private LoginAttemptRepositoryInterface $loginAttemptRepository,
private LoginRateLimitPolicy $rateLimitPolicy,
) {}
public function handle(RequestPasswordResetCommand $command): void
{
$normalizedEmail = mb_strtolower(trim($command->email));
if ($normalizedEmail === '') {
return;
}
if ($this->isPasswordResetEmailCoolingDown($normalizedEmail)) {
return;
}
$this->markPasswordResetEmailRequest($normalizedEmail);
$user = $this->userRepository->findByEmail($normalizedEmail);
if ($user === null) {
return;
}
$this->passwordResetRepository->invalidateByUserId($user->getId());
$tokenRaw = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $tokenRaw);
$expiresAt = date('Y-m-d H:i:s', time() + $this->tokenPolicy->ttlMinutes() * 60);
$this->passwordResetRepository->create($user->getId(), $tokenHash, $expiresAt);
$resetUrl = rtrim($command->baseUrl, '/') . '/password/reset?token=' . $tokenRaw;
$this->mailService->send(
to: $user->getEmail(),
subject: 'Réinitialisation de votre mot de passe',
template: '@Identity/emails/password-reset.twig',
context: [
'username' => $user->getUsername(),
'resetUrl' => $resetUrl,
],
);
}
/**
* Vérifie si un cooldown e-mail est déjà actif pour cette adresse.
*/
private function isPasswordResetEmailCoolingDown(string $email): bool
{
$this->loginAttemptRepository->deleteExpired();
$row = $this->loginAttemptRepository->findByScopeAndKey(
self::PASSWORD_RESET_EMAIL_SCOPE,
$this->emailRateLimitKey($email),
);
if ($row === null || $row['locked_until'] === null) {
return false;
}
return new \DateTime($row['locked_until']) > new \DateTime();
}
/**
* Enregistre la demande pour activer le cooldown anti-spam par e-mail.
*/
private function markPasswordResetEmailRequest(string $email): void
{
$this->loginAttemptRepository->recordFailureForScope(
self::PASSWORD_RESET_EMAIL_SCOPE,
$this->emailRateLimitKey($email),
1,
$this->rateLimitPolicy->passwordResetEmailCooldownMinutes(),
);
}
/**
* Produit une clé stable pour le cooldown e-mail sans exposer l'adresse brute.
*/
private function emailRateLimitKey(string $email): string
{
return hash('sha256', $email);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\UseCase;
use Netig\Netslim\Identity\Application\Command\ResetPasswordCommand;
use Netig\Netslim\Identity\Domain\Exception\InvalidResetTokenException;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use Netig\Netslim\Identity\Domain\Repository\PasswordResetRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Persistence\Application\TransactionManagerInterface;
/**
* Use case de consommation atomique d'un token de réinitialisation.
*/
final readonly class ResetPassword
{
public function __construct(
private PasswordResetRepositoryInterface $passwordResetRepository,
private UserRepositoryInterface $userRepository,
private TransactionManagerInterface $transactionManager,
private PasswordPolicy $passwordPolicy,
) {}
/**
* @throws InvalidResetTokenException
*/
public function handle(ResetPasswordCommand $command): void
{
$this->passwordPolicy->assert($command->newPassword);
$usedAt = date('Y-m-d H:i:s');
$newHash = $this->passwordPolicy->hash($command->newPassword);
$this->transactionManager->run(function () use ($command, $usedAt, $newHash): void {
$row = $this->passwordResetRepository->consumeActiveToken(hash('sha256', $command->tokenRaw), $usedAt);
if ($row === null) {
throw new InvalidResetTokenException();
}
$user = $this->userRepository->findById((int) $row['user_id']);
if ($user === null) {
throw new InvalidResetTokenException();
}
$this->userRepository->updatePassword($user->getId(), $newHash);
});
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\UseCase;
use Netig\Netslim\Identity\Application\Command\UpdateUserRoleCommand;
use Netig\Netslim\Identity\Domain\Policy\RolePolicy;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
/**
* Cas d'usage de changement de rôle pour un utilisateur existant.
*/
final readonly class UpdateUserRole
{
public function __construct(
private UserRepositoryInterface $userRepository,
private RolePolicy $rolePolicy,
) {}
public function handle(UpdateUserRoleCommand $command): void
{
$this->rolePolicy->assertAssignable($command->role);
if ($this->userRepository->findById($command->id) === null) {
throw new NotFoundException('Utilisateur', $command->id);
}
$this->userRepository->updateRole($command->id, $command->role);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application\UseCase;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Repository\PasswordResetRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
/**
* Use case de validation d'un token de réinitialisation encore actif.
*/
final readonly class ValidatePasswordResetToken
{
public function __construct(
private PasswordResetRepositoryInterface $passwordResetRepository,
private UserRepositoryInterface $userRepository,
) {}
/**
* Retourne l'utilisateur associé au token, ou null si le token n'est plus exploitable.
*/
public function handle(string $tokenRaw): ?User
{
$row = $this->passwordResetRepository->findActiveByHash(hash('sha256', $tokenRaw));
if ($row === null) {
return null;
}
if (strtotime((string) $row['expires_at']) < time()) {
return null;
}
return $this->userRepository->findById((int) $row['user_id']);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application;
use Netig\Netslim\Identity\Application\Command\AdminDeleteUserCommand;
use Netig\Netslim\Identity\Application\Command\AdminUpdateUserRoleCommand;
use Netig\Netslim\Identity\Application\Command\CreateUserCommand;
use Netig\Netslim\Identity\Application\Command\UpdateUserRoleCommand;
use Netig\Netslim\Identity\Application\UseCase\AdminDeleteUser;
use Netig\Netslim\Identity\Application\UseCase\AdminUpdateUserRole;
use Netig\Netslim\Identity\Application\UseCase\CreateUser;
use Netig\Netslim\Identity\Application\UseCase\DeleteUser;
use Netig\Netslim\Identity\Application\UseCase\UpdateUserRole;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
final class UserApplicationService implements UserServiceInterface
{
public function __construct(
private readonly UserRepositoryInterface $userRepository,
private readonly CreateUser $createUser,
private readonly UpdateUserRole $updateUserRole,
private readonly DeleteUser $deleteUser,
private readonly AdminUpdateUserRole $adminUpdateUserRole,
private readonly AdminDeleteUser $adminDeleteUser,
) {}
/** @return User[] */
public function findAll(): array
{
return $this->userRepository->findAll();
}
/** @return PaginatedResult<User> */
public function findPaginated(int $page, int $perPage): PaginatedResult
{
$page = max(1, $page);
$total = $this->userRepository->countAll();
$offset = ($page - 1) * $perPage;
return new PaginatedResult(
$this->userRepository->findPage($perPage, $offset),
$total,
$page,
$perPage,
);
}
public function findById(int $id): ?User
{
return $this->userRepository->findById($id);
}
public function delete(int $id): void
{
$this->deleteUser->handle($id);
}
public function deleteFromAdministration(int $actorUserId, int $targetUserId): User
{
return $this->adminDeleteUser->handle(new AdminDeleteUserCommand($actorUserId, $targetUserId));
}
public function updateRole(int $id, string $role): void
{
$this->updateUserRole->handle(new UpdateUserRoleCommand($id, $role));
}
public function updateRoleFromAdministration(int $actorUserId, int $targetUserId, string $role): User
{
return $this->adminUpdateUserRole->handle(new AdminUpdateUserRoleCommand($actorUserId, $targetUserId, $role));
}
public function create(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User
{
return $this->createUser->handle(new CreateUserCommand($username, $email, $plainPassword, $role));
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Application;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
interface UserServiceInterface
{
/** @return list<User> */
public function findAll(): array;
/** @return PaginatedResult<User> */
public function findPaginated(int $page, int $perPage): PaginatedResult;
public function findById(int $id): ?User;
public function delete(int $id): void;
public function deleteFromAdministration(int $actorUserId, int $targetUserId): User;
public function updateRole(int $id, string $role): void;
public function updateRoleFromAdministration(int $actorUserId, int $targetUserId, string $role): User;
public function create(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User;
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Entity;
use DateTime;
use Netig\Netslim\Kernel\Support\Util\DateParser;
/**
* Représente un utilisateur du système avec son identité, son email et son rôle applicatif.
*/
final class User
{
private readonly DateTime $createdAt;
public const ROLE_USER = 'user';
public const ROLE_EDITOR = 'editor';
public const ROLE_ADMIN = 'admin';
public function __construct(
private readonly int $id,
private readonly string $username,
private readonly string $email,
private readonly string $passwordHash,
private readonly string $role = self::ROLE_USER,
?DateTime $createdAt = null,
private readonly int $sessionVersion = 1,
) {
$this->createdAt = $createdAt ?? new DateTime();
$this->validate();
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
id: (int) ($data['id'] ?? 0),
username: (string) ($data['username'] ?? ''),
email: (string) ($data['email'] ?? ''),
passwordHash: (string) ($data['password_hash'] ?? ''),
role: (string) ($data['role'] ?? self::ROLE_USER),
createdAt: DateParser::parse($data['created_at'] ?? null),
sessionVersion: max(1, (int) ($data['session_version'] ?? 1)),
);
}
/**
* @return string[]
*/
public static function allRoles(): array
{
return [self::ROLE_USER, self::ROLE_EDITOR, self::ROLE_ADMIN];
}
/**
* @return string[]
*/
public static function assignableRoles(): array
{
return [self::ROLE_USER, self::ROLE_EDITOR];
}
public function getId(): int
{
return $this->id;
}
public function getUsername(): string
{
return $this->username;
}
public function getEmail(): string
{
return $this->email;
}
public function getPasswordHash(): string
{
return $this->passwordHash;
}
public function getRole(): string
{
return $this->role;
}
public function isAdmin(): bool
{
return $this->role === self::ROLE_ADMIN;
}
public function isEditor(): bool
{
return $this->role === self::ROLE_EDITOR;
}
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
public function getSessionVersion(): int
{
return $this->sessionVersion;
}
private function validate(): void
{
if (mb_strlen($this->username) < 3) {
throw new \InvalidArgumentException("Le nom d'utilisateur doit contenir au moins 3 caractères");
}
if (mb_strlen($this->username) > 50) {
throw new \InvalidArgumentException("Le nom d'utilisateur ne peut pas dépasser 50 caractères");
}
if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("L'email n'est pas valide");
}
if ($this->passwordHash === '') {
throw new \InvalidArgumentException('Le hash du mot de passe ne peut pas être vide');
}
if (!in_array($this->role, self::allRoles(), true)) {
throw new \InvalidArgumentException(
"Le rôle '{$this->role}' est invalide. Valeurs autorisées : " . implode(', ', self::allRoles()),
);
}
if ($this->sessionVersion < 1) {
throw new \InvalidArgumentException('La version de session doit être supérieure ou égale à 1');
}
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Exception;
final class CannotDeleteOwnAccountException extends \DomainException
{
public function __construct()
{
parent::__construct('Vous ne pouvez pas supprimer votre propre compte');
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Exception;
final class CannotModifyOwnRoleException extends \DomainException
{
public function __construct()
{
parent::__construct('Vous ne pouvez pas modifier votre propre rôle');
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Exception;
/**
* Exception levée lorsqu'une adresse e-mail est déjà utilisée.
*
* Permet aux appelants de distinguer cette erreur métier précise
* d'une InvalidArgumentException générique sans analyser le message.
*/
final class DuplicateEmailException extends \InvalidArgumentException
{
public function __construct(string $email)
{
parent::__construct("Cette adresse e-mail est déjà utilisée : {$email}");
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Exception;
/**
* Exception levée lorsqu'un nom d'utilisateur est déjà pris.
*
* Permet aux appelants de distinguer cette erreur métier précise
* d'une InvalidArgumentException générique sans analyser le message.
*/
final class DuplicateUsernameException extends \InvalidArgumentException
{
public function __construct(string $username)
{
parent::__construct("Ce nom d'utilisateur est déjà pris : {$username}");
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Exception;
/**
* Exception levée lorsqu'un lien de réinitialisation est invalide,
* expiré, déjà consommé, ou lié à un utilisateur absent.
*/
final class InvalidResetTokenException extends \InvalidArgumentException
{
public function __construct()
{
parent::__construct('Ce lien de réinitialisation est invalide ou a expiré.');
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Exception;
use Netig\Netslim\Identity\Domain\Entity\User;
/**
* Exception levée lorsqu'un rôle utilisateur non autorisé est demandé.
*/
final class InvalidRoleException extends \InvalidArgumentException
{
public function __construct(string $role)
{
$validRoles = [User::ROLE_USER, User::ROLE_EDITOR, User::ROLE_ADMIN];
parent::__construct(
"Le rôle '{$role}' est invalide. Valeurs autorisées : " . implode(', ', $validRoles),
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Exception;
final class ProtectedAdministratorDeletionException extends \DomainException
{
public function __construct()
{
parent::__construct('Le compte administrateur ne peut pas être supprimé');
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Exception;
final class ProtectedAdministratorRoleChangeException extends \DomainException
{
public function __construct()
{
parent::__construct("Le rôle d'un administrateur ne peut pas être modifié depuis l'interface");
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Exception;
use Netig\Netslim\Identity\Domain\Entity\User;
final class RoleAssignmentNotAllowedException extends \InvalidArgumentException
{
public function __construct(string $role)
{
parent::__construct(
"Le rôle '{$role}' ne peut pas être attribué depuis l'interface. Rôles autorisés : "
. implode(', ', User::assignableRoles()),
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Exception;
/**
* Exception levée lorsqu'un mot de passe ne respecte pas les règles de complexité.
*
* Permet aux appelants de distinguer cette erreur métier précise
* d'une InvalidArgumentException générique sans analyser le message.
*/
final class WeakPasswordException extends \InvalidArgumentException
{
/**
* @param int $minLength Longueur minimale requise
*/
public function __construct(int $minLength = 12)
{
parent::__construct("Le mot de passe doit contenir au moins {$minLength} caractères");
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Policy;
/**
* Définit les seuils de protection contre les tentatives répétées.
*/
class LoginRateLimitPolicy
{
public function maxAttempts(): int
{
return 5;
}
public function lockMinutes(): int
{
return 15;
}
public function passwordResetMaxAttemptsPerIp(): int
{
return 3;
}
public function passwordResetLockMinutesPerIp(): int
{
return 15;
}
public function passwordResetEmailCooldownMinutes(): int
{
return 5;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Policy;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
/**
* Politique centrale de mot de passe.
*
* Rassemble en un seul endroit :
* - la longueur minimale ;
* - l'algorithme de hachage ;
* - les règles de rehash.
*/
final class PasswordPolicy
{
private const MIN_LENGTH = 12;
private const HASH_ALGO = PASSWORD_BCRYPT;
private const HASH_OPTIONS = ['cost' => 12];
public function minLength(): int
{
return self::MIN_LENGTH;
}
/**
* @throws WeakPasswordException
*/
public function assert(string $plainPassword): void
{
if (mb_strlen($plainPassword) < self::MIN_LENGTH) {
throw new WeakPasswordException(self::MIN_LENGTH);
}
}
public function hash(string $plainPassword): string
{
return password_hash($plainPassword, self::HASH_ALGO, self::HASH_OPTIONS);
}
public function needsRehash(string $passwordHash): bool
{
return password_needs_rehash($passwordHash, self::HASH_ALGO, self::HASH_OPTIONS);
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Policy;
/**
* Définit la durée de vie des jetons de réinitialisation de mot de passe.
*/
class PasswordResetTokenPolicy
{
public function ttlMinutes(): int
{
return 60;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Policy;
/**
* Catalogue minimal des permissions partagées par le core.
*/
final class Permission
{
public const PROFILE_MANAGE = 'profile.manage';
public const CONTENT_MANAGE = 'content.manage';
public const CONTENT_PUBLISH = 'content.publish';
public const MEDIA_MANAGE = 'media.manage';
public const TAXONOMY_MANAGE = 'taxonomy.manage';
public const SETTINGS_MANAGE = 'settings.manage';
public const USERS_MANAGE = 'users.manage';
public const AUDIT_LOG_VIEW = 'audit-log.view';
public const NOTIFICATIONS_SEND = 'notifications.send';
/** @return list<string> */
public static function all(): array
{
return [
self::PROFILE_MANAGE,
self::CONTENT_MANAGE,
self::CONTENT_PUBLISH,
self::MEDIA_MANAGE,
self::TAXONOMY_MANAGE,
self::SETTINGS_MANAGE,
self::USERS_MANAGE,
self::AUDIT_LOG_VIEW,
self::NOTIFICATIONS_SEND,
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Policy;
use Netig\Netslim\Identity\Domain\Entity\User;
/**
* Matrice simple rôle -> permissions pour les projets consommateurs du core.
*/
final class RolePermissionMatrix
{
/** @return list<string> */
public function permissionsForRole(string $role): array
{
return match ($role) {
User::ROLE_ADMIN => ['*'],
User::ROLE_EDITOR => [
Permission::PROFILE_MANAGE,
Permission::CONTENT_MANAGE,
Permission::CONTENT_PUBLISH,
Permission::MEDIA_MANAGE,
Permission::TAXONOMY_MANAGE,
],
User::ROLE_USER => [Permission::PROFILE_MANAGE],
default => [],
};
}
public function allows(string $role, string $permission): bool
{
$permissions = $this->permissionsForRole($role);
return in_array('*', $permissions, true) || in_array($permission, $permissions, true);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Policy;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Exception\InvalidRoleException;
use Netig\Netslim\Identity\Domain\Exception\RoleAssignmentNotAllowedException;
class RolePolicy
{
/**
* @return string[]
*/
public function allRoles(): array
{
return User::allRoles();
}
/**
* @return string[]
*/
public function assignableRoles(): array
{
return User::assignableRoles();
}
/**
* @throws InvalidRoleException
* @throws RoleAssignmentNotAllowedException
*/
public function assertAssignable(string $role): void
{
if (!in_array($role, $this->allRoles(), true)) {
throw new InvalidRoleException($role);
}
if (!in_array($role, $this->assignableRoles(), true)) {
throw new RoleAssignmentNotAllowedException($role);
}
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Repository;
/**
* Contrat de persistance des compteurs de limitation de débit.
*/
interface LoginAttemptRepositoryInterface
{
/**
* Retourne l'état courant des tentatives pour une adresse IP dans le scope de connexion.
*
* @return array{scope:string, rate_key:string, attempts:int, locked_until:string|null, updated_at:string}|null
*/
public function findByIp(string $ip): ?array;
/**
* Enregistre un échec de connexion pour une adresse IP dans le scope de connexion.
*/
public function recordFailure(string $ip, int $maxAttempts, int $lockMinutes): void;
/**
* Réinitialise le compteur pour une adresse IP dans le scope de connexion.
*/
public function resetForIp(string $ip): void;
/**
* Retourne l'état courant des tentatives pour un scope et une clé donnés.
*
* @return array{scope:string, rate_key:string, attempts:int, locked_until:string|null, updated_at:string}|null
*/
public function findByScopeAndKey(string $scope, string $key): ?array;
/**
* Enregistre un échec pour un scope et une clé donnés.
*/
public function recordFailureForScope(string $scope, string $key, int $maxAttempts, int $lockMinutes): void;
/**
* Réinitialise le compteur pour un scope et une clé donnés.
*/
public function resetForScope(string $scope, string $key): void;
/**
* Supprime les entrées devenues inutiles car leur verrouillage est expiré.
*/
public function deleteExpired(): void;
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Repository;
/**
* Contrat de persistance des tokens de réinitialisation de mot de passe.
*/
interface PasswordResetRepositoryInterface
{
/**
* Crée un nouveau token actif pour un utilisateur.
*/
public function create(int $userId, string $tokenHash, string $expiresAt): void;
/**
* Retourne le token actif correspondant à un hash, s'il existe.
*
* @return array{id:int, user_id:int, token_hash:string, expires_at:string, used_at:string|null, created_at:string}|null
*/
public function findActiveByHash(string $tokenHash): ?array;
/**
* Invalide tous les tokens actifs d'un utilisateur.
*/
public function invalidateByUserId(int $userId): void;
/**
* Consomme un token actif et retourne la ligne mise à jour.
*
* Certaines plateformes de test ou variantes SQL peuvent ne retourner
* qu'un sous-ensemble des colonnes via RETURNING.
*
* @return array{id:int, user_id:int, token_hash:string, used_at:string|null, expires_at?:string, created_at?:string}|null
*/
public function consumeActiveToken(string $tokenHash, string $usedAt): ?array;
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Domain\Repository;
use Netig\Netslim\Identity\Domain\Entity\User;
/**
* Contrat de persistance des utilisateurs.
*/
interface UserRepositoryInterface
{
/**
* Retourne tous les utilisateurs.
*
* @return list<User>
*/
public function findAll(): array;
/**
* Retourne une page d'utilisateurs.
*
* @return list<User>
*/
public function findPage(int $limit, int $offset): array;
/**
* Retourne le nombre total d'utilisateurs.
*/
public function countAll(): int;
/**
* Retourne un utilisateur par identifiant, ou `null` s'il n'existe pas.
*/
public function findById(int $id): ?User;
/**
* Retourne un utilisateur par nom d'utilisateur, ou `null` s'il n'existe pas.
*/
public function findByUsername(string $username): ?User;
/**
* Retourne un utilisateur par adresse e-mail, ou `null` s'il n'existe pas.
*/
public function findByEmail(string $email): ?User;
/**
* Persiste un utilisateur et retourne son identifiant.
*/
public function create(User $user): int;
/**
* Met à jour le hash du mot de passe d'un utilisateur.
*
* Si $invalidateSessions vaut true, la version de session est incrémentée
* afin d'invalider les sessions existantes.
*/
public function updatePassword(int $id, string $passwordHash, bool $invalidateSessions = true): void;
/**
* Met à jour le rôle d'un utilisateur.
*/
public function updateRole(int $id, string $role): void;
/**
* Supprime un utilisateur.
*/
public function delete(int $id): void;
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity;
use Netig\Netslim\Identity\Infrastructure\AdminUserProvisioner;
use Netig\Netslim\Identity\UI\Http\AuthRoutes;
use Netig\Netslim\Identity\UI\Http\UserRoutes;
use Netig\Netslim\Kernel\Runtime\Module\ModuleInterface;
use Netig\Netslim\Kernel\Runtime\Module\ProvidesProvisioningInterface;
use Netig\Netslim\Kernel\Runtime\Module\ProvidesSchemaInterface;
use Psr\Container\ContainerInterface;
use Slim\App;
/**
* Module d'identité exposé à l'assemblage applicatif.
*
* Il regroupe l'authentification, la gestion des comptes et le provisionnement
* initial dans un seul domaine cohérent.
*/
final class IdentityModule implements ModuleInterface, ProvidesProvisioningInterface, ProvidesSchemaInterface
{
public function definitions(): array
{
return require __DIR__ . '/Infrastructure/dependencies.php';
}
/** @param App<ContainerInterface> $app */
public function registerRoutes(App $app): void
{
AuthRoutes::register($app);
UserRoutes::register($app);
}
public function templateNamespaces(): array
{
return [
'Identity' => __DIR__ . '/UI/Templates',
];
}
public function provision(\PDO $db): void
{
AdminUserProvisioner::seed($db);
}
public function twigExtensions(): array
{
return [];
}
public function migrationDirectories(): array
{
return [__DIR__ . '/Migrations'];
}
public function requiredTables(): array
{
return ['users', 'password_resets', 'rate_limits'];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Infrastructure;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
/**
* Valide l'environnement minimal attendu avant le provisionnement du compte admin.
*/
final class AdminProvisioningEnvironmentValidator
{
private const MIN_ADMIN_PASSWORD_LENGTH = 12;
public static function assert(): void
{
$adminPassword = $_ENV['ADMIN_PASSWORD'] ?? '';
if (mb_strlen($adminPassword) < self::MIN_ADMIN_PASSWORD_LENGTH) {
throw new WeakPasswordException(self::MIN_ADMIN_PASSWORD_LENGTH);
}
if (!self::isDevelopmentEnvironment() && $adminPassword === 'changeme12345') {
throw new \RuntimeException(
'ADMIN_PASSWORD doit être changé avant de provisionner en production.',
);
}
}
private static function isDevelopmentEnvironment(): bool
{
return strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Infrastructure;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use PDO;
/**
* Provisionne le compte administrateur initial du domaine identité.
*/
final class AdminUserProvisioner
{
public static function seed(PDO $db): void
{
AdminProvisioningEnvironmentValidator::assert();
$username = mb_strtolower(trim($_ENV['ADMIN_USERNAME'] ?? 'admin'));
$email = mb_strtolower(trim($_ENV['ADMIN_EMAIL'] ?? 'admin@example.com'));
$password = $_ENV['ADMIN_PASSWORD'] ?? 'changeme12345';
$passwordPolicy = new PasswordPolicy();
$stmt = $db->prepare('SELECT id FROM users WHERE username = :username');
$stmt->execute([':username' => $username]);
if ($stmt->fetchColumn() !== false) {
return;
}
$passwordPolicy->assert($password);
$stmt = $db->prepare('
INSERT INTO users (username, email, password_hash, role, session_version, created_at)
VALUES (:username, :email, :password_hash, :role, :session_version, :created_at)
');
$stmt->execute([
':username' => $username,
':email' => $email,
':password_hash' => $passwordPolicy->hash($password),
':role' => 'admin',
':session_version' => 1,
':created_at' => date('Y-m-d H:i:s'),
]);
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Infrastructure;
use Netig\Netslim\Identity\Domain\Repository\LoginAttemptRepositoryInterface;
use PDO;
/**
* Persiste les compteurs de limitation de débit par scope et par clé.
*/
class PdoLoginAttemptRepository implements LoginAttemptRepositoryInterface
{
private const LOGIN_SCOPE = 'login_ip';
public function __construct(private readonly PDO $db) {}
public function findByIp(string $ip): ?array
{
return $this->findByScopeAndKey(self::LOGIN_SCOPE, $ip);
}
public function recordFailure(string $ip, int $maxAttempts, int $lockMinutes): void
{
$this->recordFailureForScope(self::LOGIN_SCOPE, $ip, $maxAttempts, $lockMinutes);
}
public function resetForIp(string $ip): void
{
$this->resetForScope(self::LOGIN_SCOPE, $ip);
}
public function findByScopeAndKey(string $scope, string $key): ?array
{
$stmt = $this->db->prepare('SELECT * FROM rate_limits WHERE scope = :scope AND rate_key = :rate_key');
$stmt->execute([
':scope' => $scope,
':rate_key' => $key,
]);
/** @var array{scope: string, rate_key: string, attempts: int, locked_until: string|null, updated_at: string}|false $row */
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
/**
* Incrémente le compteur d'échecs pour un scope et une clé, puis positionne le verrouillage si le seuil est atteint.
*/
public function recordFailureForScope(string $scope, string $key, int $maxAttempts, int $lockMinutes): void
{
$now = (new \DateTime())->format('Y-m-d H:i:s');
$lockUntil = (new \DateTime())->modify("+{$lockMinutes} minutes")->format('Y-m-d H:i:s');
$stmt = $this->db->prepare(
'INSERT INTO rate_limits (scope, rate_key, attempts, locked_until, updated_at)
VALUES (:scope1, :rate_key1, 1, CASE WHEN 1 >= :max1 THEN :lock1 ELSE NULL END, :now1)
ON CONFLICT(scope, rate_key) DO UPDATE SET
attempts = rate_limits.attempts + 1,
locked_until = CASE WHEN rate_limits.attempts + 1 >= :max2
THEN :lock2
ELSE NULL END,
updated_at = :now2',
);
$stmt->execute([
':scope1' => $scope,
':rate_key1' => $key,
':max1' => $maxAttempts,
':lock1' => $lockUntil,
':now1' => $now,
':max2' => $maxAttempts,
':lock2' => $lockUntil,
':now2' => $now,
]);
}
public function resetForScope(string $scope, string $key): void
{
$stmt = $this->db->prepare('DELETE FROM rate_limits WHERE scope = :scope AND rate_key = :rate_key');
$stmt->execute([
':scope' => $scope,
':rate_key' => $key,
]);
}
public function deleteExpired(): void
{
$now = (new \DateTime())->format('Y-m-d H:i:s');
$stmt = $this->db->prepare(
'DELETE FROM rate_limits WHERE locked_until IS NOT NULL AND locked_until < :now',
);
$stmt->execute([':now' => $now]);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Infrastructure;
use Netig\Netslim\Identity\Domain\Repository\PasswordResetRepositoryInterface;
use PDO;
class PdoPasswordResetRepository implements PasswordResetRepositoryInterface
{
public function __construct(private readonly PDO $db) {}
public function create(int $userId, string $tokenHash, string $expiresAt): void
{
$stmt = $this->db->prepare('
INSERT INTO password_resets (user_id, token_hash, expires_at, created_at)
VALUES (:user_id, :token_hash, :expires_at, :created_at)
');
$stmt->execute([
':user_id' => $userId,
':token_hash' => $tokenHash,
':expires_at' => $expiresAt,
':created_at' => date('Y-m-d H:i:s'),
]);
}
public function findActiveByHash(string $tokenHash): ?array
{
$stmt = $this->db->prepare(
'SELECT * FROM password_resets WHERE token_hash = :token_hash AND used_at IS NULL',
);
$stmt->execute([':token_hash' => $tokenHash]);
/** @var array<string, mixed>|false $row */
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->hydrateActiveRow($row) : null;
}
public function invalidateByUserId(int $userId): void
{
$stmt = $this->db->prepare(
'UPDATE password_resets SET used_at = :used_at WHERE user_id = :user_id AND used_at IS NULL',
);
$stmt->execute([':used_at' => date('Y-m-d H:i:s'), ':user_id' => $userId]);
}
public function consumeActiveToken(string $tokenHash, string $usedAt): ?array
{
$stmt = $this->db->prepare(
'UPDATE password_resets
SET used_at = :used_at
WHERE token_hash = :token_hash
AND used_at IS NULL
AND expires_at >= :now
RETURNING *',
);
$stmt->execute([
':used_at' => $usedAt,
':token_hash' => $tokenHash,
':now' => $usedAt,
]);
/** @var array<string, mixed>|false $row */
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->hydrateConsumedRow($row) : null;
}
/**
* Normalise une ligne SQL complète vers la shape promise par findActiveByHash().
*
* @param array<string, mixed> $row
* @return array{id:int, user_id:int, token_hash:string, expires_at:string, used_at:string|null, created_at:string}
*/
private function hydrateActiveRow(array $row): array
{
return [
'id' => (int) $row['id'],
'user_id' => (int) $row['user_id'],
'token_hash' => (string) $row['token_hash'],
'expires_at' => (string) $row['expires_at'],
'used_at' => array_key_exists('used_at', $row) && $row['used_at'] !== null ? (string) $row['used_at'] : null,
'created_at' => (string) $row['created_at'],
];
}
/**
* Normalise la ligne renvoyée par RETURNING après consommation d'un token.
*
* @param array<string, mixed> $row
* @return array{id:int, user_id:int, token_hash:string, used_at:string|null, expires_at?:string, created_at?:string}
*/
private function hydrateConsumedRow(array $row): array
{
/** @var array{id:int, user_id:int, token_hash:string, used_at:string|null, expires_at?:string, created_at?:string} $normalized */
$normalized = [
'id' => (int) $row['id'],
'user_id' => (int) $row['user_id'],
'token_hash' => (string) $row['token_hash'],
'used_at' => array_key_exists('used_at', $row) && $row['used_at'] !== null ? (string) $row['used_at'] : null,
];
if (array_key_exists('expires_at', $row)) {
$normalized['expires_at'] = (string) $row['expires_at'];
}
if (array_key_exists('created_at', $row)) {
$normalized['created_at'] = (string) $row['created_at'];
}
return $normalized;
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Infrastructure;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use PDO;
class PdoUserRepository implements UserRepositoryInterface
{
public function __construct(private readonly PDO $db) {}
/** @return User[] */
public function findAll(): array
{
$stmt = $this->db->query('SELECT * FROM users ORDER BY created_at ASC');
if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur users a échoué.');
}
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @return User[] */
public function findPage(int $limit, int $offset): array
{
$stmt = $this->db->prepare('SELECT * FROM users ORDER BY created_at ASC LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function countAll(): int
{
$stmt = $this->db->query('SELECT COUNT(*) FROM users');
if ($stmt === false) {
throw new \RuntimeException('La requête COUNT sur users a échoué.');
}
return (int) ($stmt->fetchColumn() ?: 0);
}
public function findById(int $id): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
public function findByUsername(string $username): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE username = :username');
$stmt->execute([':username' => $username]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
public function findByEmail(string $email): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute([':email' => $email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
public function create(User $user): int
{
$stmt = $this->db->prepare(
'INSERT INTO users (username, email, password_hash, role, session_version, created_at)
VALUES (:username, :email, :password_hash, :role, :session_version, :created_at)',
);
$stmt->execute([
':username' => $user->getUsername(),
':email' => $user->getEmail(),
':password_hash' => $user->getPasswordHash(),
':role' => $user->getRole(),
':session_version' => $user->getSessionVersion(),
':created_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->db->lastInsertId();
}
public function updatePassword(int $id, string $newHash, bool $invalidateSessions = true): void
{
$sql = $invalidateSessions
? 'UPDATE users SET password_hash = :password_hash, session_version = session_version + 1 WHERE id = :id'
: 'UPDATE users SET password_hash = :password_hash WHERE id = :id';
$stmt = $this->db->prepare($sql);
$stmt->execute([':password_hash' => $newHash, ':id' => $id]);
}
public function updateRole(int $id, string $role): void
{
$stmt = $this->db->prepare('UPDATE users SET role = :role WHERE id = :id');
$stmt->execute([':role' => $role, ':id' => $id]);
}
public function delete(int $id): void
{
$stmt = $this->db->prepare('DELETE FROM users WHERE id = :id');
$stmt->execute([':id' => $id]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\Infrastructure;
use Netig\Netslim\Identity\Application\AuthSessionInterface;
use Netig\Netslim\Identity\Domain\Entity\User;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
/**
* Adaptateur reliant le port d'authentification applicatif à la session HTTP concrète.
*/
final class SessionAuthSession implements AuthSessionInterface
{
public function __construct(private readonly SessionManagerInterface $sessionManager) {}
public function isAuthenticated(): bool
{
return $this->sessionManager->isAuthenticated();
}
public function startForUser(User $user): void
{
$this->sessionManager->setUser(
$user->getId(),
$user->getUsername(),
$user->getRole(),
$user->getSessionVersion(),
);
}
public function clear(): void
{
$this->sessionManager->destroy();
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
use function DI\autowire;
use function DI\factory;
use Netig\Netslim\Identity\Application\AuthApplicationService;
use Netig\Netslim\Identity\Application\AuthorizationApplicationService;
use Netig\Netslim\Identity\Application\AuthorizationServiceInterface;
use Netig\Netslim\Identity\Application\AuthServiceInterface;
use Netig\Netslim\Identity\Application\AuthSessionInterface;
use Netig\Netslim\Identity\Application\PasswordResetApplicationService;
use Netig\Netslim\Identity\Application\PasswordResetServiceInterface;
use Netig\Netslim\Identity\Application\UseCase\CreateUser;
use Netig\Netslim\Identity\Application\UseCase\DeleteUser;
use Netig\Netslim\Identity\Application\UseCase\UpdateUserRole;
use Netig\Netslim\Identity\Application\UserApplicationService;
use Netig\Netslim\Identity\Application\UserServiceInterface;
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
use Netig\Netslim\Identity\Domain\Policy\PasswordResetTokenPolicy;
use Netig\Netslim\Identity\Domain\Policy\RolePermissionMatrix;
use Netig\Netslim\Identity\Domain\Policy\RolePolicy;
use Netig\Netslim\Identity\Domain\Repository\LoginAttemptRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\PasswordResetRepositoryInterface;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Identity\Infrastructure\PdoLoginAttemptRepository;
use Netig\Netslim\Identity\Infrastructure\PdoPasswordResetRepository;
use Netig\Netslim\Identity\Infrastructure\PdoUserRepository;
use Netig\Netslim\Identity\Infrastructure\SessionAuthSession;
use Netig\Netslim\Identity\UI\Http\AccountController;
use Netig\Netslim\Identity\UI\Http\AuthController;
use Netig\Netslim\Identity\UI\Http\Middleware\AuthMiddleware;
use Netig\Netslim\Identity\UI\Http\PasswordResetController;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
use Netig\Netslim\Kernel\Http\Infrastructure\Request\ClientIpResolver;
use Psr\Log\LoggerInterface;
use Slim\Views\Twig;
return [
AuthSessionInterface::class => autowire(SessionAuthSession::class),
AuthServiceInterface::class => autowire(AuthApplicationService::class),
AuthorizationServiceInterface::class => autowire(AuthorizationApplicationService::class),
LoginRateLimitPolicy::class => autowire(),
LoginAttemptRepositoryInterface::class => autowire(PdoLoginAttemptRepository::class),
PasswordResetRepositoryInterface::class => autowire(PdoPasswordResetRepository::class),
PasswordResetTokenPolicy::class => autowire(),
PasswordPolicy::class => autowire(),
PasswordResetServiceInterface::class => autowire(PasswordResetApplicationService::class),
UserServiceInterface::class => autowire(UserApplicationService::class),
UserRepositoryInterface::class => autowire(PdoUserRepository::class),
RolePolicy::class => autowire(),
RolePermissionMatrix::class => autowire(),
CreateUser::class => autowire(),
UpdateUserRole::class => autowire(),
DeleteUser::class => autowire(),
AuthMiddleware::class => factory(function (
SessionManagerInterface $sessionManager,
UserRepositoryInterface $userRepository,
): AuthMiddleware {
return new AuthMiddleware($sessionManager, $userRepository);
}),
AuthController::class => factory(function (
Twig $twig,
AuthServiceInterface $authService,
FlashServiceInterface $flash,
ClientIpResolver $clientIpResolver,
LoggerInterface $logger,
): AuthController {
return new AuthController(
$twig,
$authService,
$flash,
$clientIpResolver,
$logger,
);
}),
AccountController::class => factory(function (
Twig $twig,
AuthServiceInterface $authService,
FlashServiceInterface $flash,
SessionManagerInterface $sessionManager,
LoggerInterface $logger,
): AccountController {
return new AccountController(
$twig,
$authService,
$flash,
$sessionManager,
$logger,
);
}),
PasswordResetController::class => factory(function (
Twig $twig,
PasswordResetServiceInterface $passwordResetService,
AuthServiceInterface $authService,
FlashServiceInterface $flash,
ClientIpResolver $clientIpResolver,
LoggerInterface $logger,
): PasswordResetController {
return new PasswordResetController(
$twig,
$passwordResetService,
$authService,
$flash,
$clientIpResolver,
rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'),
$logger,
);
}),
];

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
session_version INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS password_resets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS rate_limits (
scope TEXT NOT NULL,
rate_key TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT DEFAULT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (scope, rate_key)
);
CREATE INDEX IF NOT EXISTS idx_rate_limits_locked_until ON rate_limits(locked_until);
",
'down' => "
DROP INDEX IF EXISTS idx_rate_limits_locked_until;
DROP TABLE IF EXISTS rate_limits;
DROP TABLE IF EXISTS password_resets;
DROP TABLE IF EXISTS users;
",
];

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http;
use Netig\Netslim\Identity\Application\AuthServiceInterface;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
use Netig\Netslim\Identity\UI\Http\Request\ChangePasswordRequest;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Slim\Views\Twig;
/**
* Gère les actions liées au compte courant, notamment le changement de mot de passe.
*/
class AccountController
{
public function __construct(
private readonly Twig $view,
private readonly AuthServiceInterface $authService,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
private readonly ?LoggerInterface $logger = null,
) {}
public function showChangePassword(Request $req, Response $res): Response
{
$referer = $req->getHeaderLine('Referer');
$path = parse_url($referer, PHP_URL_PATH);
$path = is_string($path) ? $path : '';
$backUrl = (str_starts_with($path, '/') && $path !== '/account/password')
? $path
: AdminHomePath::resolve();
return $this->view->render($res, '@Identity/account/password-change.twig', [
'error' => $this->flash->get('password_error'),
'success' => $this->flash->get('password_success'),
'back_url' => $backUrl,
]);
}
/**
* Valide puis applique le changement de mot de passe pour l'utilisateur connecté.
*/
public function changePassword(Request $req, Response $res): Response
{
$changePasswordRequest = ChangePasswordRequest::fromRequest($req);
$userId = $this->sessionManager->getUserId() ?? 0;
try {
$changePasswordRequest->ensureConfirmed();
$this->authService->changePassword(
$userId,
$changePasswordRequest->currentPassword,
$changePasswordRequest->newPassword,
);
$this->flash->set('password_success', 'Mot de passe modifié avec succès');
} catch (WeakPasswordException $e) {
$this->flash->set('password_error', $e->getMessage());
} catch (\InvalidArgumentException $e) {
$message = $e->getMessage();
if ($message === 'Mot de passe actuel incorrect') {
$message = 'Le mot de passe actuel est incorrect';
}
$this->flash->set('password_error', $message);
} catch (\Throwable $e) {
$incidentId = $this->logUnexpectedError($req, $e, [
'user_id' => $userId,
]);
$this->flash->set('password_error', "Une erreur inattendue s\'est produite (réf. {$incidentId})");
}
return $res->withHeader('Location', '/account/password')->withStatus(302);
}
/**
* @param array<string, mixed> $context
*/
private function logUnexpectedError(Request $req, \Throwable $e, array $context = []): string
{
$incidentId = bin2hex(random_bytes(8));
$this->logger?->error('Account password change failed', $context + [
'incident_id' => $incidentId,
'route' => (string) $req->getUri()->getPath(),
'method' => $req->getMethod(),
'exception_class' => $e::class,
'exception_message' => $e->getMessage(),
'exception' => $e,
]);
return $incidentId;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http;
/**
* Résout la destination d'atterrissage du back-office pour le domaine identité.
*
* Le socle ne suppose pas qu'un module éditorial soit présent : l'application
* consommatrice peut donc surcharger cette destination via ADMIN_HOME_PATH.
*/
final class AdminHomePath
{
public static function resolve(): string
{
$path = trim((string) ($_ENV['ADMIN_HOME_PATH'] ?? '/admin'));
if ($path === '') {
return '/admin';
}
return '/' . ltrim($path, '/');
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http;
use Netig\Netslim\Identity\Application\AuthServiceInterface;
use Netig\Netslim\Identity\UI\Http\Request\LoginRequest;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Infrastructure\Request\ClientIpResolver;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Slim\Views\Twig;
/**
* Contrôleur HTTP responsable de l'ouverture et de la fermeture de session.
*/
class AuthController
{
public function __construct(
private readonly Twig $view,
private readonly AuthServiceInterface $authService,
private readonly FlashServiceInterface $flash,
private readonly ClientIpResolver $clientIpResolver,
private readonly ?LoggerInterface $logger = null,
) {}
public function showLogin(Request $req, Response $res): Response
{
if ($this->authService->isLoggedIn()) {
return $res->withHeader('Location', AdminHomePath::resolve())->withStatus(302);
}
return $this->view->render($res, '@Identity/login.twig', [
'error' => $this->flash->get('login_error'),
'success' => $this->flash->get('login_success'),
]);
}
/**
* Traite la soumission du formulaire de connexion et applique la limitation de débit.
*/
public function login(Request $req, Response $res): Response
{
$ip = $this->clientIpResolver->resolve($req);
try {
$remainingMinutes = $this->authService->checkRateLimit($ip);
if ($remainingMinutes > 0) {
$this->flash->set(
'login_error',
"Trop de tentatives. Réessayez dans {$remainingMinutes} minute"
. ($remainingMinutes > 1 ? 's' : ''),
);
return $res->withHeader('Location', '/auth/login')->withStatus(302);
}
$loginRequest = LoginRequest::fromRequest($req);
$user = $this->authService->authenticate($loginRequest->username, $loginRequest->password);
if ($user === null) {
$this->authService->recordFailure($ip);
$this->flash->set('login_error', 'Identifiants invalides');
return $res->withHeader('Location', '/auth/login')->withStatus(302);
}
$this->authService->resetRateLimit($ip);
$this->authService->login($user);
} catch (\Throwable $e) {
$incidentId = $this->logUnexpectedError($req, $e, [
'ip' => $ip,
]);
$this->flash->set('login_error', "Une erreur inattendue s'est produite (réf. {$incidentId})");
return $res->withHeader('Location', '/auth/login')->withStatus(302);
}
return $res->withHeader('Location', AdminHomePath::resolve())->withStatus(302);
}
public function logout(Request $req, Response $res): Response
{
try {
$this->authService->logout();
} catch (\Throwable $e) {
$this->logUnexpectedError($req, $e);
}
return $res->withHeader('Location', '/')->withStatus(302);
}
/**
* @param array<string, mixed> $context
*/
private function logUnexpectedError(Request $req, \Throwable $e, array $context = []): string
{
$incidentId = bin2hex(random_bytes(8));
$this->logger?->error('Authentication flow failed', $context + [
'incident_id' => $incidentId,
'route' => (string) $req->getUri()->getPath(),
'method' => $req->getMethod(),
'exception_class' => $e::class,
'exception_message' => $e->getMessage(),
'exception' => $e,
]);
return $incidentId;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http;
use Netig\Netslim\Identity\UI\Http\Middleware\AuthMiddleware;
use Psr\Container\ContainerInterface;
use Slim\App;
/**
* Enregistre les routes HTTP du sous-domaine authentification.
*/
final class AuthRoutes
{
/** @param App<ContainerInterface> $app */
public static function register(App $app): void
{
$app->get('/auth/login', [AuthController::class, 'showLogin']);
$app->post('/auth/login', [AuthController::class, 'login']);
$app->post('/auth/logout', [AuthController::class, 'logout']);
$app->get('/password/forgot', [PasswordResetController::class, 'showForgot']);
$app->post('/password/forgot', [PasswordResetController::class, 'forgot']);
$app->get('/password/reset', [PasswordResetController::class, 'showReset']);
$app->post('/password/reset', [PasswordResetController::class, 'reset']);
$app->group('/account', function ($group) {
$group->get('/password', [AccountController::class, 'showChangePassword']);
$group->post('/password', [AccountController::class, 'changePassword']);
})->add(AuthMiddleware::class);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http\Middleware;
use Netig\Netslim\Identity\UI\Http\AdminHomePath;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Psr7\Response as SlimResponse;
/**
* Middleware de protection des routes réservées aux administrateurs.
*
* Intercepte les requêtes et redirige vers la page d'accueil du back-office si l'utilisateur
* connecté n'a pas le rôle 'admin'.
*
* Ce middleware doit être utilisé en complément de AuthMiddleware,
* qui vérifie en amont que l'utilisateur est connecté.
* Ordre dans la chaîne Slim : ->add($adminMiddleware)->add($authMiddleware)
*/
final class AdminMiddleware implements MiddlewareInterface
{
/**
* @param SessionManagerInterface $sessionManager Gestionnaire de session (lecture du rôle admin)
*/
public function __construct(private readonly SessionManagerInterface $sessionManager) {}
/**
* Vérifie le rôle admin avant de transmettre la requête au gestionnaire suivant.
*
* @param Request $request La requête HTTP entrante
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
*
* @return Response Une redirection 302 vers la page d'accueil du back-office, ou la réponse normale
*/
public function process(Request $request, RequestHandler $handler): Response
{
if (!$this->sessionManager->isAdmin()) {
return (new SlimResponse())
->withHeader('Location', AdminHomePath::resolve())
->withStatus(302);
}
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http\Middleware;
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Psr7\Response as SlimResponse;
/**
* Middleware de protection des routes réservées aux utilisateurs connectés.
*
* Intercepte les requêtes entrantes et redirige vers /auth/login
* si aucune session utilisateur n'est active.
* Doit être appliqué avant AdminMiddleware dans la chaîne.
*/
final class AuthMiddleware implements MiddlewareInterface
{
/**
* @param SessionManagerInterface $sessionManager Gestionnaire de session (vérification de l'authentification)
* @param UserRepositoryInterface|null $userRepository Dépôt pour valider la version de session
*/
public function __construct(
private readonly SessionManagerInterface $sessionManager,
private readonly ?UserRepositoryInterface $userRepository = null,
) {}
/**
* Vérifie l'authentification avant de transmettre la requête au gestionnaire suivant.
*
* @param Request $request La requête HTTP entrante
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
*
* @return Response Une redirection 302 vers /auth/login, ou la réponse normale
*/
public function process(Request $request, RequestHandler $handler): Response
{
if (!$this->sessionManager->isAuthenticated()) {
return $this->redirectToLogin();
}
if (!$this->isSessionStillValid()) {
$this->sessionManager->destroy();
return $this->redirectToLogin();
}
return $handler->handle($request);
}
private function isSessionStillValid(): bool
{
if ($this->userRepository === null) {
return true;
}
$userId = $this->sessionManager->getUserId();
$sessionVersion = $this->sessionManager->getSessionVersion();
if ($userId === null || $sessionVersion === null) {
return false;
}
$user = $this->userRepository->findById($userId);
return $user !== null && $user->getSessionVersion() === $sessionVersion;
}
private function redirectToLogin(): Response
{
return (new SlimResponse())
->withHeader('Location', '/auth/login')
->withStatus(302);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http\Middleware;
use Netig\Netslim\Identity\UI\Http\AdminHomePath;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Psr7\Response as SlimResponse;
/**
* Middleware de protection des routes réservées aux éditeurs et administrateurs.
*
* Intercepte les requêtes et redirige vers la page d'accueil du back-office si l'utilisateur
* connecté n'a ni le rôle 'editor' ni le rôle 'admin'.
*
* Ce middleware doit être utilisé en complément de AuthMiddleware,
* qui vérifie en amont que l'utilisateur est connecté.
* Ordre dans la chaîne Slim : ->add($editorMiddleware)->add($authMiddleware)
*/
final class EditorMiddleware implements MiddlewareInterface
{
/**
* @param SessionManagerInterface $sessionManager Gestionnaire de session (lecture du rôle)
*/
public function __construct(private readonly SessionManagerInterface $sessionManager) {}
/**
* Vérifie le rôle (editor ou admin) avant de transmettre la requête au gestionnaire suivant.
*
* @param Request $request La requête HTTP entrante
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
*
* @return Response Une redirection 302 vers la page d'accueil du back-office, ou la réponse normale
*/
public function process(Request $request, RequestHandler $handler): Response
{
if (!$this->sessionManager->isAdmin() && !$this->sessionManager->isEditor()) {
return (new SlimResponse())
->withHeader('Location', AdminHomePath::resolve())
->withStatus(302);
}
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http;
use Netig\Netslim\Identity\Application\AuthServiceInterface;
use Netig\Netslim\Identity\Application\PasswordResetServiceInterface;
use Netig\Netslim\Identity\Domain\Exception\InvalidResetTokenException;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
use Netig\Netslim\Identity\UI\Http\Request\ForgotPasswordRequest;
use Netig\Netslim\Identity\UI\Http\Request\ResetPasswordRequest;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Infrastructure\Request\ClientIpResolver;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Slim\Views\Twig;
/**
* Expose les écrans et traitements HTTP du parcours de réinitialisation de mot de passe.
*/
class PasswordResetController
{
public function __construct(
private readonly Twig $view,
private readonly PasswordResetServiceInterface $passwordResetService,
private readonly AuthServiceInterface $authService,
private readonly FlashServiceInterface $flash,
private readonly ClientIpResolver $clientIpResolver,
private readonly string $baseUrl,
private readonly ?LoggerInterface $logger = null,
) {}
public function showForgot(Request $req, Response $res): Response
{
return $this->view->render($res, '@Identity/password-forgot.twig', [
'error' => $this->flash->get('reset_error'),
'success' => $this->flash->get('reset_success'),
]);
}
public function forgot(Request $req, Response $res): Response
{
$ip = $this->clientIpResolver->resolve($req);
$remainingMinutes = $this->authService->checkPasswordResetRateLimit($ip);
if ($remainingMinutes > 0) {
$this->flash->set(
'reset_error',
"Trop de demandes. Veuillez réessayer dans {$remainingMinutes} minute"
. ($remainingMinutes > 1 ? 's' : ''),
);
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
$forgotPasswordRequest = ForgotPasswordRequest::fromRequest($req);
$this->authService->recordPasswordResetAttempt($ip);
try {
$this->passwordResetService->requestReset($forgotPasswordRequest->email, $this->baseUrl);
} catch (\RuntimeException $e) {
$incidentId = $this->logUnexpectedError(
$req,
$e,
'Password reset request failed',
[
'ip' => $ip,
'email_hash' => $forgotPasswordRequest->email === '' ? null : hash('sha256', mb_strtolower(trim($forgotPasswordRequest->email))),
],
);
$this->flash->set('reset_error', "Une erreur est survenue. Veuillez réessayer (réf. {$incidentId}).");
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
$this->flash->set(
'reset_success',
'Si cette adresse est associée à un compte, un email de réinitialisation a été envoyé',
);
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
public function showReset(Request $req, Response $res): Response
{
$token = trim((string) ($req->getQueryParams()['token'] ?? ''));
if ($token === '') {
$this->flash->set('reset_error', 'Lien de réinitialisation manquant');
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
$user = $this->passwordResetService->validateToken($token);
if ($user === null) {
$this->flash->set('reset_error', 'Ce lien est invalide ou a expiré. Veuillez faire une nouvelle demande.');
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
return $this->view->render($res, '@Identity/password-reset.twig', [
'token' => $token,
'error' => $this->flash->get('reset_error'),
]);
}
/**
* Tente de consommer un jeton de réinitialisation et redirige avec le message adapté.
*/
public function reset(Request $req, Response $res): Response
{
$resetPasswordRequest = ResetPasswordRequest::fromRequest($req);
try {
$resetPasswordRequest->ensureTokenPresent();
$resetPasswordRequest->ensureConfirmed();
$this->passwordResetService->resetPassword($resetPasswordRequest->token, $resetPasswordRequest->newPassword);
} catch (WeakPasswordException $e) {
$this->flash->set('reset_error', $e->getMessage());
return $res->withHeader('Location', '/password/reset?token=' . urlencode($resetPasswordRequest->token))->withStatus(302);
} catch (InvalidResetTokenException) {
$this->flash->set('reset_error', 'Ce lien de réinitialisation est invalide ou a expiré');
return $res->withHeader('Location', '/password/reset?token=' . urlencode($resetPasswordRequest->token))->withStatus(302);
} catch (\InvalidArgumentException $e) {
$this->flash->set('reset_error', $e->getMessage());
$location = $resetPasswordRequest->token === ''
? '/password/forgot'
: '/password/reset?token=' . urlencode($resetPasswordRequest->token);
return $res->withHeader('Location', $location)->withStatus(302);
} catch (\Throwable $e) {
$incidentId = $this->logUnexpectedError(
$req,
$e,
'Password reset failed',
[
'token_present' => $resetPasswordRequest->token !== '',
],
);
$this->flash->set('reset_error', "Une erreur inattendue s\'est produite (réf. {$incidentId})");
return $res->withHeader('Location', '/password/reset?token=' . urlencode($resetPasswordRequest->token))->withStatus(302);
}
$this->flash->set('login_success', 'Mot de passe réinitialisé avec succès. Vous pouvez vous connecter');
return $res->withHeader('Location', '/auth/login')->withStatus(302);
}
/**
* @param array<string, mixed> $context
*/
private function logUnexpectedError(Request $req, \Throwable $e, string $message, array $context = []): string
{
$incidentId = bin2hex(random_bytes(8));
$this->logger?->error($message, $context + [
'incident_id' => $incidentId,
'route' => (string) $req->getUri()->getPath(),
'method' => $req->getMethod(),
'exception_class' => $e::class,
'exception_message' => $e->getMessage(),
'exception' => $e,
]);
return $incidentId;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http\Request;
use Psr\Http\Message\ServerRequestInterface;
/**
* Porte les champs nécessaires au changement de mot de passe du compte courant.
*/
final readonly class ChangePasswordRequest
{
public function __construct(
public string $currentPassword,
public string $newPassword,
public string $newPasswordConfirm,
) {}
public static function fromRequest(ServerRequestInterface $request): self
{
/** @var array<string, mixed> $data */
$data = (array) $request->getParsedBody();
return new self(
currentPassword: (string) ($data['current_password'] ?? ''),
newPassword: (string) ($data['new_password'] ?? ''),
newPasswordConfirm: (string) ($data['new_password_confirm'] ?? ''),
);
}
/**
* Vérifie que la confirmation correspond bien au nouveau mot de passe demandé.
*
* @throws \InvalidArgumentException
*/
public function ensureConfirmed(): void
{
if ($this->newPassword !== $this->newPasswordConfirm) {
throw new \InvalidArgumentException('Les mots de passe ne correspondent pas');
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http\Request;
use Psr\Http\Message\ServerRequestInterface;
/**
* Porte les champs utiles à la création d'un utilisateur depuis l'interface admin.
*/
final readonly class CreateUserRequest
{
public function __construct(
public string $username,
public string $email,
public string $password,
public string $passwordConfirm,
public string $role,
) {}
/** @param string[] $assignableRoles */
public static function fromRequest(ServerRequestInterface $request, array $assignableRoles): self
{
/** @var array<string, mixed> $data */
$data = (array) $request->getParsedBody();
$rawRole = trim((string) ($data['role'] ?? ''));
return new self(
username: trim((string) ($data['username'] ?? '')),
email: trim((string) ($data['email'] ?? '')),
password: (string) ($data['password'] ?? ''),
passwordConfirm: (string) ($data['password_confirm'] ?? ''),
role: in_array($rawRole, $assignableRoles, true) ? $rawRole : 'user',
);
}
public function ensureConfirmed(): void
{
if ($this->password !== $this->passwordConfirm) {
throw new \InvalidArgumentException('Les mots de passe ne correspondent pas');
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http\Request;
use Psr\Http\Message\ServerRequestInterface;
/**
* Représente la demande de réinitialisation de mot de passe.
*/
final readonly class ForgotPasswordRequest
{
public function __construct(public string $email) {}
public static function fromRequest(ServerRequestInterface $request): self
{
/** @var array<string, mixed> $data */
$data = (array) $request->getParsedBody();
return new self(trim((string) ($data['email'] ?? '')));
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http\Request;
use Psr\Http\Message\ServerRequestInterface;
/**
* Représente les champs utiles du formulaire de connexion.
*/
final readonly class LoginRequest
{
public function __construct(
public string $username,
public string $password,
) {}
public static function fromRequest(ServerRequestInterface $request): self
{
/** @var array<string, mixed> $data */
$data = (array) $request->getParsedBody();
return new self(
username: trim((string) ($data['username'] ?? '')),
password: (string) ($data['password'] ?? ''),
);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http\Request;
use Psr\Http\Message\ServerRequestInterface;
/**
* Porte le jeton et les nouveaux secrets envoyés lors d'une réinitialisation.
*/
final readonly class ResetPasswordRequest
{
public function __construct(
public string $token,
public string $newPassword,
public string $newPasswordConfirm,
) {}
public static function fromRequest(ServerRequestInterface $request): self
{
/** @var array<string, mixed> $data */
$data = (array) $request->getParsedBody();
return new self(
token: trim((string) ($data['token'] ?? '')),
newPassword: (string) ($data['new_password'] ?? ''),
newPasswordConfirm: (string) ($data['new_password_confirm'] ?? ''),
);
}
/**
* Vérifie qu'un jeton de réinitialisation est bien présent dans la requête.
*
* @throws \InvalidArgumentException
*/
public function ensureTokenPresent(): void
{
if ($this->token === '') {
throw new \InvalidArgumentException('Lien de réinitialisation manquant');
}
}
/**
* Vérifie que le mot de passe saisi et sa confirmation correspondent.
*
* @throws \InvalidArgumentException
*/
public function ensureConfirmed(): void
{
if ($this->newPassword !== $this->newPasswordConfirm) {
throw new \InvalidArgumentException('Les mots de passe ne correspondent pas');
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http\Request;
use Psr\Http\Message\ServerRequestInterface;
/**
* Porte le rôle cible demandé depuis l'interface d'administration.
*/
final readonly class UpdateUserRoleRequest
{
public function __construct(public ?string $role) {}
/** @param string[] $assignableRoles */
public static function fromRequest(ServerRequestInterface $request, array $assignableRoles): self
{
/** @var array<string, mixed> $data */
$data = (array) $request->getParsedBody();
$rawRole = trim((string) ($data['role'] ?? ''));
return new self(
role: in_array($rawRole, $assignableRoles, true) ? $rawRole : null,
);
}
public function requireRole(): string
{
if ($this->role === null) {
throw new \InvalidArgumentException('Rôle invalide');
}
return $this->role;
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http;
use Netig\Netslim\Identity\Application\UserServiceInterface;
use Netig\Netslim\Identity\Domain\Exception\DuplicateEmailException;
use Netig\Netslim\Identity\Domain\Exception\DuplicateUsernameException;
use Netig\Netslim\Identity\Domain\Exception\InvalidRoleException;
use Netig\Netslim\Identity\Domain\Exception\RoleAssignmentNotAllowedException;
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
use Netig\Netslim\Identity\Domain\Policy\RolePolicy;
use Netig\Netslim\Identity\UI\Http\Request\CreateUserRequest;
use Netig\Netslim\Identity\UI\Http\Request\UpdateUserRoleRequest;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
use Netig\Netslim\Kernel\Pagination\Infrastructure\PaginationPresenter;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Slim\Views\Twig;
/**
* Contrôleur HTTP des écrans d'administration des utilisateurs.
*/
class UserController
{
private const PER_PAGE = 15;
private readonly RolePolicy $rolePolicy;
public function __construct(
private readonly Twig $view,
private readonly UserServiceInterface $userService,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
RolePolicy $rolePolicy,
private readonly ?LoggerInterface $logger = null,
) {
$this->rolePolicy = $rolePolicy;
}
public function index(Request $req, Response $res): Response
{
$page = PaginationPresenter::resolvePage($req->getQueryParams());
$paginated = $this->userService->findPaginated($page, self::PER_PAGE);
return $this->view->render($res, '@Identity/admin/index.twig', [
'users' => $paginated->getItems(),
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
'currentUserId' => $this->sessionManager->getUserId(),
'assignableRoles' => $this->rolePolicy->assignableRoles(),
'error' => $this->flash->get('user_error'),
'success' => $this->flash->get('user_success'),
]);
}
public function showCreate(Request $req, Response $res): Response
{
return $this->view->render($res, '@Identity/admin/form.twig', [
'assignableRoles' => $this->rolePolicy->assignableRoles(),
'error' => $this->flash->get('user_error'),
]);
}
public function create(Request $req, Response $res): Response
{
$createRequest = CreateUserRequest::fromRequest($req, $this->rolePolicy->assignableRoles());
try {
$createRequest->ensureConfirmed();
$this->userService->create(
$createRequest->username,
$createRequest->email,
$createRequest->password,
$createRequest->role,
);
$this->flash->set('user_success', "L'utilisateur « {$createRequest->username} » a été créé avec succès");
return $res->withHeader('Location', '/admin/users')->withStatus(302);
} catch (DuplicateUsernameException) {
$this->flash->set('user_error', "Ce nom d'utilisateur est déjà pris");
} catch (DuplicateEmailException) {
$this->flash->set('user_error', 'Cette adresse e-mail est déjà utilisée');
} catch (WeakPasswordException $e) {
$this->flash->set('user_error', $e->getMessage());
} catch (\InvalidArgumentException|InvalidRoleException|RoleAssignmentNotAllowedException $e) {
$this->flash->set('user_error', $e->getMessage());
} catch (\Throwable $e) {
$incidentId = $this->logUnexpectedError($req, $e, [
'actor_user_id' => $this->sessionManager->getUserId(),
'target_username' => $createRequest->username,
'target_email_hash' => hash('sha256', mb_strtolower(trim($createRequest->email))),
]);
$this->flash->set('user_error', "Une erreur inattendue s'est produite (réf. {$incidentId})");
}
return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
}
/** @param array<string, mixed> $args */
public function updateRole(Request $req, Response $res, array $args): Response
{
$id = (int) $args['id'];
$user = $this->userService->findById($id);
if ($user === null) {
$this->flash->set('user_error', 'Utilisateur introuvable');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($id === $this->sessionManager->getUserId()) {
$this->flash->set('user_error', 'Vous ne pouvez pas modifier votre propre rôle');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($user->isAdmin()) {
$this->flash->set('user_error', 'Le rôle d\'un administrateur ne peut pas être modifié depuis l\'interface');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
$updateUserRoleRequest = UpdateUserRoleRequest::fromRequest($req, $this->rolePolicy->assignableRoles());
try {
$this->userService->updateRole($id, $updateUserRoleRequest->requireRole());
$this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour");
} catch (\InvalidArgumentException|InvalidRoleException|RoleAssignmentNotAllowedException $e) {
$this->flash->set('user_error', $e->getMessage());
} catch (\Throwable $e) {
$incidentId = $this->logUnexpectedError($req, $e, [
'actor_user_id' => $this->sessionManager->getUserId(),
'target_user_id' => $id,
'target_username' => $user->getUsername(),
]);
$this->flash->set('user_error', "Une erreur inattendue s'est produite (réf. {$incidentId})");
}
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
/** @param array<string, mixed> $args */
public function delete(Request $req, Response $res, array $args): Response
{
$id = (int) $args['id'];
$user = $this->userService->findById($id);
if ($user === null) {
$this->flash->set('user_error', 'Utilisateur introuvable');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($user->isAdmin()) {
$this->flash->set('user_error', 'Le compte administrateur ne peut pas être supprimé');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($id === $this->sessionManager->getUserId()) {
$this->flash->set('user_error', 'Vous ne pouvez pas supprimer votre propre compte');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
try {
$this->userService->delete($id);
$this->flash->set('user_success', "L'utilisateur « {$user->getUsername()} » a été supprimé avec succès");
} catch (\Throwable $e) {
$incidentId = $this->logUnexpectedError($req, $e, [
'actor_user_id' => $this->sessionManager->getUserId(),
'target_user_id' => $id,
'target_username' => $user->getUsername(),
]);
$this->flash->set('user_error', "Une erreur inattendue s'est produite (réf. {$incidentId})");
}
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
/**
* @param array<string, mixed> $context
*/
private function logUnexpectedError(Request $req, \Throwable $e, array $context = []): string
{
$incidentId = bin2hex(random_bytes(8));
$this->logger?->error('User administration action failed', $context + [
'incident_id' => $incidentId,
'route' => (string) $req->getUri()->getPath(),
'method' => $req->getMethod(),
'exception_class' => $e::class,
'exception_message' => $e->getMessage(),
'exception' => $e,
]);
return $incidentId;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Netig\Netslim\Identity\UI\Http;
use Netig\Netslim\Identity\UI\Http\Middleware\AdminMiddleware;
use Netig\Netslim\Identity\UI\Http\Middleware\AuthMiddleware;
use Psr\Container\ContainerInterface;
use Slim\App;
/**
* Enregistre les routes HTTP du sous-domaine gestion des comptes.
*/
final class UserRoutes
{
/** @param App<ContainerInterface> $app */
public static function register(App $app): void
{
$app->group('/admin/users', function ($group) {
$group->get('', [UserController::class, 'index']);
$group->get('/create', [UserController::class, 'showCreate']);
$group->post('/create', [UserController::class, 'create']);
$group->post('/role/{id}', [UserController::class, 'updateRole']);
$group->post('/delete/{id}', [UserController::class, 'delete']);
})->add(AdminMiddleware::class)->add(AuthMiddleware::class);
}
}

View File

@@ -0,0 +1,35 @@
{% extends "@Kernel/layout.twig" %}
{% block title %}Mon compte Changer le mot de passe{% endblock %}
{% block content %}
<div class="form-container form-container--narrow">
<div class="form-container__panel">
{% include '@Kernel/partials/_auth_form_header.twig' with {
title: 'Changer le mot de passe'
} %}
{% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %}
<form method="post" action="/account/password" class="form-container__form">
{% include '@Kernel/partials/_csrf_fields.twig' %}
<p class="form-container__field">
<label for="current_password" class="form-container__label">
<span>Mot de passe actuel</span>
<input type="password" id="current_password" name="current_password" required autofocus
class="form-container__input">
</label>
</p>
{% include '@Identity/partials/_new_password_fields.twig' with { confirm_label: 'Confirmer le nouveau mot de passe' } %}
{% include '@Kernel/partials/_admin_form_actions.twig' with {
primary_label: 'Mettre à jour',
secondary_href: back_url,
secondary_label: 'Annuler'
} %}
</form>
</div>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More