140 lines
6.9 KiB
PHP
140 lines
6.9 KiB
PHP
<?php
|
|
require __DIR__ . '/session.php';
|
|
require __DIR__ . '/db.php';
|
|
require __DIR__ . '/lib/ticket.php';
|
|
start_app_session();
|
|
|
|
if (isset($_SESSION['username'])) {
|
|
header('Location: /');
|
|
exit;
|
|
}
|
|
|
|
$token = $_GET['token'] ?? ($_POST['token'] ?? '');
|
|
$tokenIsWellFormed = is_string($token) && $token !== '' && isPasswordResetTicketWellFormed($token);
|
|
|
|
$errors = [];
|
|
$success = false;
|
|
|
|
if ($tokenIsWellFormed && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$password = $_POST['password'] ?? '';
|
|
|
|
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
|
|
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
|
|
} elseif ($password === '') {
|
|
$errors['password'] = 'Password is required.';
|
|
} elseif (strlen($password) < 8) {
|
|
$errors['password'] = 'Password must be at least 8 characters long.';
|
|
} elseif (strlen($password) > 72) {
|
|
$errors['password'] = 'Password cannot exceed 72 characters.';
|
|
}
|
|
|
|
if (empty($errors)) {
|
|
$pdo = getDbConnection();
|
|
|
|
// Consuming the ticket and updating the password happen in one transaction so a
|
|
// failure between the two can't strand the user: either both take effect, or the
|
|
// ticket stays unconsumed and the (still valid) reset link can be used again.
|
|
$pdo->beginTransaction();
|
|
try {
|
|
$userId = consumePasswordResetTicket($pdo, $token);
|
|
|
|
if ($userId === null) {
|
|
$pdo->rollBack();
|
|
$errors['password'] = 'This reset link has already been used or has expired. Please request a new one.';
|
|
} else {
|
|
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
|
|
// Bumping session_version invalidates every existing session for this user
|
|
// (including a stolen one) the next time enforce_session_version() runs.
|
|
$stmt = $pdo->prepare('UPDATE users SET password = :password, session_version = session_version + 1 WHERE id = :id');
|
|
$stmt->execute(['password' => $hashedPassword, 'id' => $userId]);
|
|
$pdo->commit();
|
|
|
|
header('Location: login.php?reset=success');
|
|
exit;
|
|
}
|
|
} catch (Throwable $e) {
|
|
$pdo->rollBack();
|
|
throw $e;
|
|
}
|
|
}
|
|
}
|
|
|
|
$pageTitle = 'Reset 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 (!$tokenIsWellFormed): ?>
|
|
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-red-50 text-red-500 mx-auto mb-4">
|
|
<i data-lucide="shield-alert" class="h-7 w-7"></i>
|
|
</div>
|
|
<h1 class="text-2xl font-extrabold text-gray-900 text-center">Link expired</h1>
|
|
<p class="mt-2 text-gray-500 text-sm text-center">
|
|
This password reset link is invalid or has expired. Request a new one below.
|
|
</p>
|
|
<a href="forgot-password.php" class="mt-6 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">
|
|
Request New Link <i data-lucide="arrow-right" class="h-4 w-4"></i>
|
|
</a>
|
|
<?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">Choose a new password</h1>
|
|
<p class="mt-2 text-gray-500 text-sm text-center">Make it something you'll remember.</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="reset-password.php" method="post" novalidate class="mt-6 space-y-4">
|
|
<?php echo csrf_field(); ?>
|
|
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
|
|
<div>
|
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
|
|
<div class="relative">
|
|
<i data-lucide="lock" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
|
|
<input
|
|
type="password" id="password" name="password"
|
|
placeholder="At least 8 characters"
|
|
class="w-full rounded-xl border border-gray-300 pl-10 pr-10 py-2.5 focus:outline-none focus:ring-2 focus:ring-purple-400"
|
|
>
|
|
<button
|
|
type="button"
|
|
onclick="togglePasswordVisibility('password', this)"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
aria-label="Show password"
|
|
>
|
|
<i data-lucide="eye" class="h-4 w-4"></i>
|
|
<i data-lucide="eye-off" class="h-4 w-4 hidden"></i>
|
|
</button>
|
|
</div>
|
|
<?php if (isset($errors['password'])): ?>
|
|
<p class="mt-1 text-sm text-red-600"><?php echo htmlspecialchars($errors['password'], 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-gradient-to-r from-purple-600 to-pink-500 text-white font-semibold py-3 shadow-sm hover:from-purple-700 hover:to-pink-600">
|
|
Reset Password <i data-lucide="arrow-right" class="h-4 w-4"></i>
|
|
</button>
|
|
</form>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function togglePasswordVisibility(inputId, button) {
|
|
const input = document.getElementById(inputId);
|
|
const isPassword = input.type === 'password';
|
|
input.type = isPassword ? 'text' : 'password';
|
|
button.querySelectorAll('[data-lucide]').forEach((icon) => icon.classList.toggle('hidden'));
|
|
}
|
|
</script>
|
|
<?php require __DIR__ . '/lib/footer.php'; ?>
|
|
</body>
|
|
</html>
|