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(); } }