diff --git a/api/critter-details.php b/api/critter-details.php index a289459..1cf94a4 100644 --- a/api/critter-details.php +++ b/api/critter-details.php @@ -5,6 +5,9 @@ start_app_session(); header('Content-Type: application/json'); +$pdo = getDbConnection(); +enforce_session_version($pdo); + if (!isset($_SESSION['user_id'])) { http_response_code(401); echo json_encode(['error' => 'Not authenticated']); @@ -28,7 +31,6 @@ function fetchOwnedCritter(PDO $pdo, int $userId, int $critterId): array|false return $stmt->fetch(PDO::FETCH_ASSOC); } -$pdo = getDbConnection(); $method = $_SERVER['REQUEST_METHOD']; $critterId = $method === 'POST' ? filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT) diff --git a/api/purchase-boolean.php b/api/purchase-boolean.php index ca8efa8..eda0766 100644 --- a/api/purchase-boolean.php +++ b/api/purchase-boolean.php @@ -5,11 +5,13 @@ start_app_session(); header('Content-Type: application/json'); +$pdo = getDbConnection(); +enforce_session_version($pdo); + $userId = $_SESSION['user_id'] ?? null; $result = false; if ($userId !== null) { - $pdo = getDbConnection(); $tableExists = $pdo->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transactions'")->fetch(); if ($tableExists) { diff --git a/create-critter.php b/create-critter.php index b93dc73..59ae450 100644 --- a/create-critter.php +++ b/create-critter.php @@ -5,6 +5,9 @@ require __DIR__ . '/lib/ticket.php'; require __DIR__ . '/lib/mailer.php'; start_app_session(); +$pdo = getDbConnection(); +enforce_session_version($pdo); + if (!isset($_SESSION['username']) || !isset($_SESSION['user_id'])) { header('Location: /'); exit; @@ -16,8 +19,6 @@ const DAILY_CRITTER_LIMIT = 30; const CRITTER_COOLDOWN_SECONDS = 15; const VERIFICATION_RESEND_COOLDOWN_SECONDS = 60; -$pdo = getDbConnection(); - $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); diff --git a/db.php b/db.php index 6ef48b6..7bfc9c9 100644 --- a/db.php +++ b/db.php @@ -26,6 +26,9 @@ function getDbConnection(): PDO if (!in_array('email_verified_at', $userColumns, true)) { $pdo->exec('ALTER TABLE users ADD COLUMN email_verified_at TEXT'); } + if (!in_array('session_version', $userColumns, true)) { + $pdo->exec('ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 0'); + } } return $pdo; diff --git a/index.php b/index.php index ecca183..9f7cc26 100644 --- a/index.php +++ b/index.php @@ -4,6 +4,8 @@ require __DIR__ . '/db.php'; start_app_session(); $pdo = getDbConnection(); +enforce_session_version($pdo); + $tableExists = $pdo->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'adjectives'")->fetch(); if (!$tableExists) { diff --git a/lib/header.php b/lib/header.php index 3bb5f36..2ab42fc 100644 --- a/lib/header.php +++ b/lib/header.php @@ -48,9 +48,12 @@ Buy 50 Credits - - Log out - +
+ + +
diff --git a/login.php b/login.php index a52d51f..53554a0 100644 --- a/login.php +++ b/login.php @@ -74,6 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { session_regenerate_id(true); $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; + $_SESSION['session_version'] = (int) $user['session_version']; header('Location: /'); exit; diff --git a/logout.php b/logout.php index 0150ba0..33bb5d3 100644 --- a/logout.php +++ b/logout.php @@ -3,6 +3,11 @@ require __DIR__ . '/session.php'; start_app_session(); +if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_verify($_POST['csrf_token'] ?? null)) { + header('Location: /'); + exit; +} + $_SESSION = []; session_destroy(); diff --git a/receive-purchase.php b/receive-purchase.php index 608c5e5..ce4e19f 100644 --- a/receive-purchase.php +++ b/receive-purchase.php @@ -115,8 +115,14 @@ if (($event['event_type'] ?? '') === 'transaction.completed') { $creditsToAdd = 0; foreach ($items as $item) { $priceId = $item['price']['id'] ?? null; + $quantity = $item['quantity'] ?? 1; + + if (!is_int($quantity) || $quantity < 1 || $quantity > 100) { + continue; + } + if (is_string($priceId) && isset($creditsPerPrice[$priceId])) { - $creditsToAdd += $creditsPerPrice[$priceId]; + $creditsToAdd += $creditsPerPrice[$priceId] * $quantity; } } diff --git a/register.php b/register.php index c0d9340..74e757e 100644 --- a/register.php +++ b/register.php @@ -115,6 +115,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { session_regenerate_id(true); $_SESSION['user_id'] = $newUserId; $_SESSION['username'] = $username; + $_SESSION['session_version'] = 0; $verifyToken = createTicket($newUserId, 'email_verify', EMAIL_VERIFICATION_LIFETIME_SECONDS); $appUrl = rtrim(getenv('APP_URL') ?: 'https://ourapp.local', '/'); diff --git a/reset-password.php b/reset-password.php index a86abf1..a5663a4 100644 --- a/reset-password.php +++ b/reset-password.php @@ -36,7 +36,9 @@ if ($tokenIsWellFormed && $_SERVER['REQUEST_METHOD'] === 'POST') { $errors['password'] = 'This reset link has already been used or has expired. Please request a new one.'; } else { $hashedPassword = password_hash($password, PASSWORD_DEFAULT); - $stmt = $pdo->prepare('UPDATE users SET password = :password WHERE id = :id'); + // 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]); header('Location: login.php?reset=success'); diff --git a/session.php b/session.php index 015c154..3c53353 100644 --- a/session.php +++ b/session.php @@ -56,3 +56,27 @@ function csrf_verify(?string $token): bool { return is_string($token) && $token !== '' && isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token); } + +// Logs the session out if the user's session_version has moved on since login (e.g. a +// password reset bumped it), so a stolen/old session cookie stops working immediately +// instead of staying valid until it naturally expires. Call this right after +// start_app_session() + getDbConnection() on any page that trusts $_SESSION['user_id']. +function enforce_session_version(PDO $pdo): void +{ + if (!isset($_SESSION['user_id'])) { + return; + } + + $stmt = $pdo->prepare('SELECT session_version FROM users WHERE id = :id'); + $stmt->execute(['id' => $_SESSION['user_id']]); + $currentVersion = $stmt->fetchColumn(); + + $sessionVersionMatches = $currentVersion !== false + && isset($_SESSION['session_version']) + && (int) $currentVersion === (int) $_SESSION['session_version']; + + if (!$sessionVersionMatches) { + $_SESSION = []; + session_destroy(); + } +} diff --git a/single-critter.php b/single-critter.php index 229f630..5f3c469 100644 --- a/single-critter.php +++ b/single-critter.php @@ -3,13 +3,14 @@ require __DIR__ . '/session.php'; require __DIR__ . '/db.php'; start_app_session(); +$pdo = getDbConnection(); +enforce_session_version($pdo); + if (!isset($_SESSION['username']) || !isset($_SESSION['user_id'])) { header('Location: /'); exit; } -$pdo = getDbConnection(); - $critterId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT); function fetchOwnedCritter(PDO $pdo, int $userId, int $critterId)