Refatoring : Working state
This commit is contained in:
@@ -12,14 +12,14 @@ declare(strict_types=1);
|
|||||||
* sont typées sur des interfaces) est résolu automatiquement par l'autowiring.
|
* 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\AuthServiceInterface;
|
||||||
use App\Auth\LoginAttemptRepository;
|
use App\Auth\Infrastructure\PdoLoginAttemptRepository;
|
||||||
use App\Auth\LoginAttemptRepositoryInterface;
|
use App\Auth\LoginAttemptRepositoryInterface;
|
||||||
use App\Auth\PasswordResetController;
|
use App\Auth\Http\PasswordResetController;
|
||||||
use App\Auth\PasswordResetRepository;
|
use App\Auth\Infrastructure\PdoPasswordResetRepository;
|
||||||
use App\Auth\PasswordResetRepositoryInterface;
|
use App\Auth\PasswordResetRepositoryInterface;
|
||||||
use App\Auth\PasswordResetService;
|
use App\Auth\Application\PasswordResetApplicationService;
|
||||||
use App\Auth\PasswordResetServiceInterface;
|
use App\Auth\PasswordResetServiceInterface;
|
||||||
use App\Category\Application\CategoryApplicationService;
|
use App\Category\Application\CategoryApplicationService;
|
||||||
use App\Category\CategoryRepository;
|
use App\Category\CategoryRepository;
|
||||||
@@ -72,7 +72,7 @@ return [
|
|||||||
|
|
||||||
// ── Bindings interface → implémentation ──────────────────────────────────
|
// ── Bindings interface → implémentation ──────────────────────────────────
|
||||||
|
|
||||||
AuthServiceInterface::class => autowire(AuthService::class),
|
AuthServiceInterface::class => autowire(AuthApplicationService::class),
|
||||||
PostServiceInterface::class => autowire(PostApplicationService::class),
|
PostServiceInterface::class => autowire(PostApplicationService::class),
|
||||||
UserServiceInterface::class => autowire(UserApplicationService::class),
|
UserServiceInterface::class => autowire(UserApplicationService::class),
|
||||||
CategoryServiceInterface::class => autowire(CategoryApplicationService::class),
|
CategoryServiceInterface::class => autowire(CategoryApplicationService::class),
|
||||||
@@ -80,9 +80,9 @@ return [
|
|||||||
MediaRepositoryInterface::class => autowire(PdoMediaRepository::class),
|
MediaRepositoryInterface::class => autowire(PdoMediaRepository::class),
|
||||||
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
|
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
|
||||||
UserRepositoryInterface::class => autowire(PdoUserRepository::class),
|
UserRepositoryInterface::class => autowire(PdoUserRepository::class),
|
||||||
LoginAttemptRepositoryInterface::class => autowire(LoginAttemptRepository::class),
|
LoginAttemptRepositoryInterface::class => autowire(PdoLoginAttemptRepository::class),
|
||||||
PasswordResetRepositoryInterface::class => autowire(PasswordResetRepository::class),
|
PasswordResetRepositoryInterface::class => autowire(PdoPasswordResetRepository::class),
|
||||||
PasswordResetServiceInterface::class => autowire(PasswordResetService::class),
|
PasswordResetServiceInterface::class => autowire(PasswordResetApplicationService::class),
|
||||||
FlashServiceInterface::class => autowire(FlashService::class),
|
FlashServiceInterface::class => autowire(FlashService::class),
|
||||||
SessionManagerInterface::class => autowire(SessionManager::class),
|
SessionManagerInterface::class => autowire(SessionManager::class),
|
||||||
HtmlSanitizerInterface::class => autowire(HtmlSanitizer::class),
|
HtmlSanitizerInterface::class => autowire(HtmlSanitizer::class),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Architecture
|
# 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
|
> `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
|
> 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
|
> 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/` |
|
| `UserRepositoryInterface` | `PdoUserRepository` | `User/` |
|
||||||
| `UserServiceInterface` | `UserApplicationService` | `User/` |
|
| `UserServiceInterface` | `UserApplicationService` | `User/` |
|
||||||
| `LoginAttemptRepositoryInterface` | `LoginAttemptRepository` | `Auth/` |
|
| `LoginAttemptRepositoryInterface` | `PdoLoginAttemptRepository` | `Auth/` |
|
||||||
| `PasswordResetRepositoryInterface` | `PasswordResetRepository` | `Auth/` |
|
| `PasswordResetRepositoryInterface` | `PdoPasswordResetRepository` | `Auth/` |
|
||||||
| `PasswordResetServiceInterface` | `PasswordResetService` | `Auth/` |
|
| `PasswordResetServiceInterface` | `PasswordResetApplicationService` | `Auth/` |
|
||||||
| `AuthServiceInterface` | `AuthService` | `Auth/` |
|
| `AuthServiceInterface` | `AuthApplicationService` | `Auth/` |
|
||||||
| `PostRepositoryInterface` | `PostRepository` | `Post/` |
|
| `PostRepositoryInterface` | `PostRepository` | `Post/` |
|
||||||
| `PostServiceInterface` | `PostService` | `Post/` |
|
| `PostServiceInterface` | `PostService` | `Post/` |
|
||||||
| `CategoryRepositoryInterface` | `CategoryRepository` | `Category/`|
|
| `CategoryRepositoryInterface` | `CategoryRepository` | `Category/`|
|
||||||
|
|||||||
@@ -3,112 +3,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Auth;
|
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é.
|
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
|
||||||
*
|
* App\Auth\Http\AccountController.
|
||||||
* Accessible uniquement aux utilisateurs authentifiés (AuthMiddleware).
|
|
||||||
* Actuellement limité au changement de mot de passe, accessible via
|
|
||||||
* le lien « Mon compte » dans le header.
|
|
||||||
*/
|
*/
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
109
src/Auth/Application/AuthApplicationService.php
Normal file
109
src/Auth/Application/AuthApplicationService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/Auth/Application/PasswordResetApplicationService.php
Normal file
110
src/Auth/Application/PasswordResetApplicationService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,73 +3,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Auth;
|
namespace App\Auth;
|
||||||
|
|
||||||
use App\Shared\Http\ClientIpResolver;
|
/**
|
||||||
use App\Shared\Http\FlashServiceInterface;
|
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
* App\Auth\Http\AuthController.
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
*/
|
||||||
use Slim\Views\Twig;
|
final class AuthController extends Http\AuthController
|
||||||
|
|
||||||
final 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,191 +3,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Auth;
|
namespace App\Auth;
|
||||||
|
|
||||||
use App\Shared\Exception\NotFoundException;
|
use App\Auth\Application\AuthApplicationService;
|
||||||
use App\Shared\Http\SessionManagerInterface;
|
|
||||||
use App\User\Exception\WeakPasswordException;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepositoryInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service d'authentification.
|
* Pont de compatibilité : l'implémentation principale vit désormais dans
|
||||||
*
|
* App\Auth\Application\AuthApplicationService.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/Auth/Domain/LoginRateLimitPolicy.php
Normal file
17
src/Auth/Domain/LoginRateLimitPolicy.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Auth/Domain/PasswordResetTokenPolicy.php
Normal file
12
src/Auth/Domain/PasswordResetTokenPolicy.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth\Domain;
|
||||||
|
|
||||||
|
class PasswordResetTokenPolicy
|
||||||
|
{
|
||||||
|
public function ttlMinutes(): int
|
||||||
|
{
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/Auth/Http/AccountController.php
Normal file
115
src/Auth/Http/AccountController.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth\Http;
|
||||||
|
|
||||||
|
use App\Auth\AuthServiceInterface;
|
||||||
|
use App\Shared\Http\FlashServiceInterface;
|
||||||
|
use App\Shared\Http\SessionManagerInterface;
|
||||||
|
use App\User\Exception\WeakPasswordException;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôleur pour les actions liées au compte de l'utilisateur connecté.
|
||||||
|
*
|
||||||
|
* Accessible uniquement aux utilisateurs authentifiés (AuthMiddleware).
|
||||||
|
* Actuellement limité au changement de mot de passe, accessible via
|
||||||
|
* le lien « Mon compte » dans le header.
|
||||||
|
*/
|
||||||
|
class AccountController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Twig $view Moteur de templates Twig
|
||||||
|
* @param AuthServiceInterface $authService Service d'authentification
|
||||||
|
* @param FlashServiceInterface $flash Service de messages flash
|
||||||
|
* @param SessionManagerInterface $sessionManager Gestionnaire de session
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly Twig $view,
|
||||||
|
private readonly AuthServiceInterface $authService,
|
||||||
|
private readonly FlashServiceInterface $flash,
|
||||||
|
private readonly SessionManagerInterface $sessionManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche le formulaire de changement de mot de passe.
|
||||||
|
*
|
||||||
|
* Transmet les messages flash d'erreur et de succès issus
|
||||||
|
* d'une soumission précédente, ainsi que l'URL de retour pour
|
||||||
|
* le bouton Annuler (déduite du Referer, validée pour éviter
|
||||||
|
* les redirections ouvertes vers des domaines externes).
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response La vue pages/account/password-change.twig
|
||||||
|
*/
|
||||||
|
public function showChangePassword(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
// Récupère l'URL de la page précédente pour le bouton Annuler.
|
||||||
|
// On valide que le Referer est bien une URL relative du site (commence par /)
|
||||||
|
// pour éviter toute redirection vers un domaine externe (open redirect).
|
||||||
|
$referer = $req->getHeaderLine('Referer');
|
||||||
|
$path = parse_url($referer, PHP_URL_PATH);
|
||||||
|
$path = is_string($path) ? $path : '';
|
||||||
|
$backUrl = (str_starts_with($path, '/') && $path !== '/account/password')
|
||||||
|
? $path
|
||||||
|
: '/admin/posts';
|
||||||
|
|
||||||
|
return $this->view->render($res, 'pages/account/password-change.twig', [
|
||||||
|
'error' => $this->flash->get('password_error'),
|
||||||
|
'success' => $this->flash->get('password_success'),
|
||||||
|
'back_url' => $backUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite la soumission du formulaire de changement de mot de passe.
|
||||||
|
*
|
||||||
|
* Vérifie que les deux nouveaux mots de passe sont identiques,
|
||||||
|
* puis délègue la vérification du mot de passe actuel et la mise
|
||||||
|
* à jour à AuthService.
|
||||||
|
*
|
||||||
|
* Note : getUserId() ne peut pas retourner null ici car la route
|
||||||
|
* est protégée par AuthMiddleware. La valeur de repli 0 ne sera
|
||||||
|
* jamais atteinte en pratique.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response Une redirection vers /account/password dans tous les cas
|
||||||
|
*/
|
||||||
|
public function changePassword(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> $data */
|
||||||
|
$data = (array) $req->getParsedBody();
|
||||||
|
$current = trim((string) ($data['current_password'] ?? ''));
|
||||||
|
$new = trim((string) ($data['new_password'] ?? ''));
|
||||||
|
$confirm = trim((string) ($data['new_password_confirm'] ?? ''));
|
||||||
|
|
||||||
|
// getUserId() ne peut pas être null : route protégée par AuthMiddleware
|
||||||
|
$userId = $this->sessionManager->getUserId() ?? 0;
|
||||||
|
|
||||||
|
if ($new !== $confirm) {
|
||||||
|
$this->flash->set('password_error', 'Les mots de passe ne correspondent pas');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/account/password')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->authService->changePassword($userId, $current, $new);
|
||||||
|
$this->flash->set('password_success', 'Mot de passe modifié avec succès');
|
||||||
|
} catch (WeakPasswordException) {
|
||||||
|
$this->flash->set('password_error', 'Le nouveau mot de passe doit contenir au moins 8 caractères');
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
$this->flash->set('password_error', 'Le mot de passe actuel est incorrect');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->flash->set('password_error', 'Une erreur inattendue s\'est produite');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/account/password')->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/Auth/Http/AuthController.php
Normal file
76
src/Auth/Http/AuthController.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth\Http;
|
||||||
|
|
||||||
|
use App\Auth\AuthServiceInterface;
|
||||||
|
use App\Shared\Http\ClientIpResolver;
|
||||||
|
use App\Shared\Http\FlashServiceInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class AuthController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Twig $view,
|
||||||
|
private readonly AuthServiceInterface $authService,
|
||||||
|
private readonly FlashServiceInterface $flash,
|
||||||
|
private readonly ClientIpResolver $clientIpResolver,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showLogin(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
if ($this->authService->isLoggedIn()) {
|
||||||
|
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view->render($res, 'pages/auth/login.twig', [
|
||||||
|
'error' => $this->flash->get('login_error'),
|
||||||
|
'success' => $this->flash->get('login_success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$ip = $this->clientIpResolver->resolve($req);
|
||||||
|
$remainingMinutes = $this->authService->checkRateLimit($ip);
|
||||||
|
|
||||||
|
if ($remainingMinutes > 0) {
|
||||||
|
$this->flash->set(
|
||||||
|
'login_error',
|
||||||
|
"Trop de tentatives. Réessayez dans {$remainingMinutes} minute"
|
||||||
|
. ($remainingMinutes > 1 ? 's' : '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/auth/login')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $data */
|
||||||
|
$data = (array) $req->getParsedBody();
|
||||||
|
$username = trim((string) ($data['username'] ?? ''));
|
||||||
|
$password = trim((string) ($data['password'] ?? ''));
|
||||||
|
|
||||||
|
$user = $this->authService->authenticate($username, $password);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
$this->authService->recordFailure($ip);
|
||||||
|
$this->flash->set('login_error', 'Identifiants invalides');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/auth/login')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authService->resetRateLimit($ip);
|
||||||
|
$this->authService->login($user);
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$this->authService->logout();
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/')->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
src/Auth/Http/PasswordResetController.php
Normal file
217
src/Auth/Http/PasswordResetController.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth\Http;
|
||||||
|
|
||||||
|
use App\Auth\AuthServiceInterface;
|
||||||
|
use App\Auth\PasswordResetServiceInterface;
|
||||||
|
use App\Auth\Exception\InvalidResetTokenException;
|
||||||
|
use App\Shared\Http\ClientIpResolver;
|
||||||
|
use App\Shared\Http\FlashServiceInterface;
|
||||||
|
use App\User\Exception\WeakPasswordException;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôleur de réinitialisation de mot de passe.
|
||||||
|
*
|
||||||
|
* Gère le flux en deux étapes :
|
||||||
|
* 1. Demande de réinitialisation : saisie de l'email (GET/POST /password/forgot)
|
||||||
|
* 2. Réinitialisation effective : saisie du nouveau mot de passe (GET/POST /password/reset)
|
||||||
|
*
|
||||||
|
* Sécurité :
|
||||||
|
* - Le formulaire de demande affiche toujours un message de succès générique,
|
||||||
|
* même si l'email est inconnu, pour éviter l'énumération des comptes.
|
||||||
|
* - Le token est transmis uniquement via l'URL (GET) et un champ hidden (POST),
|
||||||
|
* jamais via la session.
|
||||||
|
* - Le endpoint POST /password/forgot est soumis au même rate limiting par IP
|
||||||
|
* que le login : 5 tentatives autorisées, verrouillage 15 min au-delà.
|
||||||
|
* Toute tentative est comptabilisée — il n'existe pas de "succès" identifiable
|
||||||
|
* sans révéler si l'adresse est enregistrée (anti-énumération).
|
||||||
|
*/
|
||||||
|
class PasswordResetController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Twig $view Moteur de templates Twig
|
||||||
|
* @param PasswordResetServiceInterface $passwordResetService Service de réinitialisation
|
||||||
|
* @param AuthServiceInterface $authService Service d'authentification (rate limiting)
|
||||||
|
* @param FlashServiceInterface $flash Service de messages flash
|
||||||
|
* @param ClientIpResolver $clientIpResolver Résout l'IP réelle derrière un proxy approuvé
|
||||||
|
* @param string $baseUrl URL de base de l'application (depuis APP_URL dans .env)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly Twig $view,
|
||||||
|
private readonly PasswordResetServiceInterface $passwordResetService,
|
||||||
|
private readonly AuthServiceInterface $authService,
|
||||||
|
private readonly FlashServiceInterface $flash,
|
||||||
|
private readonly ClientIpResolver $clientIpResolver,
|
||||||
|
private readonly string $baseUrl,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche le formulaire de demande de réinitialisation.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response La vue pages/auth/password-forgot.twig
|
||||||
|
*/
|
||||||
|
public function showForgot(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
return $this->view->render($res, 'pages/auth/password-forgot.twig', [
|
||||||
|
'error' => $this->flash->get('reset_error'),
|
||||||
|
'success' => $this->flash->get('reset_success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite la demande de réinitialisation.
|
||||||
|
*
|
||||||
|
* Vérifie d'abord le rate limit par IP. Toute tentative est enregistrée
|
||||||
|
* comme un échec — qu'un email existe ou non — afin de ne pas déséquilibrer
|
||||||
|
* le compteur en fonction du résultat, ce qui permettrait de déduire
|
||||||
|
* l'existence d'un compte (canal caché sur le rate-limit).
|
||||||
|
*
|
||||||
|
* Génère un token et envoie l'email si l'adresse existe.
|
||||||
|
* Affiche toujours un message de succès générique — ne révèle pas
|
||||||
|
* si l'adresse est enregistrée (protection contre l'énumération).
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response Redirection vers /password/forgot avec message flash
|
||||||
|
*/
|
||||||
|
public function forgot(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$ip = $this->clientIpResolver->resolve($req);
|
||||||
|
|
||||||
|
// Vérification du rate limit avant tout traitement
|
||||||
|
$remainingMinutes = $this->authService->checkRateLimit($ip);
|
||||||
|
|
||||||
|
if ($remainingMinutes > 0) {
|
||||||
|
$this->flash->set(
|
||||||
|
'reset_error',
|
||||||
|
"Trop de demandes. Veuillez réessayer dans {$remainingMinutes} minute"
|
||||||
|
. ($remainingMinutes > 1 ? 's' : '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $data */
|
||||||
|
$data = (array) $req->getParsedBody();
|
||||||
|
$email = trim((string) ($data['email'] ?? ''));
|
||||||
|
|
||||||
|
// La tentative est enregistrée systématiquement, résultat connu ou non.
|
||||||
|
// Réinitialiser le compteur uniquement en cas de succès révélerait si
|
||||||
|
// l'adresse existe (canal caché : compteur remis à zéro = email valide).
|
||||||
|
$this->authService->recordFailure($ip);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->passwordResetService->requestReset($email, $this->baseUrl);
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
// Erreur d'envoi d'email — on n'expose pas le détail à l'utilisateur
|
||||||
|
$this->flash->set('reset_error', 'Une erreur est survenue. Veuillez réessayer.');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message générique — ne révèle pas si l'email est connu
|
||||||
|
$this->flash->set(
|
||||||
|
'reset_success',
|
||||||
|
'Si cette adresse est associée à un compte, un email de réinitialisation a été envoyé'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche le formulaire de saisie du nouveau mot de passe.
|
||||||
|
*
|
||||||
|
* Valide le token en amont — redirige vers /password/forgot avec un message
|
||||||
|
* d'erreur si le token est absent, invalide ou expiré.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response La vue pages/auth/password-reset.twig ou une redirection
|
||||||
|
*/
|
||||||
|
public function showReset(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$token = trim((string) ($req->getQueryParams()['token'] ?? ''));
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
$this->flash->set('reset_error', 'Lien de réinitialisation manquant');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->passwordResetService->validateToken($token);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
$this->flash->set('reset_error', 'Ce lien est invalide ou a expiré. Veuillez faire une nouvelle demande.');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view->render($res, 'pages/auth/password-reset.twig', [
|
||||||
|
'token' => $token,
|
||||||
|
'error' => $this->flash->get('reset_error'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite la soumission du nouveau mot de passe.
|
||||||
|
*
|
||||||
|
* Vérifie que les deux mots de passe correspondent, puis délègue
|
||||||
|
* la validation du token et la mise à jour à PasswordResetServiceInterface.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response Redirection vers /auth/login en cas de succès,
|
||||||
|
* ou vers /password/reset?token=… en cas d'erreur
|
||||||
|
*/
|
||||||
|
public function reset(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> $data */
|
||||||
|
$data = (array) $req->getParsedBody();
|
||||||
|
$token = trim((string) ($data['token'] ?? ''));
|
||||||
|
$new = trim((string) ($data['new_password'] ?? ''));
|
||||||
|
$confirm = trim((string) ($data['new_password_confirm'] ?? ''));
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
$this->flash->set('reset_error', 'Lien de réinitialisation manquant');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($new !== $confirm) {
|
||||||
|
$this->flash->set('reset_error', 'Les mots de passe ne correspondent pas');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->passwordResetService->resetPassword($token, $new);
|
||||||
|
} catch (WeakPasswordException) {
|
||||||
|
$this->flash->set('reset_error', 'Le mot de passe doit contenir au moins 8 caractères');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
|
||||||
|
} catch (InvalidResetTokenException) {
|
||||||
|
$this->flash->set('reset_error', 'Ce lien de réinitialisation est invalide ou a expiré');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->flash->set('reset_error', 'Une erreur inattendue s\'est produite');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flash->set('login_success', 'Mot de passe réinitialisé avec succès. Vous pouvez vous connecter');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/auth/login')->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/Auth/Infrastructure/PdoLoginAttemptRepository.php
Normal file
67
src/Auth/Infrastructure/PdoLoginAttemptRepository.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/Auth/Infrastructure/PdoPasswordResetRepository.php
Normal file
73
src/Auth/Infrastructure/PdoPasswordResetRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,114 +3,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Auth;
|
namespace App\Auth;
|
||||||
|
|
||||||
use PDO;
|
use App\Auth\Infrastructure\PdoLoginAttemptRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dépôt de persistance des tentatives de connexion.
|
* Pont de compatibilité : l'implémentation PDO principale vit désormais dans
|
||||||
*
|
* App\Auth\Infrastructure\PdoLoginAttemptRepository.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,213 +3,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Auth;
|
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.
|
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
|
||||||
*
|
* App\Auth\Http\PasswordResetController.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,72 +3,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Auth;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,103 +3,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Auth;
|
namespace App\Auth;
|
||||||
|
|
||||||
use App\Auth\Exception\InvalidResetTokenException;
|
use App\Auth\Application\PasswordResetApplicationService;
|
||||||
use App\Shared\Mail\MailServiceInterface;
|
|
||||||
use App\User\Exception\WeakPasswordException;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepositoryInterface;
|
|
||||||
use PDO;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user