446 lines
13 KiB
PHP
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 fuer den Passwort-Reset ist ungueltig.');
|
|
}
|
|
|
|
$user = $this->findUserById($userId);
|
|
|
|
if (!$user) {
|
|
throw new RuntimeException('Der Benutzer fuer den Passwort-Reset 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 Reset-Link ist ungueltig 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 Reset-Link ist ungueltig oder abgelaufen.');
|
|
}
|
|
|
|
$expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
|
|
|
|
if (
|
|
$resetToken['consumed_at'] !== null
|
|
|| $expiresAtTs === null
|
|
|| $expiresAtTs < $nowTs
|
|
) {
|
|
throw new RuntimeException('Der Reset-Link ist ungueltig 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 !== '' ? 'Hallo ' . $name . ',' : 'Hallo,';
|
|
$body = implode("\n\n", [
|
|
$greeting,
|
|
'fuer dein Konto wurde ein Passwort-Reset angefragt.',
|
|
'Bitte nutze diesen Link, um ein neues Passwort zu setzen:',
|
|
(string) $token['reset_url'],
|
|
'Der Link ist gueltig bis ' . (string) $token['expires_at'] . ' UTC.',
|
|
'Falls du den Reset nicht angefragt hast, kannst du 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 zuruecksetzen';
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|