first commit
This commit is contained in:
15
.editorconfig
Normal file
15
.editorconfig
Normal 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
37
.gitignore
vendored
Normal 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
36
.php-cs-fixer.dist.php
Normal 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
110
CONTRIBUTING.md
Normal 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
21
LICENSE
Normal 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
107
README.md
Normal 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
69
composer.json
Normal 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
5892
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
config/modules.php
Normal file
5
config/modules.php
Normal 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
51
docs/ARCHITECTURE.md
Normal 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 d’entré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
95
docs/DEVELOPMENT.md
Normal 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
35
docs/MODULES.md
Normal 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 d’usage.
|
||||
|
||||
## 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
44
docs/PUBLIC_API.md
Normal 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
29
docs/README.md
Normal 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
6
phpstan.neon
Normal file
@@ -0,0 +1,6 @@
|
||||
parameters:
|
||||
level: 8
|
||||
paths:
|
||||
- src
|
||||
excludePaths:
|
||||
- src/Kernel/Runtime/Bootstrap.php
|
||||
29
phpunit.xml
Normal file
29
phpunit.xml
Normal 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>
|
||||
54
src/AuditLog/Application/AuditLogApplicationService.php
Normal file
54
src/AuditLog/Application/AuditLogApplicationService.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/AuditLog/Application/AuditLogServiceInterface.php
Normal file
13
src/AuditLog/Application/AuditLogServiceInterface.php
Normal 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 {}
|
||||
44
src/AuditLog/AuditLogModule.php
Normal file
44
src/AuditLog/AuditLogModule.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
19
src/AuditLog/Contracts/AuditEntryView.php
Normal file
19
src/AuditLog/Contracts/AuditEntryView.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
14
src/AuditLog/Contracts/AuditLogReaderInterface.php
Normal file
14
src/AuditLog/Contracts/AuditLogReaderInterface.php
Normal 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;
|
||||
}
|
||||
22
src/AuditLog/Contracts/AuditLoggerInterface.php
Normal file
22
src/AuditLog/Contracts/AuditLoggerInterface.php
Normal 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;
|
||||
}
|
||||
84
src/AuditLog/Domain/Entity/AuditEntry.php
Normal file
84
src/AuditLog/Domain/Entity/AuditEntry.php
Normal 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 d’audit 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
73
src/AuditLog/Infrastructure/PdoAuditLogRepository.php
Normal file
73
src/AuditLog/Infrastructure/PdoAuditLogRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
src/AuditLog/Infrastructure/dependencies.php
Normal file
19
src/AuditLog/Infrastructure/dependencies.php
Normal 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),
|
||||
];
|
||||
24
src/AuditLog/Migrations/330_audit_log_schema.php
Normal file
24
src/AuditLog/Migrations/330_audit_log_schema.php
Normal 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;
|
||||
",
|
||||
];
|
||||
116
src/Identity/Application/AuthApplicationService.php
Normal file
116
src/Identity/Application/AuthApplicationService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
78
src/Identity/Application/AuthServiceInterface.php
Normal file
78
src/Identity/Application/AuthServiceInterface.php
Normal 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;
|
||||
}
|
||||
31
src/Identity/Application/AuthSessionInterface.php
Normal file
31
src/Identity/Application/AuthSessionInterface.php
Normal 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;
|
||||
}
|
||||
37
src/Identity/Application/AuthorizationApplicationService.php
Normal file
37
src/Identity/Application/AuthorizationApplicationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
20
src/Identity/Application/AuthorizationServiceInterface.php
Normal file
20
src/Identity/Application/AuthorizationServiceInterface.php
Normal 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;
|
||||
}
|
||||
13
src/Identity/Application/Command/AdminDeleteUserCommand.php
Normal file
13
src/Identity/Application/Command/AdminDeleteUserCommand.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
16
src/Identity/Application/Command/AuthenticateUserCommand.php
Normal file
16
src/Identity/Application/Command/AuthenticateUserCommand.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
17
src/Identity/Application/Command/ChangePasswordCommand.php
Normal file
17
src/Identity/Application/Command/ChangePasswordCommand.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
20
src/Identity/Application/Command/CreateUserCommand.php
Normal file
20
src/Identity/Application/Command/CreateUserCommand.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
16
src/Identity/Application/Command/ResetPasswordCommand.php
Normal file
16
src/Identity/Application/Command/ResetPasswordCommand.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
16
src/Identity/Application/Command/UpdateUserRoleCommand.php
Normal file
16
src/Identity/Application/Command/UpdateUserRoleCommand.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
42
src/Identity/Application/PasswordResetApplicationService.php
Normal file
42
src/Identity/Application/PasswordResetApplicationService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
40
src/Identity/Application/PasswordResetServiceInterface.php
Normal file
40
src/Identity/Application/PasswordResetServiceInterface.php
Normal 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;
|
||||
}
|
||||
37
src/Identity/Application/UseCase/AdminDeleteUser.php
Normal file
37
src/Identity/Application/UseCase/AdminDeleteUser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/Identity/Application/UseCase/AdminUpdateUserRole.php
Normal file
42
src/Identity/Application/UseCase/AdminUpdateUserRole.php
Normal 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;
|
||||
}
|
||||
}
|
||||
47
src/Identity/Application/UseCase/AuthenticateUser.php
Normal file
47
src/Identity/Application/UseCase/AuthenticateUser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
44
src/Identity/Application/UseCase/ChangePassword.php
Normal file
44
src/Identity/Application/UseCase/ChangePassword.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
src/Identity/Application/UseCase/CreateUser.php
Normal file
57
src/Identity/Application/UseCase/CreateUser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
src/Identity/Application/UseCase/DeleteUser.php
Normal file
25
src/Identity/Application/UseCase/DeleteUser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
111
src/Identity/Application/UseCase/RequestPasswordReset.php
Normal file
111
src/Identity/Application/UseCase/RequestPasswordReset.php
Normal 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);
|
||||
}
|
||||
}
|
||||
52
src/Identity/Application/UseCase/ResetPassword.php
Normal file
52
src/Identity/Application/UseCase/ResetPassword.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
32
src/Identity/Application/UseCase/UpdateUserRole.php
Normal file
32
src/Identity/Application/UseCase/UpdateUserRole.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
81
src/Identity/Application/UserApplicationService.php
Normal file
81
src/Identity/Application/UserApplicationService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
29
src/Identity/Application/UserServiceInterface.php
Normal file
29
src/Identity/Application/UserServiceInterface.php
Normal 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;
|
||||
}
|
||||
139
src/Identity/Domain/Entity/User.php
Normal file
139
src/Identity/Domain/Entity/User.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
19
src/Identity/Domain/Exception/DuplicateEmailException.php
Normal file
19
src/Identity/Domain/Exception/DuplicateEmailException.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
19
src/Identity/Domain/Exception/DuplicateUsernameException.php
Normal file
19
src/Identity/Domain/Exception/DuplicateUsernameException.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
17
src/Identity/Domain/Exception/InvalidResetTokenException.php
Normal file
17
src/Identity/Domain/Exception/InvalidResetTokenException.php
Normal 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é.');
|
||||
}
|
||||
}
|
||||
22
src/Identity/Domain/Exception/InvalidRoleException.php
Normal file
22
src/Identity/Domain/Exception/InvalidRoleException.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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é');
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/Identity/Domain/Exception/WeakPasswordException.php
Normal file
22
src/Identity/Domain/Exception/WeakPasswordException.php
Normal 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");
|
||||
}
|
||||
}
|
||||
36
src/Identity/Domain/Policy/LoginRateLimitPolicy.php
Normal file
36
src/Identity/Domain/Policy/LoginRateLimitPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
47
src/Identity/Domain/Policy/PasswordPolicy.php
Normal file
47
src/Identity/Domain/Policy/PasswordPolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/Identity/Domain/Policy/PasswordResetTokenPolicy.php
Normal file
16
src/Identity/Domain/Policy/PasswordResetTokenPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
37
src/Identity/Domain/Policy/Permission.php
Normal file
37
src/Identity/Domain/Policy/Permission.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
37
src/Identity/Domain/Policy/RolePermissionMatrix.php
Normal file
37
src/Identity/Domain/Policy/RolePermissionMatrix.php
Normal 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);
|
||||
}
|
||||
}
|
||||
43
src/Identity/Domain/Policy/RolePolicy.php
Normal file
43
src/Identity/Domain/Policy/RolePolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
70
src/Identity/Domain/Repository/UserRepositoryInterface.php
Normal file
70
src/Identity/Domain/Repository/UserRepositoryInterface.php
Normal 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;
|
||||
}
|
||||
62
src/Identity/IdentityModule.php
Normal file
62
src/Identity/IdentityModule.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
47
src/Identity/Infrastructure/AdminUserProvisioner.php
Normal file
47
src/Identity/Infrastructure/AdminUserProvisioner.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
96
src/Identity/Infrastructure/PdoLoginAttemptRepository.php
Normal file
96
src/Identity/Infrastructure/PdoLoginAttemptRepository.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
117
src/Identity/Infrastructure/PdoPasswordResetRepository.php
Normal file
117
src/Identity/Infrastructure/PdoPasswordResetRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
114
src/Identity/Infrastructure/PdoUserRepository.php
Normal file
114
src/Identity/Infrastructure/PdoUserRepository.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
37
src/Identity/Infrastructure/SessionAuthSession.php
Normal file
37
src/Identity/Infrastructure/SessionAuthSession.php
Normal 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();
|
||||
}
|
||||
}
|
||||
113
src/Identity/Infrastructure/dependencies.php
Normal file
113
src/Identity/Infrastructure/dependencies.php
Normal 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,
|
||||
);
|
||||
}),
|
||||
];
|
||||
42
src/Identity/Migrations/100_identity_schema.php
Normal file
42
src/Identity/Migrations/100_identity_schema.php
Normal 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;
|
||||
",
|
||||
];
|
||||
98
src/Identity/UI/Http/AccountController.php
Normal file
98
src/Identity/UI/Http/AccountController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
src/Identity/UI/Http/AdminHomePath.php
Normal file
25
src/Identity/UI/Http/AdminHomePath.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
114
src/Identity/UI/Http/AuthController.php
Normal file
114
src/Identity/UI/Http/AuthController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
33
src/Identity/UI/Http/AuthRoutes.php
Normal file
33
src/Identity/UI/Http/AuthRoutes.php
Normal 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);
|
||||
}
|
||||
}
|
||||
50
src/Identity/UI/Http/Middleware/AdminMiddleware.php
Normal file
50
src/Identity/UI/Http/Middleware/AdminMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
80
src/Identity/UI/Http/Middleware/AuthMiddleware.php
Normal file
80
src/Identity/UI/Http/Middleware/AuthMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
50
src/Identity/UI/Http/Middleware/EditorMiddleware.php
Normal file
50
src/Identity/UI/Http/Middleware/EditorMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
174
src/Identity/UI/Http/PasswordResetController.php
Normal file
174
src/Identity/UI/Http/PasswordResetController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
43
src/Identity/UI/Http/Request/ChangePasswordRequest.php
Normal file
43
src/Identity/UI/Http/Request/ChangePasswordRequest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/Identity/UI/Http/Request/CreateUserRequest.php
Normal file
44
src/Identity/UI/Http/Request/CreateUserRequest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/Identity/UI/Http/Request/ForgotPasswordRequest.php
Normal file
23
src/Identity/UI/Http/Request/ForgotPasswordRequest.php
Normal 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'] ?? '')));
|
||||
}
|
||||
}
|
||||
29
src/Identity/UI/Http/Request/LoginRequest.php
Normal file
29
src/Identity/UI/Http/Request/LoginRequest.php
Normal 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'] ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/Identity/UI/Http/Request/ResetPasswordRequest.php
Normal file
55
src/Identity/UI/Http/Request/ResetPasswordRequest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Identity/UI/Http/Request/UpdateUserRoleRequest.php
Normal file
36
src/Identity/UI/Http/Request/UpdateUserRoleRequest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
202
src/Identity/UI/Http/UserController.php
Normal file
202
src/Identity/UI/Http/UserController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
28
src/Identity/UI/Http/UserRoutes.php
Normal file
28
src/Identity/UI/Http/UserRoutes.php
Normal 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);
|
||||
}
|
||||
}
|
||||
35
src/Identity/UI/Templates/account/password-change.twig
Normal file
35
src/Identity/UI/Templates/account/password-change.twig
Normal 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
Reference in New Issue
Block a user