145 lines
7.1 KiB
PHP
145 lines
7.1 KiB
PHP
<?php
|
|
require __DIR__ . '/session.php';
|
|
require __DIR__ . '/db.php';
|
|
require __DIR__ . '/lib/ticket.php';
|
|
require __DIR__ . '/lib/mailer.php';
|
|
start_app_session();
|
|
|
|
if (isset($_SESSION['username'])) {
|
|
header('Location: /');
|
|
exit;
|
|
}
|
|
|
|
const PASSWORD_RESET_REQUEST_WINDOW_SECONDS = 3600;
|
|
const MAX_PASSWORD_RESET_REQUESTS_PER_IP = 10;
|
|
const MAX_PASSWORD_RESET_REQUESTS_PER_EMAIL = 3;
|
|
|
|
function passwordResetRequestCount(PDO $pdo, string $identifier, int $windowSeconds): int
|
|
{
|
|
$stmt = $pdo->prepare("SELECT COUNT(*) FROM password_reset_requests WHERE identifier = :identifier AND created_at > datetime('now', :window)");
|
|
$stmt->execute(['identifier' => $identifier, 'window' => '-' . $windowSeconds . ' seconds']);
|
|
return (int) $stmt->fetchColumn();
|
|
}
|
|
|
|
function recordPasswordResetRequest(PDO $pdo, string $identifier): void
|
|
{
|
|
$pdo->prepare('INSERT INTO password_reset_requests (identifier) VALUES (:identifier)')->execute(['identifier' => $identifier]);
|
|
}
|
|
|
|
$errors = [];
|
|
$email = '';
|
|
$submitted = false;
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$email = trim($_POST['email'] ?? '');
|
|
|
|
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
|
|
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
|
|
} elseif ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
$errors['email'] = 'Please enter a valid email address.';
|
|
}
|
|
|
|
if (empty($errors)) {
|
|
$pdo = getDbConnection();
|
|
$pdo->exec('CREATE TABLE IF NOT EXISTS password_reset_requests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
identifier TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)');
|
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_password_reset_requests_identifier ON password_reset_requests (identifier)');
|
|
|
|
$ipIdentifier = 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
|
$emailIdentifier = 'email:' . strtolower($email);
|
|
|
|
$rateLimited = passwordResetRequestCount($pdo, $ipIdentifier, PASSWORD_RESET_REQUEST_WINDOW_SECONDS) >= MAX_PASSWORD_RESET_REQUESTS_PER_IP
|
|
|| passwordResetRequestCount($pdo, $emailIdentifier, PASSWORD_RESET_REQUEST_WINDOW_SECONDS) >= MAX_PASSWORD_RESET_REQUESTS_PER_EMAIL;
|
|
|
|
if (!$rateLimited) {
|
|
recordPasswordResetRequest($pdo, $ipIdentifier);
|
|
recordPasswordResetRequest($pdo, $emailIdentifier);
|
|
|
|
$stmt = $pdo->prepare('SELECT id, username FROM users WHERE email = :email');
|
|
$stmt->execute(['email' => $email]);
|
|
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if ($user) {
|
|
$token = createPasswordResetTicket($pdo, (int) $user['id']);
|
|
$appUrl = rtrim(getenv('APP_URL') ?: 'https://ourapp.local', '/');
|
|
$resetLink = $appUrl . '/reset-password.php?token=' . urlencode($token);
|
|
sendPasswordResetEmail($email, $user['username'], $resetLink);
|
|
}
|
|
}
|
|
|
|
// Always show the same success state, whether or not the email matched an account
|
|
// or the request was throttled, so this page can't be used to probe which emails
|
|
// are registered or to detect the rate limit.
|
|
$submitted = true;
|
|
}
|
|
}
|
|
|
|
$pageTitle = 'Forgot Password';
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<?php require __DIR__ . '/lib/head.php'; ?>
|
|
<body class="bg-amber-50 min-h-screen flex flex-col">
|
|
<?php require __DIR__ . '/lib/header.php'; ?>
|
|
<div class="flex-1 flex items-center justify-center bg-gradient-to-b from-amber-50 via-pink-50 to-white p-4 py-12">
|
|
<div class="w-full max-w-md bg-white rounded-3xl shadow-xl ring-1 ring-gray-100 p-8 sm:p-10">
|
|
<?php if ($submitted): ?>
|
|
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-green-50 text-green-500 mx-auto mb-4">
|
|
<i data-lucide="mail-check" class="h-7 w-7"></i>
|
|
</div>
|
|
<h1 class="text-2xl font-extrabold text-gray-900 text-center">Check your inbox</h1>
|
|
<p class="mt-2 text-gray-500 text-sm text-center">
|
|
If an account exists for <span class="font-medium text-gray-700"><?php echo htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); ?></span>,
|
|
we've sent instructions to reset your password. The link expires in 20 minutes.
|
|
</p>
|
|
<p class="mt-6 text-center text-sm text-gray-600">
|
|
<a href="login.php" class="text-purple-600 font-semibold hover:underline">Back to log in</a>
|
|
</p>
|
|
<?php else: ?>
|
|
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-purple-50 text-purple-500 mx-auto mb-4">
|
|
<i data-lucide="key-round" class="h-7 w-7"></i>
|
|
</div>
|
|
<h1 class="text-2xl font-extrabold text-gray-900 text-center">Forgot your password?</h1>
|
|
<p class="mt-2 text-gray-500 text-sm text-center">Enter your email and we'll send you a link to reset it.</p>
|
|
|
|
<?php if (isset($errors['csrf'])): ?>
|
|
<div class="mt-4 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
|
|
<?php echo htmlspecialchars($errors['csrf'], ENT_QUOTES, 'UTF-8'); ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<form action="forgot-password.php" method="post" novalidate class="mt-6 space-y-4">
|
|
<?php echo csrf_field(); ?>
|
|
<div>
|
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
|
<div class="relative">
|
|
<i data-lucide="mail" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
|
|
<input
|
|
type="email" id="email" name="email"
|
|
value="<?php echo htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); ?>"
|
|
placeholder="you@example.com"
|
|
class="w-full rounded-xl border border-gray-300 pl-10 pr-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-purple-400"
|
|
>
|
|
</div>
|
|
<?php if (isset($errors['email'])): ?>
|
|
<p class="mt-1 text-sm text-red-600"><?php echo htmlspecialchars($errors['email'], ENT_QUOTES, 'UTF-8'); ?></p>
|
|
<?php endif; ?>
|
|
</div>
|
|
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gray-900 text-white font-semibold py-3 hover:bg-gray-800">
|
|
Send Reset Link <i data-lucide="arrow-right" class="h-4 w-4"></i>
|
|
</button>
|
|
</form>
|
|
|
|
<p class="mt-6 text-center text-sm text-gray-600">
|
|
Remembered it? <a href="login.php" class="text-purple-600 font-semibold hover:underline">Log in</a>
|
|
</p>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
<?php require __DIR__ . '/lib/footer.php'; ?>
|
|
</body>
|
|
</html>
|