issues identified by Davidson CI
This commit is contained in:
@@ -48,6 +48,12 @@ if (!$critter) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method === 'POST' && !csrf_verify($_POST['csrf_token'] ?? null)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
|
||||
+59
-6
@@ -1,6 +1,8 @@
|
||||
<?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']) || !isset($_SESSION['user_id'])) {
|
||||
@@ -12,12 +14,38 @@ $isLoggedIn = isset($_SESSION['username']) && isset($_SESSION['user_id']);
|
||||
|
||||
const DAILY_CRITTER_LIMIT = 30;
|
||||
const CRITTER_COOLDOWN_SECONDS = 15;
|
||||
const VERIFICATION_RESEND_COOLDOWN_SECONDS = 60;
|
||||
|
||||
$pdo = getDbConnection();
|
||||
|
||||
$creditsStmt = $pdo->prepare('SELECT credits FROM users WHERE id = :id');
|
||||
$creditsStmt->execute(['id' => $_SESSION['user_id']]);
|
||||
$userCredits = (int) $creditsStmt->fetchColumn();
|
||||
$userStmt = $pdo->prepare('SELECT email, username, credits, email_verified_at FROM users WHERE id = :id');
|
||||
$userStmt->execute(['id' => $_SESSION['user_id']]);
|
||||
$userRow = $userStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$userCredits = (int) ($userRow['credits'] ?? 0);
|
||||
$isEmailVerified = !empty($userRow['email_verified_at']);
|
||||
|
||||
$verificationEmailSent = false;
|
||||
$verificationResendError = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'resend_verification') {
|
||||
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
|
||||
$verificationResendError = 'Your session has expired. Please refresh the page and try again.';
|
||||
} elseif ($isEmailVerified) {
|
||||
$verificationResendError = 'Your email is already verified.';
|
||||
} elseif (
|
||||
isset($_SESSION['last_verification_resend_at'])
|
||||
&& (time() - $_SESSION['last_verification_resend_at']) < VERIFICATION_RESEND_COOLDOWN_SECONDS
|
||||
) {
|
||||
$verificationResendError = 'Please wait a minute before requesting another verification email.';
|
||||
} else {
|
||||
$verifyToken = createTicket((int) $_SESSION['user_id'], 'email_verify', EMAIL_VERIFICATION_LIFETIME_SECONDS);
|
||||
$appUrl = rtrim(getenv('APP_URL') ?: 'https://ourapp.local', '/');
|
||||
$verifyLink = $appUrl . '/verify-email.php?token=' . urlencode($verifyToken);
|
||||
sendEmailVerificationEmail($userRow['email'], $userRow['username'], $verifyLink);
|
||||
$_SESSION['last_verification_resend_at'] = time();
|
||||
$verificationEmailSent = true;
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS critters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -150,6 +178,10 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $userCredits > 0 && !$dailyLimitReached) {
|
||||
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
|
||||
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
|
||||
}
|
||||
|
||||
foreach ($fields as $field => $info) {
|
||||
$value = $_POST[$field] ?? '';
|
||||
$selected[$field] = $value;
|
||||
@@ -323,7 +355,27 @@ $pageTitle = 'Create Critter';
|
||||
<body class="bg-amber-50 min-h-screen flex flex-col">
|
||||
<?php require __DIR__ . '/lib/header.php'; ?>
|
||||
<main class="flex-1">
|
||||
<?php if ($dailyLimitReached): ?>
|
||||
<?php if (!$isEmailVerified): ?>
|
||||
<div class="max-w-md mx-auto px-6 py-20 text-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-purple-50 text-purple-300 mx-auto mb-4">
|
||||
<i data-lucide="mail-check" class="h-8 w-8"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-extrabold text-gray-900 mb-2">Verify your email to start creating</h1>
|
||||
<p class="text-gray-600">We sent a verification link to your email when you signed up. Verify it to unlock your <?php echo STARTING_CREDITS; ?> free credits.</p>
|
||||
<?php if ($verificationEmailSent): ?>
|
||||
<p class="mt-4 text-sm text-green-600">Verification email sent! Check your inbox.</p>
|
||||
<?php elseif ($verificationResendError): ?>
|
||||
<p class="mt-4 text-sm text-red-600"><?php echo htmlspecialchars($verificationResendError, ENT_QUOTES, 'UTF-8'); ?></p>
|
||||
<?php endif; ?>
|
||||
<form method="post" class="mt-6">
|
||||
<?php echo csrf_field(); ?>
|
||||
<input type="hidden" name="action" value="resend_verification">
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-pink-500 text-white font-semibold px-5 py-2.5 shadow-sm hover:from-purple-700 hover:to-pink-600">
|
||||
<i data-lucide="send" class="h-4 w-4"></i> Resend verification email
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php elseif ($dailyLimitReached): ?>
|
||||
<div class="max-w-md mx-auto px-6 py-20 text-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-purple-50 text-purple-300 mx-auto mb-4">
|
||||
<i data-lucide="calendar-clock" class="h-8 w-8"></i>
|
||||
@@ -351,12 +403,13 @@ $pageTitle = 'Create Critter';
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-white ring-1 ring-gray-100 shadow-sm p-6 sm:p-8">
|
||||
<?php if (isset($errors['credits']) || isset($errors['cooldown'])): ?>
|
||||
<?php if (isset($errors['credits']) || isset($errors['cooldown']) || isset($errors['csrf'])): ?>
|
||||
<div class="mb-5 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
|
||||
<?php echo htmlspecialchars($errors['credits'] ?? $errors['cooldown'], ENT_QUOTES, 'UTF-8'); ?>
|
||||
<?php echo htmlspecialchars($errors['credits'] ?? $errors['cooldown'] ?? $errors['csrf'], ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form id="createCritterForm" action="create-critter.php" method="post" novalidate class="space-y-5">
|
||||
<?php echo csrf_field(); ?>
|
||||
<div>
|
||||
<label for="adjective_id" class="block text-sm font-medium text-gray-700 mb-1">Adjective</label>
|
||||
<div class="relative">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
const STARTING_CREDITS = 3;
|
||||
|
||||
function getDbConnection(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
@@ -21,6 +23,9 @@ function getDbConnection(): PDO
|
||||
if (!in_array('credits', $userColumns, true)) {
|
||||
$pdo->exec('ALTER TABLE users ADD COLUMN credits INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
if (!in_array('email_verified_at', $userColumns, true)) {
|
||||
$pdo->exec('ALTER TABLE users ADD COLUMN email_verified_at TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
|
||||
+55
-11
@@ -10,6 +10,22 @@ if (isset($_SESSION['username'])) {
|
||||
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;
|
||||
@@ -17,25 +33,46 @@ $submitted = false;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_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();
|
||||
$stmt = $pdo->prepare('SELECT id, username FROM users WHERE email = :email');
|
||||
$stmt->execute(['email' => $email]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$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)');
|
||||
|
||||
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);
|
||||
$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,
|
||||
// so this page can't be used to check which emails are registered.
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +105,14 @@ $pageTitle = 'Forgot Password';
|
||||
<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">
|
||||
|
||||
@@ -66,7 +66,11 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
$postedCritterId = $_POST['critter_id'] ?? '';
|
||||
|
||||
if (in_array($action, ['toggle_favorite', 'toggle_archived'], true) && ctype_digit((string) $postedCritterId)) {
|
||||
if (
|
||||
csrf_verify($_POST['csrf_token'] ?? null)
|
||||
&& in_array($action, ['toggle_favorite', 'toggle_archived'], true)
|
||||
&& ctype_digit((string) $postedCritterId)
|
||||
) {
|
||||
$stmt = $pdo->prepare('SELECT author_id, isFavorite, isArchived FROM critters WHERE id = :id');
|
||||
$stmt->execute(['id' => $postedCritterId]);
|
||||
$target = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
@@ -352,6 +356,7 @@ $pageTitle = 'Welcome';
|
||||
>
|
||||
</button>
|
||||
<form action="" method="post" class="absolute top-3 right-3">
|
||||
<?php echo csrf_field(); ?>
|
||||
<input type="hidden" name="action" value="toggle_favorite">
|
||||
<input type="hidden" name="critter_id" value="<?php echo (int) $critter['id']; ?>">
|
||||
<button type="submit" aria-label="<?php echo $critter['isFavorite'] ? 'Unfavorite' : 'Favorite'; ?>" class="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 shadow-sm transition-transform duration-150 hover:bg-white hover:scale-110">
|
||||
@@ -373,6 +378,7 @@ $pageTitle = 'Welcome';
|
||||
<i data-lucide="eye" class="h-4 w-4"></i>
|
||||
</button>
|
||||
<form action="" method="post">
|
||||
<?php echo csrf_field(); ?>
|
||||
<input type="hidden" name="action" value="toggle_favorite">
|
||||
<input type="hidden" name="critter_id" value="<?php echo (int) $critter['id']; ?>">
|
||||
<button type="submit" aria-label="<?php echo $critter['isFavorite'] ? 'Unfavorite' : 'Favorite'; ?>" class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-pink-500">
|
||||
@@ -380,6 +386,7 @@ $pageTitle = 'Welcome';
|
||||
</button>
|
||||
</form>
|
||||
<form action="" method="post">
|
||||
<?php echo csrf_field(); ?>
|
||||
<input type="hidden" name="action" value="toggle_archived">
|
||||
<input type="hidden" name="critter_id" value="<?php echo (int) $critter['id']; ?>">
|
||||
<button type="submit" aria-label="<?php echo $critter['isArchived'] ? 'Unarchive' : 'Archive'; ?>" class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-green-600">
|
||||
|
||||
@@ -207,12 +207,17 @@
|
||||
if (event.key === 'Escape') closeCritterModal();
|
||||
});
|
||||
|
||||
function getCsrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.content : '';
|
||||
}
|
||||
|
||||
function toggleModalFavorite() {
|
||||
if (!currentCritterModalId) return;
|
||||
fetch('api/critter-details.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ id: currentCritterModalId, action: 'toggle_favorite' }),
|
||||
body: new URLSearchParams({ id: currentCritterModalId, action: 'toggle_favorite', csrf_token: getCsrfToken() }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="<?php echo htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<title><?php echo htmlspecialchars($pageTitle ?? 'OurApp', ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
+9
-7
@@ -14,6 +14,7 @@ function configureAppMailer(): PHPMailer
|
||||
$mail->Host = getenv('SMTP_HOST');
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Port = (int) getenv('SMTP_PORT');
|
||||
$mail->SMTPSecure = $mail->Port === 465 ? PHPMailer::ENCRYPTION_SMTPS : PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->Username = getenv('SMTP_USERNAME');
|
||||
$mail->Password = getenv('SMTP_PASSWORD');
|
||||
$mail->SMTPOptions = [
|
||||
@@ -30,24 +31,25 @@ function configureAppMailer(): PHPMailer
|
||||
return $mail;
|
||||
}
|
||||
|
||||
function sendRegistrationConfirmationEmail(string $toEmail, string $username): bool
|
||||
function sendEmailVerificationEmail(string $toEmail, string $username, string $verifyLink): bool
|
||||
{
|
||||
try {
|
||||
$mail = configureAppMailer();
|
||||
$mail->addAddress($toEmail, $username);
|
||||
$mail->Subject = 'Welcome to OurApp!';
|
||||
$mail->Subject = 'Verify your email to start creating critters';
|
||||
|
||||
$safeUsername = htmlspecialchars($username, ENT_QUOTES, 'UTF-8');
|
||||
$safeVerifyLink = htmlspecialchars($verifyLink, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$mail->Body = '
|
||||
<div style="font-family: Arial, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px 24px;">
|
||||
<h1 style="color: #111827; font-size: 22px; margin-bottom: 8px;">Welcome, ' . $safeUsername . '! 🐾</h1>
|
||||
<p style="color: #4b5563; font-size: 15px; line-height: 1.6;">
|
||||
Your OurApp account has been created. You are ready to start creating unique, AI-generated critters — pick an adjective, animal, action, and location, and watch your imagination come to life.
|
||||
Your OurApp account has been created. Verify your email to unlock your free credits and start creating unique, AI-generated critters — pick an adjective, animal, action, and location, and watch your imagination come to life.
|
||||
</p>
|
||||
<p style="margin-top: 24px;">
|
||||
<a href="https://ourapp.local/" style="display: inline-block; background: linear-gradient(to right, #9333ea, #ec4899); color: #ffffff; text-decoration: none; font-weight: 600; padding: 12px 24px; border-radius: 9999px;">
|
||||
Start Creating
|
||||
<a href="' . $safeVerifyLink . '" style="display: inline-block; background: linear-gradient(to right, #9333ea, #ec4899); color: #ffffff; text-decoration: none; font-weight: 600; padding: 12px 24px; border-radius: 9999px;">
|
||||
Verify Email
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #9ca3af; font-size: 13px; margin-top: 32px;">
|
||||
@@ -55,13 +57,13 @@ function sendRegistrationConfirmationEmail(string $toEmail, string $username): b
|
||||
</p>
|
||||
</div>
|
||||
';
|
||||
$mail->AltBody = "Welcome, {$username}!\n\nYour OurApp account has been created. Start creating unique, AI-generated critters at https://ourapp.local/\n\nIf you didn't create this account, you can safely ignore this email.";
|
||||
$mail->AltBody = "Welcome, {$username}!\n\nYour OurApp account has been created. Verify your email to unlock your free credits and start creating unique, AI-generated critters:\n\n{$verifyLink}\n\nIf you didn't create this account, you can safely ignore this email.";
|
||||
|
||||
$mail->send();
|
||||
|
||||
return true;
|
||||
} catch (PHPMailerException $e) {
|
||||
error_log('Registration confirmation email failed: ' . (isset($mail) ? $mail->ErrorInfo : $e->getMessage()));
|
||||
error_log('Email verification email failed: ' . (isset($mail) ? $mail->ErrorInfo : $e->getMessage()));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
+9
-2
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
define('TICKET_LIFETIME_SECONDS', 20 * 60);
|
||||
define('EMAIL_VERIFICATION_LIFETIME_SECONDS', 60 * 60 * 24 * 3);
|
||||
|
||||
function getTicketSecret(): string
|
||||
{
|
||||
@@ -12,9 +13,9 @@ function getTicketSecret(): string
|
||||
return $secret;
|
||||
}
|
||||
|
||||
function createTicket(int $userId, string $purpose = 'default'): string
|
||||
function createTicket(int $userId, string $purpose = 'default', ?int $lifetimeSeconds = null): string
|
||||
{
|
||||
$expiresAt = time() + TICKET_LIFETIME_SECONDS;
|
||||
$expiresAt = time() + ($lifetimeSeconds ?? TICKET_LIFETIME_SECONDS);
|
||||
$payload = $userId . '|' . $expiresAt . '|' . $purpose;
|
||||
$signature = hash_hmac('sha256', $payload, getTicketSecret());
|
||||
|
||||
@@ -75,6 +76,12 @@ function createPasswordResetTicket(PDO $pdo, int $userId): string
|
||||
{
|
||||
ensurePasswordResetTicketsTable($pdo);
|
||||
|
||||
// Opportunistic cleanup: every new ticket also sweeps out rows that are long past
|
||||
// useful (expired for over a day), so this table doesn't grow unbounded without
|
||||
// needing a separate cron job.
|
||||
$pdo->prepare('DELETE FROM password_reset_tickets WHERE expires_at < :cutoff')
|
||||
->execute(['cutoff' => time() - 86400]);
|
||||
|
||||
$token = createTicket($userId, 'password_reset');
|
||||
|
||||
$stmt = $pdo->prepare('INSERT INTO password_reset_tickets (token_hash, user_id, expires_at) VALUES (:token_hash, :user_id, :expires_at)');
|
||||
|
||||
@@ -31,6 +31,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
|
||||
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
|
||||
}
|
||||
|
||||
if ($email === '') {
|
||||
$errors['email'] = 'Email is required.';
|
||||
}
|
||||
@@ -104,9 +108,9 @@ $pageTitle = 'Log In';
|
||||
<h1 class="text-2xl font-extrabold text-gray-900">Welcome back, explorer.</h1>
|
||||
<p class="mt-1 text-gray-500 text-sm">Your critters have been waiting.</p>
|
||||
|
||||
<?php if (isset($errors['login'])): ?>
|
||||
<?php if (isset($errors['login']) || 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['login'], ENT_QUOTES, 'UTF-8'); ?>
|
||||
<?php echo htmlspecialchars($errors['login'] ?? $errors['csrf'], ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -117,6 +121,7 @@ $pageTitle = 'Log In';
|
||||
<?php endif; ?>
|
||||
|
||||
<form action="login.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">
|
||||
|
||||
+100
-60
@@ -2,6 +2,7 @@
|
||||
require __DIR__ . '/session.php';
|
||||
require __DIR__ . '/db.php';
|
||||
require __DIR__ . '/lib/mailer.php';
|
||||
require __DIR__ . '/lib/ticket.php';
|
||||
start_app_session();
|
||||
|
||||
if (isset($_SESSION['username'])) {
|
||||
@@ -9,7 +10,20 @@ if (isset($_SESSION['username'])) {
|
||||
exit;
|
||||
}
|
||||
|
||||
const STARTING_CREDITS = 3;
|
||||
const REGISTRATION_WINDOW_SECONDS = 3600;
|
||||
const MAX_REGISTRATIONS_PER_IP = 5;
|
||||
|
||||
function registrationAttemptCount(PDO $pdo, string $identifier, int $windowSeconds): int
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM registration_attempts WHERE identifier = :identifier AND created_at > datetime('now', :window)");
|
||||
$stmt->execute(['identifier' => $identifier, 'window' => '-' . $windowSeconds . ' seconds']);
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function recordRegistrationAttempt(PDO $pdo, string $identifier): void
|
||||
{
|
||||
$pdo->prepare('INSERT INTO registration_attempts (identifier) VALUES (:identifier)')->execute(['identifier' => $identifier]);
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$username = '';
|
||||
@@ -23,74 +37,93 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = filter_var($email, FILTER_SANITIZE_EMAIL);
|
||||
|
||||
$pdo = getDbConnection();
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS registration_attempts (
|
||||
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_registration_attempts_identifier ON registration_attempts (identifier)');
|
||||
|
||||
if ($username === '') {
|
||||
$errors['username'] = 'Username is required.';
|
||||
} elseif (strlen($username) < 3 || strlen($username) > 12) {
|
||||
$errors['username'] = 'Username must be between 3 and 12 characters long.';
|
||||
} elseif (!preg_match('/^[a-z0-9]+$/', $username)) {
|
||||
$errors['username'] = 'Username can only contain lowercase letters (a-z) and numbers (0-9).';
|
||||
$ipIdentifier = 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
|
||||
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
|
||||
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
|
||||
} elseif (registrationAttemptCount($pdo, $ipIdentifier, REGISTRATION_WINDOW_SECONDS) >= MAX_REGISTRATIONS_PER_IP) {
|
||||
$errors['rate_limit'] = 'Too many accounts have been created from this network. Please try again later.';
|
||||
} else {
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = :username');
|
||||
$stmt->execute(['username' => $username]);
|
||||
if ($stmt->fetch()) {
|
||||
$errors['username'] = 'That username is not available.';
|
||||
}
|
||||
}
|
||||
|
||||
if ($email === '') {
|
||||
$errors['email'] = 'Email is required.';
|
||||
} elseif (strlen($email) > 100) {
|
||||
$errors['email'] = 'Email cannot exceed 100 characters.';
|
||||
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors['email'] = 'Please enter a valid email address.';
|
||||
} else {
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE email = :email');
|
||||
$stmt->execute(['email' => $email]);
|
||||
if ($stmt->fetch()) {
|
||||
$errors['email'] = "We couldn't create your account with those details. If you already have an account, try logging in instead.";
|
||||
}
|
||||
}
|
||||
|
||||
if ($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)) {
|
||||
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare('INSERT INTO users (username, email, password, credits) VALUES (:username, :email, :password, :credits)');
|
||||
$stmt->execute([
|
||||
'username' => $username,
|
||||
'email' => $email,
|
||||
'password' => $hashedPassword,
|
||||
'credits' => STARTING_CREDITS,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
if (str_contains($e->getMessage(), 'UNIQUE constraint failed: users.username')) {
|
||||
if ($username === '') {
|
||||
$errors['username'] = 'Username is required.';
|
||||
} elseif (strlen($username) < 3 || strlen($username) > 12) {
|
||||
$errors['username'] = 'Username must be between 3 and 12 characters long.';
|
||||
} elseif (!preg_match('/^[a-z0-9]+$/', $username)) {
|
||||
$errors['username'] = 'Username can only contain lowercase letters (a-z) and numbers (0-9).';
|
||||
} else {
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = :username');
|
||||
$stmt->execute(['username' => $username]);
|
||||
if ($stmt->fetch()) {
|
||||
$errors['username'] = 'That username is not available.';
|
||||
} elseif (str_contains($e->getMessage(), 'UNIQUE constraint failed: users.email')) {
|
||||
$errors['email'] = "We couldn't create your account with those details. If you already have an account, try logging in instead.";
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($email === '') {
|
||||
$errors['email'] = 'Email is required.';
|
||||
} elseif (strlen($email) > 100) {
|
||||
$errors['email'] = 'Email cannot exceed 100 characters.';
|
||||
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors['email'] = 'Please enter a valid email address.';
|
||||
} else {
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE email = :email');
|
||||
$stmt->execute(['email' => $email]);
|
||||
if ($stmt->fetch()) {
|
||||
$errors['email'] = "We couldn't create your account with those details. If you already have an account, try logging in instead.";
|
||||
}
|
||||
}
|
||||
|
||||
if ($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)) {
|
||||
start_app_session();
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = (int) $pdo->lastInsertId();
|
||||
$_SESSION['username'] = $username;
|
||||
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
sendRegistrationConfirmationEmail($email, $username);
|
||||
try {
|
||||
$stmt = $pdo->prepare('INSERT INTO users (username, email, password, credits) VALUES (:username, :email, :password, 0)');
|
||||
$stmt->execute([
|
||||
'username' => $username,
|
||||
'email' => $email,
|
||||
'password' => $hashedPassword,
|
||||
]);
|
||||
recordRegistrationAttempt($pdo, $ipIdentifier);
|
||||
} catch (PDOException $e) {
|
||||
if (str_contains($e->getMessage(), 'UNIQUE constraint failed: users.username')) {
|
||||
$errors['username'] = 'That username is not available.';
|
||||
} elseif (str_contains($e->getMessage(), 'UNIQUE constraint failed: users.email')) {
|
||||
$errors['email'] = "We couldn't create your account with those details. If you already have an account, try logging in instead.";
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
header('Location: /');
|
||||
exit;
|
||||
if (empty($errors)) {
|
||||
$newUserId = (int) $pdo->lastInsertId();
|
||||
|
||||
start_app_session();
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = $newUserId;
|
||||
$_SESSION['username'] = $username;
|
||||
|
||||
$verifyToken = createTicket($newUserId, 'email_verify', EMAIL_VERIFICATION_LIFETIME_SECONDS);
|
||||
$appUrl = rtrim(getenv('APP_URL') ?: 'https://ourapp.local', '/');
|
||||
$verifyLink = $appUrl . '/verify-email.php?token=' . urlencode($verifyToken);
|
||||
sendEmailVerificationEmail($email, $username, $verifyLink);
|
||||
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +155,14 @@ $pageTitle = 'Register';
|
||||
<h1 class="text-2xl font-extrabold text-gray-900">Start your collection.</h1>
|
||||
<p class="mt-1 text-gray-500 text-sm">Create an account and bring your imagination to life.</p>
|
||||
|
||||
<?php if (isset($errors['csrf']) || isset($errors['rate_limit'])): ?>
|
||||
<div class="mt-4 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
|
||||
<?php echo htmlspecialchars($errors['csrf'] ?? $errors['rate_limit'], ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form action="register.php" method="post" novalidate class="mt-6 space-y-4">
|
||||
<?php echo csrf_field(); ?>
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
||||
<div class="relative">
|
||||
|
||||
+10
-1
@@ -18,7 +18,9 @@ $success = false;
|
||||
if ($tokenIsWellFormed && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
if ($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.';
|
||||
@@ -70,7 +72,14 @@ $pageTitle = 'Reset Password';
|
||||
<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>
|
||||
|
||||
+18
@@ -38,3 +38,21 @@ function start_app_session(): void
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
function csrf_token(): string
|
||||
{
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
function csrf_field(): string
|
||||
{
|
||||
return '<input type="hidden" name="csrf_token" value="' . htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') . '">';
|
||||
}
|
||||
|
||||
function csrf_verify(?string $token): bool
|
||||
{
|
||||
return is_string($token) && $token !== '' && isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
|
||||
}
|
||||
|
||||
+3
-1
@@ -62,7 +62,7 @@ if ($critter) {
|
||||
));
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $critter && $isOwner) {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $critter && $isOwner && csrf_verify($_POST['csrf_token'] ?? null)) {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'toggle_favorite') {
|
||||
@@ -109,6 +109,7 @@ $pageTitle = 'View Critter';
|
||||
<div class="flex justify-center gap-4 mt-6">
|
||||
<?php if ($isOwner): ?>
|
||||
<form action="single-critter.php?id=<?php echo (int) $critterId; ?>" method="post">
|
||||
<?php echo csrf_field(); ?>
|
||||
<input type="hidden" name="action" value="toggle_favorite">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -118,6 +119,7 @@ $pageTitle = 'View Critter';
|
||||
</button>
|
||||
</form>
|
||||
<form action="single-critter.php?id=<?php echo (int) $critterId; ?>" method="post">
|
||||
<?php echo csrf_field(); ?>
|
||||
<input type="hidden" name="action" value="toggle_archived">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
require __DIR__ . '/session.php';
|
||||
require __DIR__ . '/db.php';
|
||||
require __DIR__ . '/lib/ticket.php';
|
||||
start_app_session();
|
||||
|
||||
$token = $_GET['token'] ?? '';
|
||||
$userId = is_string($token) && $token !== '' ? decodeTicket($token, 'email_verify') : null;
|
||||
|
||||
$status = 'invalid';
|
||||
|
||||
if ($userId !== null) {
|
||||
$pdo = getDbConnection();
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE users SET email_verified_at = CURRENT_TIMESTAMP, credits = credits + :credits WHERE id = :id AND email_verified_at IS NULL');
|
||||
$stmt->execute(['credits' => STARTING_CREDITS, 'id' => $userId]);
|
||||
|
||||
if ($stmt->rowCount() === 1) {
|
||||
$status = 'verified';
|
||||
} else {
|
||||
$checkStmt = $pdo->prepare('SELECT email_verified_at FROM users WHERE id = :id');
|
||||
$checkStmt->execute(['id' => $userId]);
|
||||
$existing = $checkStmt->fetchColumn();
|
||||
$status = ($existing !== false && $existing !== null) ? 'already_verified' : 'invalid';
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = 'Verify Email';
|
||||
?>
|
||||
<!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 text-center">
|
||||
<?php if ($status === 'verified'): ?>
|
||||
<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="badge-check" class="h-7 w-7"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-extrabold text-gray-900">Email verified!</h1>
|
||||
<p class="mt-2 text-gray-500 text-sm">
|
||||
Your email is confirmed and your free credits have been added to your account. You're ready to create.
|
||||
</p>
|
||||
<a href="/" class="mt-6 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">
|
||||
Start Creating <i data-lucide="arrow-right" class="h-4 w-4"></i>
|
||||
</a>
|
||||
<?php elseif ($status === 'already_verified'): ?>
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-blue-50 text-blue-500 mx-auto mb-4">
|
||||
<i data-lucide="badge-check" class="h-7 w-7"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-extrabold text-gray-900">Already verified</h1>
|
||||
<p class="mt-2 text-gray-500 text-sm">This email address has already been verified. You're all set.</p>
|
||||
<a href="/" 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">
|
||||
Go Home <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-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">Link expired</h1>
|
||||
<p class="mt-2 text-gray-500 text-sm">
|
||||
This verification link is invalid or has expired. You can request a new one from the create critter page.
|
||||
</p>
|
||||
<a href="/" 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">
|
||||
Go Home <i data-lucide="arrow-right" class="h-4 w-4"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php require __DIR__ . '/lib/footer.php'; ?>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user