Files
kaffeekasse-saas/app/Services/PasswordResetService.php
T
2026-06-23 00:06:31 +02:00

446 lines
13 KiB
PHP

<?php
namespace App\Services;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
use RuntimeException;
final class PasswordResetService
{
private readonly DateTimeZone $utc;
public function __construct(
private readonly PDO $pdo,
private readonly MailerService $mailer,
private readonly array $config = []
) {
$this->utc = new DateTimeZone('UTC');
}
public function requestReset(string $email, array $context = []): void
{
$email = $this->normalizeEmail($email);
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return;
}
$user = $this->findUserByEmail($email);
if (!$user) {
return;
}
$token = $this->createTokenForUser((int) $user['id'], $context);
$this->sendResetMail($token);
}
public function createTokenForUser(int $userId, array $context = []): array
{
if ($userId <= 0) {
throw new RuntimeException('Der Benutzer für den Passwortwechsel ist ungültig.');
}
$user = $this->findUserById($userId);
if (!$user) {
throw new RuntimeException('Der Benutzer für den Passwortwechsel wurde nicht gefunden.');
}
$nowTs = time();
$now = $this->formatTimestamp($nowTs);
$expiresAt = $this->formatTimestamp($nowTs + ($this->ttlMinutes() * 60));
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
$userAgent = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
$this->pdo->beginTransaction();
try {
$cleanupStatement = $this->pdo->prepare(
'DELETE FROM password_reset_tokens
WHERE user_id = :user_id
AND (consumed_at IS NOT NULL OR expires_at < :now)'
);
$cleanupStatement->execute([
'user_id' => $userId,
'now' => $now,
]);
$insertStatement = $this->pdo->prepare(
'INSERT INTO password_reset_tokens (
user_id,
email,
token_hash,
requested_ip_hash,
requested_user_agent_hash,
expires_at,
consumed_at,
created_at
) VALUES (
:user_id,
:email,
:token_hash,
:requested_ip_hash,
:requested_user_agent_hash,
:expires_at,
NULL,
:created_at
)'
);
$insertStatement->execute([
'user_id' => $userId,
'email' => $user['email'],
'token_hash' => $tokenHash,
'requested_ip_hash' => $ip !== '' ? hash('sha256', $ip) : null,
'requested_user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
'expires_at' => $expiresAt,
'created_at' => $now,
]);
$trimStatement = $this->pdo->prepare(
'SELECT id
FROM password_reset_tokens
WHERE user_id = :user_id
AND consumed_at IS NULL
AND expires_at >= :now
ORDER BY created_at DESC, id DESC'
);
$trimStatement->execute([
'user_id' => $userId,
'now' => $now,
]);
$tokenIds = array_map(
static fn (array $row): int => (int) $row['id'],
$trimStatement->fetchAll()
);
$idsToDelete = array_slice($tokenIds, $this->maxActiveTokens());
if ($idsToDelete !== []) {
$deleteStatement = $this->pdo->prepare(
'DELETE FROM password_reset_tokens
WHERE user_id = :user_id
AND id = :id'
);
foreach ($idsToDelete as $tokenId) {
$deleteStatement->execute([
'user_id' => $userId,
'id' => $tokenId,
]);
}
}
$this->pdo->commit();
} catch (\Throwable $throwable) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $throwable;
}
return [
'user_id' => $userId,
'email' => $user['email'],
'full_name' => $user['full_name'],
'token' => $token,
'expires_at' => $expiresAt,
'reset_url' => $this->buildResetUrl($token, $context),
];
}
public function findValidToken(string $token): ?array
{
$token = trim($token);
if ($token === '') {
return null;
}
$statement = $this->pdo->prepare(
'SELECT prt.id,
prt.user_id,
prt.email,
prt.expires_at,
prt.consumed_at,
prt.created_at,
u.full_name
FROM password_reset_tokens prt
INNER JOIN users u ON u.id = prt.user_id
WHERE prt.token_hash = :token_hash
LIMIT 1'
);
$statement->execute([
'token_hash' => hash('sha256', $token),
]);
$resetToken = $statement->fetch();
if (!$resetToken) {
return null;
}
$expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
if (
$resetToken['consumed_at'] !== null
|| $expiresAtTs === null
|| $expiresAtTs < time()
) {
return null;
}
return $resetToken;
}
public function resetPassword(string $token, string $newPassword): int
{
$token = trim($token);
$newPassword = (string) $newPassword;
if ($token === '') {
throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
}
if (mb_strlen($newPassword) < 12) {
throw new RuntimeException('Das neue Passwort muss mindestens 12 Zeichen lang sein.');
}
$tokenHash = hash('sha256', $token);
$nowTs = time();
$now = $this->formatTimestamp($nowTs);
$this->pdo->beginTransaction();
try {
$statement = $this->pdo->prepare(
'SELECT id, user_id, expires_at, consumed_at
FROM password_reset_tokens
WHERE token_hash = :token_hash
LIMIT 1
FOR UPDATE'
);
$statement->execute(['token_hash' => $tokenHash]);
$resetToken = $statement->fetch();
if (!$resetToken) {
throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
}
$expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
if (
$resetToken['consumed_at'] !== null
|| $expiresAtTs === null
|| $expiresAtTs < $nowTs
) {
throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
}
$updateUserStatement = $this->pdo->prepare(
'UPDATE users
SET password_hash = :password_hash
WHERE id = :user_id'
);
$updateUserStatement->execute([
'password_hash' => secure_password_hash($newPassword),
'user_id' => $resetToken['user_id'],
]);
$consumeTokenStatement = $this->pdo->prepare(
'UPDATE password_reset_tokens
SET consumed_at = :consumed_at
WHERE id = :id'
);
$consumeTokenStatement->execute([
'consumed_at' => $now,
'id' => $resetToken['id'],
]);
$revokeOtherTokensStatement = $this->pdo->prepare(
'DELETE FROM password_reset_tokens
WHERE user_id = :user_id
AND id <> :id'
);
$revokeOtherTokensStatement->execute([
'user_id' => $resetToken['user_id'],
'id' => $resetToken['id'],
]);
$this->pdo->commit();
} catch (\Throwable $throwable) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $throwable;
}
return (int) $resetToken['user_id'];
}
public function revokeTokensForUser(int $userId): int
{
if ($userId <= 0) {
return 0;
}
$statement = $this->pdo->prepare(
'DELETE FROM password_reset_tokens
WHERE user_id = :user_id'
);
$statement->execute(['user_id' => $userId]);
return $statement->rowCount();
}
public function purgeExpiredTokens(): int
{
$statement = $this->pdo->prepare(
'DELETE FROM password_reset_tokens
WHERE consumed_at IS NOT NULL
OR expires_at < :now'
);
$statement->execute(['now' => $this->formatTimestamp(time())]);
return $statement->rowCount();
}
private function sendResetMail(array $token): void
{
$name = trim((string) ($token['full_name'] ?? ''));
$greeting = $name !== '' ? 'Guten Tag ' . $name . ',' : 'Guten Tag,';
$body = implode("\n\n", [
$greeting,
'für Ihr Konto wurde ein Passwortwechsel angefragt.',
'Bitte nutzen Sie diesen Link, um ein neues Passwort zu setzen:',
(string) $token['reset_url'],
'Der Link ist gültig bis ' . (string) $token['expires_at'] . '.',
'Falls Sie den Passwortwechsel nicht angefragt haben, können Sie diese Nachricht ignorieren.',
]);
$this->mailer->send(
(string) $token['email'],
$this->mailSubject(),
$body
);
}
private function findUserByEmail(string $email): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, full_name, email
FROM users
WHERE email = :email
LIMIT 1'
);
$statement->execute(['email' => $email]);
$user = $statement->fetch();
return $user ?: null;
}
private function findUserById(int $userId): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, full_name, email
FROM users
WHERE id = :id
LIMIT 1'
);
$statement->execute(['id' => $userId]);
$user = $statement->fetch();
return $user ?: null;
}
private function normalizeEmail(string $email): string
{
return mb_strtolower(trim($email));
}
private function buildResetUrl(string $token, array $context = []): string
{
$template = trim((string) ($this->config['reset_url'] ?? ''));
if ($template === '') {
$template = 'http://localhost:8080/reset-password?token={{token}}';
}
$query = [];
foreach ($context as $key => $value) {
if (!is_string($key) || trim($key) === '' || !is_scalar($value)) {
continue;
}
$normalizedValue = trim((string) $value);
if ($normalizedValue === '') {
continue;
}
$query[$key] = $normalizedValue;
}
if (str_contains($template, '{{token}}')) {
$url = str_replace('{{token}}', rawurlencode($token), $template);
if ($query === []) {
return $url;
}
$separator = str_contains($url, '?') ? '&' : '?';
return $url . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
$query['token'] = $token;
$separator = str_contains($template, '?') ? '&' : '?';
return $template . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
private function mailSubject(): string
{
$subject = trim((string) ($this->config['subject'] ?? ''));
return $subject !== '' ? $subject : 'Passwort zurücksetzen';
}
private function ttlMinutes(): int
{
return max(5, (int) ($this->config['ttl_minutes'] ?? 60));
}
private function maxActiveTokens(): int
{
return max(1, (int) ($this->config['max_active_tokens'] ?? 3));
}
private function formatTimestamp(int $timestamp): string
{
return gmdate('Y-m-d H:i:s', $timestamp);
}
private function timestampFromDatabase(?string $value): ?int
{
if ($value === null || trim($value) === '') {
return null;
}
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc);
if (!$date instanceof DateTimeImmutable) {
return null;
}
return $date->getTimestamp();
}
}