Refatoring : Working state
This commit is contained in:
115
src/Auth/Http/AccountController.php
Normal file
115
src/Auth/Http/AccountController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
76
src/Auth/Http/AuthController.php
Normal file
76
src/Auth/Http/AuthController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
217
src/Auth/Http/PasswordResetController.php
Normal file
217
src/Auth/Http/PasswordResetController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user