diff --git a/config/container.php b/config/container.php index df0d70d..a6a310c 100644 --- a/config/container.php +++ b/config/container.php @@ -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), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 31e2514..4807ebb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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/`| diff --git a/src/Auth/AccountController.php b/src/Auth/AccountController.php index d764c2b..2499277 100644 --- a/src/Auth/AccountController.php +++ b/src/Auth/AccountController.php @@ -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 $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); - } } diff --git a/src/Auth/Application/AuthApplicationService.php b/src/Auth/Application/AuthApplicationService.php new file mode 100644 index 0000000..9954d02 --- /dev/null +++ b/src/Auth/Application/AuthApplicationService.php @@ -0,0 +1,109 @@ +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(); + } +} diff --git a/src/Auth/Application/PasswordResetApplicationService.php b/src/Auth/Application/PasswordResetApplicationService.php new file mode 100644 index 0000000..e5a51f4 --- /dev/null +++ b/src/Auth/Application/PasswordResetApplicationService.php @@ -0,0 +1,110 @@ +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; + } + } +} diff --git a/src/Auth/AuthController.php b/src/Auth/AuthController.php index 0b1ca41..a3abcbd 100644 --- a/src/Auth/AuthController.php +++ b/src/Auth/AuthController.php @@ -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 $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); - } } diff --git a/src/Auth/AuthService.php b/src/Auth/AuthService.php index 14372e5..1c28af2 100644 --- a/src/Auth/AuthService.php +++ b/src/Auth/AuthService.php @@ -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(); - } } diff --git a/src/Auth/Domain/LoginRateLimitPolicy.php b/src/Auth/Domain/LoginRateLimitPolicy.php new file mode 100644 index 0000000..bf1d534 --- /dev/null +++ b/src/Auth/Domain/LoginRateLimitPolicy.php @@ -0,0 +1,17 @@ +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 $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); + } +} diff --git a/src/Auth/Http/AuthController.php b/src/Auth/Http/AuthController.php new file mode 100644 index 0000000..ac5f2fc --- /dev/null +++ b/src/Auth/Http/AuthController.php @@ -0,0 +1,76 @@ +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 $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); + } +} diff --git a/src/Auth/Http/PasswordResetController.php b/src/Auth/Http/PasswordResetController.php new file mode 100644 index 0000000..35f0d55 --- /dev/null +++ b/src/Auth/Http/PasswordResetController.php @@ -0,0 +1,217 @@ +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 $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 $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); + } +} diff --git a/src/Auth/Infrastructure/PdoLoginAttemptRepository.php b/src/Auth/Infrastructure/PdoLoginAttemptRepository.php new file mode 100644 index 0000000..f3cbc68 --- /dev/null +++ b/src/Auth/Infrastructure/PdoLoginAttemptRepository.php @@ -0,0 +1,67 @@ +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]); + } +} diff --git a/src/Auth/Infrastructure/PdoPasswordResetRepository.php b/src/Auth/Infrastructure/PdoPasswordResetRepository.php new file mode 100644 index 0000000..adccbbb --- /dev/null +++ b/src/Auth/Infrastructure/PdoPasswordResetRepository.php @@ -0,0 +1,73 @@ +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|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|false $row */ + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ?: null; + } +} diff --git a/src/Auth/LoginAttemptRepository.php b/src/Auth/LoginAttemptRepository.php index 1def730..685ab72 100644 --- a/src/Auth/LoginAttemptRepository.php +++ b/src/Auth/LoginAttemptRepository.php @@ -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]); - } } diff --git a/src/Auth/PasswordResetController.php b/src/Auth/PasswordResetController.php index fc77c6c..7c3aac9 100644 --- a/src/Auth/PasswordResetController.php +++ b/src/Auth/PasswordResetController.php @@ -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 $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 $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); - } } diff --git a/src/Auth/PasswordResetRepository.php b/src/Auth/PasswordResetRepository.php index 1a535ef..f928965 100644 --- a/src/Auth/PasswordResetRepository.php +++ b/src/Auth/PasswordResetRepository.php @@ -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; - } } diff --git a/src/Auth/PasswordResetService.php b/src/Auth/PasswordResetService.php index 8409fc6..4e81bcd 100644 --- a/src/Auth/PasswordResetService.php +++ b/src/Auth/PasswordResetService.php @@ -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; - } - } }