Phase1 Bearbeitung
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user