first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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