first commit
This commit is contained in:
114
src/Auth/AccountController.php
Normal file
114
src/Auth/AccountController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
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.
|
||||
*/
|
||||
final 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);
|
||||
}
|
||||
}
|
||||
75
src/Auth/AuthController.php
Normal file
75
src/Auth/AuthController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
193
src/Auth/AuthService.php
Normal file
193
src/Auth/AuthService.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
final class AuthService 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();
|
||||
}
|
||||
}
|
||||
84
src/Auth/AuthServiceInterface.php
Normal file
84
src/Auth/AuthServiceInterface.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Shared\Exception\NotFoundException;
|
||||
use App\User\Exception\WeakPasswordException;
|
||||
use App\User\User;
|
||||
|
||||
/**
|
||||
* Contrat du service d'authentification.
|
||||
*
|
||||
* Permet de mocker le service dans les tests unitaires sans dépendre
|
||||
* de la classe concrète finale AuthService.
|
||||
*/
|
||||
interface AuthServiceInterface
|
||||
{
|
||||
/**
|
||||
* Vérifie si l'adresse IP est actuellement verrouillée par le rate limiter.
|
||||
*
|
||||
* @param string $ip Adresse IP du client
|
||||
*
|
||||
* @return int 0 si libre, nombre de minutes restantes si verrouillé
|
||||
*/
|
||||
public function checkRateLimit(string $ip): int;
|
||||
|
||||
/**
|
||||
* Enregistre une tentative de connexion échouée pour une IP.
|
||||
*
|
||||
* @param string $ip Adresse IP du client
|
||||
*/
|
||||
public function recordFailure(string $ip): void;
|
||||
|
||||
/**
|
||||
* Réinitialise le compteur de tentatives pour une IP.
|
||||
*
|
||||
* @param string $ip Adresse IP du client
|
||||
* @return void
|
||||
*/
|
||||
public function resetRateLimit(string $ip): void;
|
||||
|
||||
/**
|
||||
* Tente d'authentifier un utilisateur par ses identifiants.
|
||||
*
|
||||
* @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;
|
||||
|
||||
/**
|
||||
* Change le mot de passe d'un utilisateur après vérification de l'actuel.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
public function changePassword(int $userId, string $currentPassword, string $newPassword): void;
|
||||
|
||||
/**
|
||||
* Vérifie si une session utilisateur est active.
|
||||
*
|
||||
* @return bool True si une session utilisateur est active
|
||||
*/
|
||||
public function isLoggedIn(): bool;
|
||||
|
||||
/**
|
||||
* Ouvre une session pour l'utilisateur donné.
|
||||
*
|
||||
* @param User $user L'utilisateur à connecter
|
||||
*/
|
||||
public function login(User $user): void;
|
||||
|
||||
/**
|
||||
* Détruit la session utilisateur courante.
|
||||
* @return void
|
||||
*/
|
||||
public function logout(): void;
|
||||
}
|
||||
16
src/Auth/Exception/InvalidResetTokenException.php
Normal file
16
src/Auth/Exception/InvalidResetTokenException.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth\Exception;
|
||||
|
||||
/**
|
||||
* Exception levée lorsqu'un lien de réinitialisation est invalide,
|
||||
* expiré, déjà consommé, ou lié à un utilisateur absent.
|
||||
*/
|
||||
final class InvalidResetTokenException extends \InvalidArgumentException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Ce lien de réinitialisation est invalide ou a expiré.');
|
||||
}
|
||||
}
|
||||
116
src/Auth/LoginAttemptRepository.php
Normal file
116
src/Auth/LoginAttemptRepository.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
final class LoginAttemptRepository 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]);
|
||||
}
|
||||
}
|
||||
44
src/Auth/LoginAttemptRepositoryInterface.php
Normal file
44
src/Auth/LoginAttemptRepositoryInterface.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
/**
|
||||
* Contrat de persistance des tentatives de connexion.
|
||||
*
|
||||
* Découple AuthService de l'implémentation concrète PDO/SQLite,
|
||||
* facilitant les mocks dans les tests unitaires.
|
||||
*/
|
||||
interface LoginAttemptRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Enregistre un échec de connexion pour une IP (INSERT ou 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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Supprime les entrées dont le verrouillage est expiré.
|
||||
*/
|
||||
public function deleteExpired(): void;
|
||||
}
|
||||
50
src/Auth/Middleware/AdminMiddleware.php
Normal file
50
src/Auth/Middleware/AdminMiddleware.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth\Middleware;
|
||||
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||
use Slim\Psr7\Response as SlimResponse;
|
||||
|
||||
/**
|
||||
* Middleware de protection des routes réservées aux administrateurs.
|
||||
*
|
||||
* Intercepte les requêtes et redirige vers /admin/posts si l'utilisateur
|
||||
* connecté n'a pas le rôle 'admin'.
|
||||
*
|
||||
* Ce middleware doit être utilisé en complément de AuthMiddleware,
|
||||
* qui vérifie en amont que l'utilisateur est connecté.
|
||||
* Ordre dans la chaîne Slim : ->add($adminMiddleware)->add($authMiddleware)
|
||||
*/
|
||||
final class AdminMiddleware implements MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* @param SessionManagerInterface $sessionManager Gestionnaire de session (lecture du rôle admin)
|
||||
*/
|
||||
public function __construct(private readonly SessionManagerInterface $sessionManager)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le rôle admin avant de transmettre la requête au gestionnaire suivant.
|
||||
*
|
||||
* @param Request $request La requête HTTP entrante
|
||||
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
|
||||
*
|
||||
* @return Response Une redirection 302 vers /admin/posts, ou la réponse normale
|
||||
*/
|
||||
public function process(Request $request, RequestHandler $handler): Response
|
||||
{
|
||||
if (!$this->sessionManager->isAdmin()) {
|
||||
return (new SlimResponse())
|
||||
->withHeader('Location', '/admin/posts')
|
||||
->withStatus(302);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
47
src/Auth/Middleware/AuthMiddleware.php
Normal file
47
src/Auth/Middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth\Middleware;
|
||||
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||
use Slim\Psr7\Response as SlimResponse;
|
||||
|
||||
/**
|
||||
* Middleware de protection des routes réservées aux utilisateurs connectés.
|
||||
*
|
||||
* Intercepte les requêtes entrantes et redirige vers /auth/login
|
||||
* si aucune session utilisateur n'est active.
|
||||
* Doit être appliqué avant AdminMiddleware dans la chaîne.
|
||||
*/
|
||||
final class AuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* @param SessionManagerInterface $sessionManager Gestionnaire de session (vérification de l'authentification)
|
||||
*/
|
||||
public function __construct(private readonly SessionManagerInterface $sessionManager)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie l'authentification avant de transmettre la requête au gestionnaire suivant.
|
||||
*
|
||||
* @param Request $request La requête HTTP entrante
|
||||
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
|
||||
*
|
||||
* @return Response Une redirection 302 vers /auth/login, ou la réponse normale
|
||||
*/
|
||||
public function process(Request $request, RequestHandler $handler): Response
|
||||
{
|
||||
if (!$this->sessionManager->isAuthenticated()) {
|
||||
return (new SlimResponse())
|
||||
->withHeader('Location', '/auth/login')
|
||||
->withStatus(302);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
50
src/Auth/Middleware/EditorMiddleware.php
Normal file
50
src/Auth/Middleware/EditorMiddleware.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth\Middleware;
|
||||
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||
use Slim\Psr7\Response as SlimResponse;
|
||||
|
||||
/**
|
||||
* Middleware de protection des routes réservées aux éditeurs et administrateurs.
|
||||
*
|
||||
* Intercepte les requêtes et redirige vers /admin/posts si l'utilisateur
|
||||
* connecté n'a ni le rôle 'editor' ni le rôle 'admin'.
|
||||
*
|
||||
* Ce middleware doit être utilisé en complément de AuthMiddleware,
|
||||
* qui vérifie en amont que l'utilisateur est connecté.
|
||||
* Ordre dans la chaîne Slim : ->add($editorMiddleware)->add($authMiddleware)
|
||||
*/
|
||||
final class EditorMiddleware implements MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* @param SessionManagerInterface $sessionManager Gestionnaire de session (lecture du rôle)
|
||||
*/
|
||||
public function __construct(private readonly SessionManagerInterface $sessionManager)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le rôle (editor ou admin) avant de transmettre la requête au gestionnaire suivant.
|
||||
*
|
||||
* @param Request $request La requête HTTP entrante
|
||||
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
|
||||
*
|
||||
* @return Response Une redirection 302 vers /admin/posts, ou la réponse normale
|
||||
*/
|
||||
public function process(Request $request, RequestHandler $handler): Response
|
||||
{
|
||||
if (!$this->sessionManager->isAdmin() && !$this->sessionManager->isEditor()) {
|
||||
return (new SlimResponse())
|
||||
->withHeader('Location', '/admin/posts')
|
||||
->withStatus(302);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
218
src/Auth/PasswordResetController.php
Normal file
218
src/Auth/PasswordResetController.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Auth\Exception\InvalidResetTokenException;
|
||||
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).
|
||||
*/
|
||||
final 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 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 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
|
||||
{
|
||||
// Résolution de l'IP réelle derrière un reverse proxy (Caddy/Nginx).
|
||||
// Même logique que AuthController::login() — voir son commentaire pour le détail.
|
||||
$serverParams = $req->getServerParams();
|
||||
$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? ''));
|
||||
$ip = $forwarded !== '' && $forwarded !== '0.0.0.0'
|
||||
? trim(explode(',', $forwarded)[0])
|
||||
: ($serverParams['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
74
src/Auth/PasswordResetRepository.php
Normal file
74
src/Auth/PasswordResetRepository.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class PasswordResetRepository 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;
|
||||
}
|
||||
}
|
||||
21
src/Auth/PasswordResetRepositoryInterface.php
Normal file
21
src/Auth/PasswordResetRepositoryInterface.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
interface PasswordResetRepositoryInterface
|
||||
{
|
||||
public function create(int $userId, string $tokenHash, string $expiresAt): void;
|
||||
|
||||
/**
|
||||
* Consomme atomiquement un token non utilisé et non expiré.
|
||||
*
|
||||
* L'implémentation doit effectuer l'opération en une seule étape SQL
|
||||
* afin d'éviter les courses entre lecture et écriture.
|
||||
*
|
||||
* @param string $tokenHash Hash SHA-256 du token de reset
|
||||
* @param string $usedAt Horodatage de consommation au format SQL
|
||||
* @return array<string, mixed>|null Les données du token consommé, ou null si le token est invalide, expiré ou déjà utilisé
|
||||
*/
|
||||
public function consumeActiveToken(string $tokenHash, string $usedAt): ?array;
|
||||
}
|
||||
105
src/Auth/PasswordResetService.php
Normal file
105
src/Auth/PasswordResetService.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
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;
|
||||
|
||||
final class PasswordResetService 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/Auth/PasswordResetServiceInterface.php
Normal file
56
src/Auth/PasswordResetServiceInterface.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Auth\Exception\InvalidResetTokenException;
|
||||
use App\User\User;
|
||||
|
||||
/**
|
||||
* Contrat du service de réinitialisation de mot de passe.
|
||||
*
|
||||
* Définit les trois opérations du flux de réinitialisation :
|
||||
* 1. Demande (génération du token + envoi d'e-mail)
|
||||
* 2. Validation du token reçu par e-mail
|
||||
* 3. Réinitialisation effective du mot de passe
|
||||
*/
|
||||
interface PasswordResetServiceInterface
|
||||
{
|
||||
/**
|
||||
* Génère un token de réinitialisation et envoie le lien par e-mail.
|
||||
*
|
||||
* Retour silencieux si l'e-mail est inconnu — ne révèle pas l'existence du compte.
|
||||
*
|
||||
* @param string $email Adresse e-mail de l'utilisateur
|
||||
* @param string $baseUrl URL de base de l'application (pour construire le lien)
|
||||
*
|
||||
* @throws \RuntimeException Si l'envoi de l'e-mail échoue
|
||||
*/
|
||||
public function requestReset(string $email, string $baseUrl): void;
|
||||
|
||||
/**
|
||||
* Valide un token brut reçu dans l'URL.
|
||||
*
|
||||
* Calcule le hash SHA-256 du token, vérifie son existence en base
|
||||
* et s'assure qu'il n'est pas expiré ni déjà consommé.
|
||||
*
|
||||
* @param string $tokenRaw Token brut reçu en clair dans l'URL
|
||||
*
|
||||
* @return User|null L'utilisateur associé au token, ou null si invalide/expiré
|
||||
*/
|
||||
public function validateToken(string $tokenRaw): ?User;
|
||||
|
||||
/**
|
||||
* Réinitialise le mot de passe d'un utilisateur.
|
||||
*
|
||||
* Valide le token, hache le nouveau mot de passe, met à jour la base
|
||||
* et marque le token comme consommé.
|
||||
*
|
||||
* @param string $tokenRaw Token brut reçu dans l'URL
|
||||
* @param string $newPassword Nouveau mot de passe en clair
|
||||
*
|
||||
* @throws InvalidResetTokenException Si le token est invalide ou expiré
|
||||
* @throws \App\User\Exception\WeakPasswordException Si le mot de passe est trop court
|
||||
*/
|
||||
public function resetPassword(string $tokenRaw, string $newPassword): void;
|
||||
}
|
||||
Reference in New Issue
Block a user