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

@@ -12,14 +12,14 @@ declare(strict_types=1);
* sont typées sur des interfaces) est résolu automatiquement par l'autowiring.
*/
use App\Auth\AuthService;
use App\Auth\Application\AuthApplicationService;
use App\Auth\AuthServiceInterface;
use App\Auth\LoginAttemptRepository;
use App\Auth\Infrastructure\PdoLoginAttemptRepository;
use App\Auth\LoginAttemptRepositoryInterface;
use App\Auth\PasswordResetController;
use App\Auth\PasswordResetRepository;
use App\Auth\Http\PasswordResetController;
use App\Auth\Infrastructure\PdoPasswordResetRepository;
use App\Auth\PasswordResetRepositoryInterface;
use App\Auth\PasswordResetService;
use App\Auth\Application\PasswordResetApplicationService;
use App\Auth\PasswordResetServiceInterface;
use App\Category\Application\CategoryApplicationService;
use App\Category\CategoryRepository;
@@ -72,7 +72,7 @@ return [
// ── Bindings interface → implémentation ──────────────────────────────────
AuthServiceInterface::class => autowire(AuthService::class),
AuthServiceInterface::class => autowire(AuthApplicationService::class),
PostServiceInterface::class => autowire(PostApplicationService::class),
UserServiceInterface::class => autowire(UserApplicationService::class),
CategoryServiceInterface::class => autowire(CategoryApplicationService::class),
@@ -80,9 +80,9 @@ return [
MediaRepositoryInterface::class => autowire(PdoMediaRepository::class),
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
UserRepositoryInterface::class => autowire(PdoUserRepository::class),
LoginAttemptRepositoryInterface::class => autowire(LoginAttemptRepository::class),
PasswordResetRepositoryInterface::class => autowire(PasswordResetRepository::class),
PasswordResetServiceInterface::class => autowire(PasswordResetService::class),
LoginAttemptRepositoryInterface::class => autowire(PdoLoginAttemptRepository::class),
PasswordResetRepositoryInterface::class => autowire(PdoPasswordResetRepository::class),
PasswordResetServiceInterface::class => autowire(PasswordResetApplicationService::class),
FlashServiceInterface::class => autowire(FlashService::class),
SessionManagerInterface::class => autowire(SessionManager::class),
HtmlSanitizerInterface::class => autowire(HtmlSanitizer::class),

View File

@@ -1,8 +1,8 @@
# Architecture
> **Refactor DDD légère — lots 1 à 3**
> **Refactor DDD légère — lots 1 à 4**
>
> `Post/`, `Category/`, `User/` et `Media/` introduisent maintenant une organisation verticale
> `Post/`, `Category/`, `User/`, `Media/` et `Auth/` introduisent maintenant une organisation verticale
> `Application / Infrastructure / Http / Domain` pour alléger la lecture et préparer
> un découpage plus fin par cas d'usage. Les classes historiques à la racine du domaine
> sont conservées comme **ponts de compatibilité** afin de préserver les routes, le conteneur
@@ -85,10 +85,10 @@ final class PostService
|------------------------------------|---------------------------|------------|
| `UserRepositoryInterface` | `PdoUserRepository` | `User/` |
| `UserServiceInterface` | `UserApplicationService` | `User/` |
| `LoginAttemptRepositoryInterface` | `LoginAttemptRepository` | `Auth/` |
| `PasswordResetRepositoryInterface` | `PasswordResetRepository` | `Auth/` |
| `PasswordResetServiceInterface` | `PasswordResetService` | `Auth/` |
| `AuthServiceInterface` | `AuthService` | `Auth/` |
| `LoginAttemptRepositoryInterface` | `PdoLoginAttemptRepository` | `Auth/` |
| `PasswordResetRepositoryInterface` | `PdoPasswordResetRepository` | `Auth/` |
| `PasswordResetServiceInterface` | `PasswordResetApplicationService` | `Auth/` |
| `AuthServiceInterface` | `AuthApplicationService` | `Auth/` |
| `PostRepositoryInterface` | `PostRepository` | `Post/` |
| `PostServiceInterface` | `PostService` | `Post/` |
| `CategoryRepositoryInterface` | `CategoryRepository` | `Category/`|

View File

@@ -3,112 +3,10 @@ declare(strict_types=1);
namespace App\Auth;
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.
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
* App\Auth\Http\AccountController.
*/
final class AccountController
final class AccountController extends Http\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,109 @@
<?php
declare(strict_types=1);
namespace App\Auth\Application;
use App\Auth\AuthServiceInterface;
use App\Auth\Domain\LoginRateLimitPolicy;
use App\Auth\LoginAttemptRepositoryInterface;
use App\Shared\Exception\NotFoundException;
use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
class AuthApplicationService implements AuthServiceInterface
{
private readonly LoginRateLimitPolicy $rateLimitPolicy;
public function __construct(
private readonly UserRepositoryInterface $userRepository,
private readonly SessionManagerInterface $sessionManager,
private readonly LoginAttemptRepositoryInterface $loginAttemptRepository,
?LoginRateLimitPolicy $rateLimitPolicy = null,
) {
$this->rateLimitPolicy = $rateLimitPolicy ?? new LoginRateLimitPolicy();
}
public function checkRateLimit(string $ip): int
{
$this->loginAttemptRepository->deleteExpired();
$row = $this->loginAttemptRepository->findByIp($ip);
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));
}
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 authenticate(string $username, string $plainPassword): ?User
{
$user = $this->userRepository->findByUsername(mb_strtolower(trim($username)));
if ($user === null) {
return null;
}
if (!password_verify(trim($plainPassword), $user->getPasswordHash())) {
return null;
}
return $user;
}
public function changePassword(int $userId, string $currentPassword, string $newPassword): void
{
$user = $this->userRepository->findById($userId);
if ($user === null) {
throw new NotFoundException('Utilisateur', $userId);
}
if (!password_verify(trim($currentPassword), $user->getPasswordHash())) {
throw new \InvalidArgumentException('Mot de passe actuel incorrect');
}
if (mb_strlen(trim($newPassword)) < 8) {
throw new WeakPasswordException();
}
$newHash = password_hash(trim($newPassword), PASSWORD_BCRYPT, ['cost' => 12]);
$this->userRepository->updatePassword($userId, $newHash);
}
public function isLoggedIn(): bool
{
return $this->sessionManager->isAuthenticated();
}
public function login(User $user): void
{
$this->sessionManager->setUser($user->getId(), $user->getUsername(), $user->getRole());
}
public function logout(): void
{
$this->sessionManager->destroy();
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Auth\Application;
use App\Auth\Domain\PasswordResetTokenPolicy;
use App\Auth\Exception\InvalidResetTokenException;
use App\Auth\PasswordResetRepositoryInterface;
use App\Auth\PasswordResetServiceInterface;
use App\Shared\Mail\MailServiceInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
use PDO;
class PasswordResetApplicationService implements PasswordResetServiceInterface
{
private readonly PasswordResetTokenPolicy $tokenPolicy;
public function __construct(
private readonly PasswordResetRepositoryInterface $passwordResetRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly MailServiceInterface $mailService,
private readonly PDO $db,
?PasswordResetTokenPolicy $tokenPolicy = null,
) {
$this->tokenPolicy = $tokenPolicy ?? new PasswordResetTokenPolicy();
}
public function requestReset(string $email, string $baseUrl): void
{
$user = $this->userRepository->findByEmail(mb_strtolower(trim($email)));
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($baseUrl, '/') . '/password/reset?token=' . $tokenRaw;
$this->mailService->send(
to: $user->getEmail(),
subject: 'Réinitialisation de votre mot de passe',
template: 'emails/password-reset.twig',
context: [
'username' => $user->getUsername(),
'resetUrl' => $resetUrl,
'ttlMinutes' => $this->tokenPolicy->ttlMinutes(),
]
);
}
public function validateToken(string $tokenRaw): ?User
{
$tokenHash = hash('sha256', $tokenRaw);
$row = $this->passwordResetRepository->findActiveByHash($tokenHash);
if ($row === null) {
return null;
}
if (strtotime((string) $row['expires_at']) < time()) {
return null;
}
return $this->userRepository->findById((int) $row['user_id']);
}
public function resetPassword(string $tokenRaw, string $newPassword): void
{
if (mb_strlen(trim($newPassword)) < 8) {
throw new WeakPasswordException();
}
$usedAt = date('Y-m-d H:i:s');
$newHash = password_hash(trim($newPassword), PASSWORD_BCRYPT, ['cost' => 12]);
$this->db->beginTransaction();
try {
$row = $this->passwordResetRepository->consumeActiveToken(hash('sha256', $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);
$this->db->commit();
} catch (\Throwable $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
}
}

View File

@@ -3,73 +3,10 @@ declare(strict_types=1);
namespace App\Auth;
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;
final class AuthController
/**
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
* App\Auth\Http\AuthController.
*/
final class AuthController extends Http\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

@@ -3,191 +3,12 @@ declare(strict_types=1);
namespace App\Auth;
use App\Shared\Exception\NotFoundException;
use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
use App\Auth\Application\AuthApplicationService;
/**
* Service d'authentification.
*
* Centralise la logique métier liée à l'authentification :
* vérification des identifiants, ouverture et fermeture de session,
* changement de mot de passe et protection brute-force par IP.
*
* La création de comptes est déléguée à UserService (domaine User).
* La gestion de la session PHP est entièrement déléguée à SessionManagerInterface.
* Les noms d'utilisateurs sont normalisés en minuscules pour garantir
* l'insensibilité à la casse.
* Pont de compatibilité : l'implémentation principale vit désormais dans
* App\Auth\Application\AuthApplicationService.
*/
final class AuthService implements AuthServiceInterface
final class AuthService extends AuthApplicationService implements AuthServiceInterface
{
/**
* Nombre maximum de tentatives échouées avant verrouillage.
*/
private const MAX_ATTEMPTS = 5;
/**
* Durée du verrouillage en minutes après dépassement du seuil.
*/
private const LOCK_MINUTES = 15;
/**
* @param UserRepositoryInterface $userRepository Dépôt de persistance des utilisateurs
* @param SessionManagerInterface $sessionManager Gestionnaire de session PHP
* @param LoginAttemptRepositoryInterface $loginAttemptRepository Dépôt des tentatives de connexion
*/
public function __construct(
private readonly UserRepositoryInterface $userRepository,
private readonly SessionManagerInterface $sessionManager,
private readonly LoginAttemptRepositoryInterface $loginAttemptRepository,
) {
}
/**
* Vérifie si une adresse IP est actuellement verrouillée.
*
* Nettoie les entrées expirées avant la vérification. Si l'IP est
* verrouillée, retourne le nombre de minutes restantes (arrondi au supérieur,
* minimum 1) calculé depuis les timestamps bruts pour éviter les erreurs
* d'arithmétique sur DateInterval pour des durées supérieures à 59 minutes.
*
* @param string $ip Adresse IP du client
*
* @return int 0 si libre, nombre de minutes restantes si verrouillé
*/
public function checkRateLimit(string $ip): int
{
$this->loginAttemptRepository->deleteExpired();
$row = $this->loginAttemptRepository->findByIp($ip);
if ($row === null || $row['locked_until'] === null) {
return 0;
}
$lockedUntil = new \DateTime($row['locked_until']);
$now = new \DateTime();
if ($lockedUntil <= $now) {
return 0;
}
// $diff->i et $diff->h sont des portions de l'intervalle (ex: 1h30 → h=1, i=30),
// pas des totaux — la somme serait incorrecte pour des durées > 59 min.
// On calcule directement depuis les timestamps bruts.
$secondsLeft = $lockedUntil->getTimestamp() - $now->getTimestamp();
return max(1, (int) ceil($secondsLeft / 60));
}
/**
* Enregistre un échec de connexion pour une IP.
*
* @param string $ip Adresse IP du client
* @return void
*/
public function recordFailure(string $ip): void
{
$this->loginAttemptRepository->recordFailure($ip, self::MAX_ATTEMPTS, self::LOCK_MINUTES);
}
/**
* Réinitialise le compteur de tentatives pour une IP (connexion réussie).
*
* @param string $ip Adresse IP du client
*/
public function resetRateLimit(string $ip): void
{
$this->loginAttemptRepository->resetForIp($ip);
}
/**
* Authentifie un utilisateur par nom d'utilisateur et mot de passe.
*
* Le nom d'utilisateur est normalisé en minuscules avant la recherche.
* Ne gère pas le rate limiting — responsabilité de l'appelant (AuthController).
*
* @param string $username Nom d'utilisateur (insensible à la casse)
* @param string $plainPassword Mot de passe en clair
*
* @return User|null L'utilisateur authentifié, ou null si les identifiants sont invalides
*/
public function authenticate(string $username, string $plainPassword): ?User
{
$user = $this->userRepository->findByUsername(mb_strtolower(trim($username)));
if ($user === null) {
return null;
}
if (!password_verify(trim($plainPassword), $user->getPasswordHash())) {
return null;
}
return $user;
}
/**
* Modifie le mot de passe de l'utilisateur connecté.
*
* Vérifie le mot de passe actuel avant d'appliquer le changement.
*
* @param int $userId Identifiant de l'utilisateur
* @param string $currentPassword Mot de passe actuel en clair (pour vérification)
* @param string $newPassword Nouveau mot de passe en clair (min. 8 caractères)
*
* @throws NotFoundException Si l'utilisateur est introuvable
* @throws \InvalidArgumentException Si le mot de passe actuel est incorrect
* @throws WeakPasswordException Si le nouveau mot de passe est trop court
* @return void
*/
public function changePassword(int $userId, string $currentPassword, string $newPassword): void
{
$user = $this->userRepository->findById($userId);
if ($user === null) {
throw new NotFoundException('Utilisateur', $userId);
}
if (!password_verify(trim($currentPassword), $user->getPasswordHash())) {
throw new \InvalidArgumentException('Mot de passe actuel incorrect');
}
if (mb_strlen(trim($newPassword)) < 8) {
throw new WeakPasswordException();
}
$newHash = password_hash(trim($newPassword), PASSWORD_BCRYPT, ['cost' => 12]);
$this->userRepository->updatePassword($userId, $newHash);
}
/**
* Vérifie si un utilisateur est actuellement connecté.
*
* @return bool True si une session utilisateur est active
*/
public function isLoggedIn(): bool
{
return $this->sessionManager->isAuthenticated();
}
/**
* Ouvre une session pour l'utilisateur donné.
*
* @param User $user L'utilisateur à connecter
*/
public function login(User $user): void
{
$this->sessionManager->setUser($user->getId(), $user->getUsername(), $user->getRole());
}
/**
* Ferme la session de l'utilisateur connecté.
*/
public function logout(): void
{
$this->sessionManager->destroy();
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Auth\Domain;
class LoginRateLimitPolicy
{
public function maxAttempts(): int
{
return 5;
}
public function lockMinutes(): int
{
return 15;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Auth\Domain;
class PasswordResetTokenPolicy
{
public function ttlMinutes(): int
{
return 60;
}
}

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);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Auth\Infrastructure;
use App\Auth\LoginAttemptRepositoryInterface;
use PDO;
class PdoLoginAttemptRepository implements LoginAttemptRepositoryInterface
{
public function __construct(private readonly PDO $db)
{
}
public function findByIp(string $ip): ?array
{
$stmt = $this->db->prepare('SELECT * FROM login_attempts WHERE ip = :ip');
$stmt->execute([':ip' => $ip]);
/** @var array{ip: string, attempts: int, locked_until: string|null, updated_at: string}|false $row */
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function recordFailure(string $ip, 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 login_attempts (ip, attempts, locked_until, updated_at)
VALUES (:ip, 1, CASE WHEN 1 >= :max1 THEN :lock1 ELSE NULL END, :now1)
ON CONFLICT(ip) DO UPDATE SET
attempts = login_attempts.attempts + 1,
locked_until = CASE WHEN login_attempts.attempts + 1 >= :max2
THEN :lock2
ELSE NULL END,
updated_at = :now2'
);
$stmt->execute([
':ip' => $ip,
':max1' => $maxAttempts,
':lock1' => $lockUntil,
':now1' => $now,
':max2' => $maxAttempts,
':lock2' => $lockUntil,
':now2' => $now,
]);
}
public function resetForIp(string $ip): void
{
$stmt = $this->db->prepare('DELETE FROM login_attempts WHERE ip = :ip');
$stmt->execute([':ip' => $ip]);
}
public function deleteExpired(): void
{
$now = (new \DateTime())->format('Y-m-d H:i:s');
$stmt = $this->db->prepare(
'DELETE FROM login_attempts WHERE locked_until IS NOT NULL AND locked_until < :now'
);
$stmt->execute([':now' => $now]);
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Auth\Infrastructure;
use App\Auth\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 $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 $row ?: null;
}
}

View File

@@ -3,114 +3,12 @@ declare(strict_types=1);
namespace App\Auth;
use PDO;
use App\Auth\Infrastructure\PdoLoginAttemptRepository;
/**
* Dépôt de persistance des tentatives de connexion.
*
* Gère la lecture et l'écriture dans la table `login_attempts` pour
* la protection contre le brute-force sur le formulaire de connexion.
*
* La clé primaire est l'adresse IP — une seule ligne par IP, mise à jour
* à chaque tentative (UPSERT). Les entrées dont `locked_until` est dépassé
* sont réinitialisées automatiquement lors de la prochaine vérification.
* Pont de compatibilité : l'implémentation PDO principale vit désormais dans
* App\Auth\Infrastructure\PdoLoginAttemptRepository.
*/
final class LoginAttemptRepository implements LoginAttemptRepositoryInterface
final class LoginAttemptRepository extends PdoLoginAttemptRepository implements LoginAttemptRepositoryInterface
{
/**
* @param PDO $db Instance de connexion à la base de données
*/
public function __construct(private readonly PDO $db)
{
}
/**
* Retourne la ligne de tentatives pour une IP donnée, ou null si absente.
*
* @param string $ip Adresse IP du client
*
* @return array{ip: string, attempts: int, locked_until: string|null, updated_at: string}|null
*/
public function findByIp(string $ip): ?array
{
$stmt = $this->db->prepare('SELECT * FROM login_attempts WHERE ip = :ip');
$stmt->execute([':ip' => $ip]);
/** @var array{ip: string, attempts: int, locked_until: string|null, updated_at: string}|false $row */
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
/**
* Enregistre un échec de connexion pour une IP via un UPSERT atomique.
*
* Une seule opération SQL insère la ligne si elle n'existe pas, ou incrémente
* le compteur si elle existe déjà. L'atomicité élimine la race condition
* présente dans un pattern SELECT + INSERT/UPDATE séparé.
*
* Le paramètre locked_until est calculé côté PHP avant la requête afin de
* garder la logique lisible et testable ; il est passé deux fois (une pour
* le cas INSERT, une pour le cas UPDATE) car PDO interdit les paramètres nommés
* dupliqués dans une même requête préparée.
*
* Requiert SQLite >= 3.24 (juin 2018) pour la syntaxe ON CONFLICT DO UPDATE.
*
* @param string $ip Adresse IP du client
* @param int $maxAttempts Nombre d'échecs avant verrouillage
* @param int $lockMinutes Durée du verrouillage en minutes
*/
public function recordFailure(string $ip, 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 login_attempts (ip, attempts, locked_until, updated_at)
VALUES (:ip, 1, CASE WHEN 1 >= :max1 THEN :lock1 ELSE NULL END, :now1)
ON CONFLICT(ip) DO UPDATE SET
attempts = login_attempts.attempts + 1,
locked_until = CASE WHEN login_attempts.attempts + 1 >= :max2
THEN :lock2
ELSE NULL END,
updated_at = :now2'
);
$stmt->execute([
':ip' => $ip,
':max1' => $maxAttempts,
':lock1' => $lockUntil,
':now1' => $now,
':max2' => $maxAttempts,
':lock2' => $lockUntil,
':now2' => $now,
]);
}
/**
* Réinitialise le compteur de tentatives pour une IP (connexion réussie).
*
* @param string $ip Adresse IP du client
* @return void
*/
public function resetForIp(string $ip): void
{
$stmt = $this->db->prepare('DELETE FROM login_attempts WHERE ip = :ip');
$stmt->execute([':ip' => $ip]);
}
/**
* Supprime les entrées dont le verrouillage est expiré.
*
* Appelé à chaque tentative de connexion pour éviter l'accumulation
* de lignes obsolètes sans tâche planifiée externe.
*/
public function deleteExpired(): void
{
$now = (new \DateTime())->format('Y-m-d H:i:s');
$stmt = $this->db->prepare(
'DELETE FROM login_attempts WHERE locked_until IS NOT NULL AND locked_until < :now'
);
$stmt->execute([':now' => $now]);
}
}

View File

@@ -3,213 +3,10 @@ declare(strict_types=1);
namespace App\Auth;
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).
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
* App\Auth\Http\PasswordResetController.
*/
final class PasswordResetController
final class PasswordResetController extends Http\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);
}
}

View File

@@ -3,72 +3,12 @@ declare(strict_types=1);
namespace App\Auth;
use PDO;
use App\Auth\Infrastructure\PdoPasswordResetRepository;
final class PasswordResetRepository implements PasswordResetRepositoryInterface
/**
* Pont de compatibilité : l'implémentation PDO principale vit désormais dans
* App\Auth\Infrastructure\PdoPasswordResetRepository.
*/
final class PasswordResetRepository extends 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]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $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]);
}
/**
* Atomically consume a token and return the affected row.
* Uses UPDATE ... RETURNING to avoid SELECT+UPDATE race conditions.
*/
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,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
}

View File

@@ -3,103 +3,12 @@ declare(strict_types=1);
namespace App\Auth;
use App\Auth\Exception\InvalidResetTokenException;
use App\Shared\Mail\MailServiceInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
use PDO;
use App\Auth\Application\PasswordResetApplicationService;
final class PasswordResetService implements PasswordResetServiceInterface
/**
* Pont de compatibilité : l'implémentation principale vit désormais dans
* App\Auth\Application\PasswordResetApplicationService.
*/
final class PasswordResetService extends PasswordResetApplicationService implements PasswordResetServiceInterface
{
private const TOKEN_TTL_MINUTES = 60;
public function __construct(
private readonly PasswordResetRepositoryInterface $passwordResetRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly MailServiceInterface $mailService,
private readonly PDO $db,
) {
}
public function requestReset(string $email, string $baseUrl): void
{
$user = $this->userRepository->findByEmail(mb_strtolower(trim($email)));
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() + self::TOKEN_TTL_MINUTES * 60);
$this->passwordResetRepository->create($user->getId(), $tokenHash, $expiresAt);
$resetUrl = rtrim($baseUrl, '/') . '/password/reset?token=' . $tokenRaw;
$this->mailService->send(
to: $user->getEmail(),
subject: 'Réinitialisation de votre mot de passe',
template: 'emails/password-reset.twig',
context: [
'username' => $user->getUsername(),
'resetUrl' => $resetUrl,
'ttlMinutes' => self::TOKEN_TTL_MINUTES,
]
);
}
public function validateToken(string $tokenRaw): ?User
{
$tokenHash = hash('sha256', $tokenRaw);
$row = $this->passwordResetRepository->findActiveByHash($tokenHash);
if ($row === null) {
return null;
}
if (strtotime((string) $row['expires_at']) < time()) {
return null;
}
return $this->userRepository->findById((int) $row['user_id']);
}
public function resetPassword(string $tokenRaw, string $newPassword): void
{
if (mb_strlen(trim($newPassword)) < 8) {
throw new WeakPasswordException();
}
$usedAt = date('Y-m-d H:i:s');
$newHash = password_hash(trim($newPassword), PASSWORD_BCRYPT, ['cost' => 12]);
$this->db->beginTransaction();
try {
$row = $this->passwordResetRepository->consumeActiveToken(hash('sha256', $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);
$this->db->commit();
} catch (\Throwable $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
}
}