corentin_wakdo/src/app/Auth/PasswordResetService.php
Imugiii 5835be0e66 feat(auth): authentification back-office (login, logout, reinitialisation mot de passe)
Implemente mlt.md section 12 : AUTHENTICATE_USER (12.1), LOGOUT_USER (12.2),
RESET_PASSWORD (12.3). Sessions PHP + argon2id, regeneration d'ID a la connexion,
idle 4h / absolu 10h via SessionGuard (cable en P3), jeton CSRF synchroniseur, backoff
degressif anti brute-force par compte et par IP source (login_throttle), audit_log
append-only (login_success/failed, password_reset), defenses anti-enumeration d'email
(timing + profil d'ecritures identique), fail-closed sur erreur base. Vues login/forgot/reset
rendues serveur. Routes posees sur le vhost admin (pas de prefixe /admin : docroot =
public/admin). PHPUnit sans Composer (unit + integration DB auto-skippee sans base) et
PHPStan L6 restent verts.
2026-06-15 18:15:32 +00:00

127 lines
4.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Auth;
use App\Core\Config;
use App\Core\DatabaseInterface;
/**
* Reinitialisation de mot de passe (mlt.md 12.3), en deux phases : demande puis
* confirmation. Sans fuite d'enumeration (reponse neutre), token CSPRNG hashe au
* repos, usage unique, confirmation transactionnelle.
*/
final class PasswordResetService
{
public function __construct(
private readonly DatabaseInterface $db,
private readonly Config $config,
private readonly PasswordHasher $hasher,
private readonly Mailer $mailer,
) {
}
/**
* Phase demande (RG-1/RG-2). Retour void : la reponse cote controleur est
* neutre que l'email existe ou non (anti-enumeration). Si l'email resout un
* utilisateur actif : token CSPRNG 32 octets, on stocke son hash SHA-256 et
* une expiration NOW()+TTL, et on envoie le token BRUT une seule fois.
*/
public function requestReset(string $email, string $baseUrl, ?int $now = null): void
{
$now ??= time();
$user = $this->db->fetch(
'SELECT id FROM user WHERE email = :email AND is_active = 1 LIMIT 1',
['email' => $email],
);
if ($user === null) {
return;
}
$userId = (int) ($user['id'] ?? 0);
// Token a haute entropie (256 bits). Stocke en SHA-256 : un hash rapide
// suffit (la robustesse vient de l'entropie, pas d'un KDF lent), et le
// brut n'est jamais persiste. Voir comment de confirmReset().
$rawToken = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $rawToken);
$ttl = $this->config->int('PASSWORD_RESET_TTL', 3600);
$expiresAt = date('Y-m-d H:i:s', $now + $ttl);
$this->db->execute(
'UPDATE user SET password_reset_token_hash = :hash, password_reset_expires_at = :exp WHERE id = :id',
['hash' => $tokenHash, 'exp' => $expiresAt, 'id' => $userId],
);
$resetUrl = rtrim($baseUrl, '/') . '/reset_password?token=' . $rawToken;
$this->mailer->sendPasswordReset($email, $resetUrl);
}
/**
* Phase confirmation (RG-3/RG-4). Hash du token soumis, recherche par hash +
* expiration future (la recherche par egalite sur un token 256 bits EST la
* comparaison ; pas de souci de temps constant car ce n'est pas un secret a
* faible entropie et la colonne n'est jamais renvoyee au client). Min 8
* caracteres, nouveau hash argon2id, token efface (usage unique), compteurs
* remis a zero, audit_log : le tout dans une transaction.
*/
public function confirmReset(string $rawToken, string $newPassword, ?int $now = null): AuthResult
{
$now ??= time();
if (strlen($newPassword) < 8) {
return AuthResult::failure('Le mot de passe doit contenir au moins 8 caracteres.');
}
if ($rawToken === '') {
return AuthResult::failure('Lien invalide ou expire.');
}
$tokenHash = hash('sha256', $rawToken);
$nowDt = date('Y-m-d H:i:s', $now);
$user = $this->db->fetch(
'SELECT id, role_id, password_reset_token_hash FROM user '
. 'WHERE password_reset_token_hash = :hash AND password_reset_expires_at > :now '
. 'AND is_active = 1 LIMIT 1',
['hash' => $tokenHash, 'now' => $nowDt],
);
if ($user === null) {
return AuthResult::failure('Lien invalide ou expire.');
}
$userId = (int) ($user['id'] ?? 0);
$roleId = (int) ($user['role_id'] ?? 0);
$newHash = $this->hasher->hash($newPassword);
$this->db->transaction(function (DatabaseInterface $db) use ($userId, $roleId, $newHash): void {
// Usage unique : on efface token + expiration et on remet les
// compteurs anti brute-force a zero (le compte redevient utilisable).
$db->execute(
'UPDATE user SET password_hash = :hash, password_reset_token_hash = NULL, '
. 'password_reset_expires_at = NULL, failed_login_attempts = 0, lockout_until = NULL '
. 'WHERE id = :id',
['hash' => $newHash, 'id' => $userId],
);
$db->execute(
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
[
'uid' => $userId,
'rid' => $roleId,
'code' => 'auth.password_reset',
'etype' => 'user',
'eid' => $userId,
'summary' => 'Reinitialisation du mot de passe',
],
);
});
return AuthResult::success($userId, $roleId, '/login?reset=ok');
}
}