Refatoring : Working state

This commit is contained in:
julien
2026-03-16 14:50:58 +01:00
parent 115b079dfb
commit 664d74cd69
18 changed files with 842 additions and 846 deletions

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Auth\Http;
use App\Auth\AuthServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\WeakPasswordException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
/**
* Contrôleur pour les actions liées au compte de l'utilisateur connecté.
*
* Accessible uniquement aux utilisateurs authentifiés (AuthMiddleware).
* Actuellement limité au changement de mot de passe, accessible via
* le lien « Mon compte » dans le header.
*/
class AccountController
{
/**
* @param Twig $view Moteur de templates Twig
* @param AuthServiceInterface $authService Service d'authentification
* @param FlashServiceInterface $flash Service de messages flash
* @param SessionManagerInterface $sessionManager Gestionnaire de session
*/
public function __construct(
private readonly Twig $view,
private readonly AuthServiceInterface $authService,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
) {
}
/**
* Affiche le formulaire de changement de mot de passe.
*
* Transmet les messages flash d'erreur et de succès issus
* d'une soumission précédente, ainsi que l'URL de retour pour
* le bouton Annuler (déduite du Referer, validée pour éviter
* les redirections ouvertes vers des domaines externes).
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue pages/account/password-change.twig
*/
public function showChangePassword(Request $req, Response $res): Response
{
// Récupère l'URL de la page précédente pour le bouton Annuler.
// On valide que le Referer est bien une URL relative du site (commence par /)
// pour éviter toute redirection vers un domaine externe (open redirect).
$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
: '/admin/posts';
return $this->view->render($res, 'pages/account/password-change.twig', [
'error' => $this->flash->get('password_error'),
'success' => $this->flash->get('password_success'),
'back_url' => $backUrl,
]);
}
/**
* Traite la soumission du formulaire de changement de mot de passe.
*
* Vérifie que les deux nouveaux mots de passe sont identiques,
* puis délègue la vérification du mot de passe actuel et la mise
* à jour à AuthService.
*
* Note : getUserId() ne peut pas retourner null ici car la route
* est protégée par AuthMiddleware. La valeur de repli 0 ne sera
* jamais atteinte en pratique.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Une redirection vers /account/password dans tous les cas
*/
public function changePassword(Request $req, Response $res): Response
{
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$current = trim((string) ($data['current_password'] ?? ''));
$new = trim((string) ($data['new_password'] ?? ''));
$confirm = trim((string) ($data['new_password_confirm'] ?? ''));
// getUserId() ne peut pas être null : route protégée par AuthMiddleware
$userId = $this->sessionManager->getUserId() ?? 0;
if ($new !== $confirm) {
$this->flash->set('password_error', 'Les mots de passe ne correspondent pas');
return $res->withHeader('Location', '/account/password')->withStatus(302);
}
try {
$this->authService->changePassword($userId, $current, $new);
$this->flash->set('password_success', 'Mot de passe modifié avec succès');
} catch (WeakPasswordException) {
$this->flash->set('password_error', 'Le nouveau mot de passe doit contenir au moins 8 caractères');
} catch (\InvalidArgumentException) {
$this->flash->set('password_error', 'Le mot de passe actuel est incorrect');
} catch (\Throwable) {
$this->flash->set('password_error', 'Une erreur inattendue s\'est produite');
}
return $res->withHeader('Location', '/account/password')->withStatus(302);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Auth\Http;
use App\Auth\AuthServiceInterface;
use App\Shared\Http\ClientIpResolver;
use App\Shared\Http\FlashServiceInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
class AuthController
{
public function __construct(
private readonly Twig $view,
private readonly AuthServiceInterface $authService,
private readonly FlashServiceInterface $flash,
private readonly ClientIpResolver $clientIpResolver,
) {
}
public function showLogin(Request $req, Response $res): Response
{
if ($this->authService->isLoggedIn()) {
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
return $this->view->render($res, 'pages/auth/login.twig', [
'error' => $this->flash->get('login_error'),
'success' => $this->flash->get('login_success'),
]);
}
public function login(Request $req, Response $res): Response
{
$ip = $this->clientIpResolver->resolve($req);
$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);
}
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$username = trim((string) ($data['username'] ?? ''));
$password = trim((string) ($data['password'] ?? ''));
$user = $this->authService->authenticate($username, $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);
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
public function logout(Request $req, Response $res): Response
{
$this->authService->logout();
return $res->withHeader('Location', '/')->withStatus(302);
}
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Auth\Http;
use App\Auth\AuthServiceInterface;
use App\Auth\PasswordResetServiceInterface;
use App\Auth\Exception\InvalidResetTokenException;
use App\Shared\Http\ClientIpResolver;
use App\Shared\Http\FlashServiceInterface;
use App\User\Exception\WeakPasswordException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
/**
* Contrôleur de réinitialisation de mot de passe.
*
* Gère le flux en deux étapes :
* 1. Demande de réinitialisation : saisie de l'email (GET/POST /password/forgot)
* 2. Réinitialisation effective : saisie du nouveau mot de passe (GET/POST /password/reset)
*
* Sécurité :
* - Le formulaire de demande affiche toujours un message de succès générique,
* même si l'email est inconnu, pour éviter l'énumération des comptes.
* - Le token est transmis uniquement via l'URL (GET) et un champ hidden (POST),
* jamais via la session.
* - Le endpoint POST /password/forgot est soumis au même rate limiting par IP
* que le login : 5 tentatives autorisées, verrouillage 15 min au-delà.
* Toute tentative est comptabilisée — il n'existe pas de "succès" identifiable
* sans révéler si l'adresse est enregistrée (anti-énumération).
*/
class PasswordResetController
{
/**
* @param Twig $view Moteur de templates Twig
* @param PasswordResetServiceInterface $passwordResetService Service de réinitialisation
* @param AuthServiceInterface $authService Service d'authentification (rate limiting)
* @param FlashServiceInterface $flash Service de messages flash
* @param ClientIpResolver $clientIpResolver Résout l'IP réelle derrière un proxy approuvé
* @param string $baseUrl URL de base de l'application (depuis APP_URL dans .env)
*/
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,
) {
}
/**
* Affiche le formulaire de demande de réinitialisation.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue pages/auth/password-forgot.twig
*/
public function showForgot(Request $req, Response $res): Response
{
return $this->view->render($res, 'pages/auth/password-forgot.twig', [
'error' => $this->flash->get('reset_error'),
'success' => $this->flash->get('reset_success'),
]);
}
/**
* Traite la demande de réinitialisation.
*
* Vérifie d'abord le rate limit par IP. Toute tentative est enregistrée
* comme un échec — qu'un email existe ou non — afin de ne pas déséquilibrer
* le compteur en fonction du résultat, ce qui permettrait de déduire
* l'existence d'un compte (canal caché sur le rate-limit).
*
* Génère un token et envoie l'email si l'adresse existe.
* Affiche toujours un message de succès générique — ne révèle pas
* si l'adresse est enregistrée (protection contre l'énumération).
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Redirection vers /password/forgot avec message flash
*/
public function forgot(Request $req, Response $res): Response
{
$ip = $this->clientIpResolver->resolve($req);
// Vérification du rate limit avant tout traitement
$remainingMinutes = $this->authService->checkRateLimit($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);
}
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$email = trim((string) ($data['email'] ?? ''));
// La tentative est enregistrée systématiquement, résultat connu ou non.
// Réinitialiser le compteur uniquement en cas de succès révélerait si
// l'adresse existe (canal caché : compteur remis à zéro = email valide).
$this->authService->recordFailure($ip);
try {
$this->passwordResetService->requestReset($email, $this->baseUrl);
} catch (\RuntimeException) {
// Erreur d'envoi d'email — on n'expose pas le détail à l'utilisateur
$this->flash->set('reset_error', 'Une erreur est survenue. Veuillez réessayer.');
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
// Message générique — ne révèle pas si l'email est connu
$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);
}
/**
* Affiche le formulaire de saisie du nouveau mot de passe.
*
* Valide le token en amont — redirige vers /password/forgot avec un message
* d'erreur si le token est absent, invalide ou expiré.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue pages/auth/password-reset.twig ou une redirection
*/
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, 'pages/auth/password-reset.twig', [
'token' => $token,
'error' => $this->flash->get('reset_error'),
]);
}
/**
* Traite la soumission du nouveau mot de passe.
*
* Vérifie que les deux mots de passe correspondent, puis délègue
* la validation du token et la mise à jour à PasswordResetServiceInterface.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Redirection vers /auth/login en cas de succès,
* ou vers /password/reset?token=… en cas d'erreur
*/
public function reset(Request $req, Response $res): Response
{
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$token = trim((string) ($data['token'] ?? ''));
$new = trim((string) ($data['new_password'] ?? ''));
$confirm = trim((string) ($data['new_password_confirm'] ?? ''));
if ($token === '') {
$this->flash->set('reset_error', 'Lien de réinitialisation manquant');
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
if ($new !== $confirm) {
$this->flash->set('reset_error', 'Les mots de passe ne correspondent pas');
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
}
try {
$this->passwordResetService->resetPassword($token, $new);
} catch (WeakPasswordException) {
$this->flash->set('reset_error', 'Le mot de passe doit contenir au moins 8 caractères');
return $res->withHeader('Location', '/password/reset?token=' . urlencode($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($token))->withStatus(302);
} catch (\Throwable) {
$this->flash->set('reset_error', 'Une erreur inattendue s\'est produite');
return $res->withHeader('Location', '/password/reset?token=' . urlencode($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);
}
}