diff --git a/api/critter-details.php b/api/critter-details.php index 2cde178..a289459 100644 --- a/api/critter-details.php +++ b/api/critter-details.php @@ -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'] ?? ''; diff --git a/create-critter.php b/create-critter.php index f43ef8e..b93dc73 100644 --- a/create-critter.php +++ b/create-critter.php @@ -1,6 +1,8 @@ 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';
- + +
+
+ +
+

Verify your email to start creating

+

We sent a verification link to your email when you signed up. Verify it to unlock your free credits.

+ +

Verification email sent! Check your inbox.

+ +

+ +
+ + + +
+
+
@@ -351,12 +403,13 @@ $pageTitle = 'Create Critter';
- +
- +
+
diff --git a/db.php b/db.php index ceab91a..6ef48b6 100644 --- a/db.php +++ b/db.php @@ -1,4 +1,6 @@ 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; diff --git a/forgot-password.php b/forgot-password.php index 2b85f63..993d4fe 100644 --- a/forgot-password.php +++ b/forgot-password.php @@ -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';

Forgot your password?

Enter your email and we'll send you a link to reset it.

+ +
+ +
+ + +
diff --git a/index.php b/index.php index a498867..ecca183 100644 --- a/index.php +++ b/index.php @@ -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'; > + +
+