0, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'httponly' => true, 'samesite' => 'Lax', ]); session_start(); } function currentUserId(): ?int { return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null; } /* --------------------------------------------------------------------------- * CSRF * The token lives in the session and reaches the SPA via GET api/auth.php * (and again in the login/register response, since login rotates it). Every * state-changing request must echo it back — fetch() sends the X-CSRF-Token * header; sendBeacon() can't set headers, so a `csrf` body field counts too. * ------------------------------------------------------------------------- */ function csrfToken(): string { if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(16)); } return $_SESSION['csrf_token']; } /** 403s the request unless it carries the session's CSRF token. */ function verifyCsrfApi(?array $body = null): void { $sent = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? $body['csrf'] ?? ''; if (!is_string($sent) || $sent === '' || !hash_equals(csrfToken(), $sent)) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'Invalid session token — reload the page and try again.']); exit; } } function currentUsername(): ?string { return $_SESSION['username'] ?? null; } function requireLogin(): int { $uid = currentUserId(); if ($uid === null) { header('Location: login.php'); exit; } return $uid; } function requireLoginApi(): int { $uid = currentUserId(); if ($uid === null) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(['error' => 'Not logged in']); exit; } return $uid; } function attemptLogin(string $username, string $password): bool { $stmt = db()->prepare('SELECT id, username, password_hash FROM users WHERE username = ?'); $stmt->execute([$username]); $user = $stmt->fetch(); if ($user && password_verify($password, $user['password_hash'])) { session_regenerate_id(true); unset($_SESSION['csrf_token']); // a fresh token for the fresh session id $_SESSION['user_id'] = (int)$user['id']; $_SESSION['username'] = $user['username']; return true; } return false; } /* --------------------------------------------------------------------------- * Login brute-force throttle * Failed attempts are recorded per username + IP. Once a username collects * LOGIN_MAX_USER_FAILS failures (or an IP LOGIN_MAX_IP_FAILS, across any * usernames) inside LOGIN_FAIL_WINDOW, sign-in answers 429 until the window * has passed since the last failure. Retrying while blocked never verifies a * password, so it doesn't extend the block. A successful login clears the * username's failures. * ------------------------------------------------------------------------- */ const LOGIN_FAIL_WINDOW = 900; // seconds const LOGIN_MAX_USER_FAILS = 5; const LOGIN_MAX_IP_FAILS = 20; /** * The client's IP for the throttle. Behind a proxy that hides the visitor * (Cloudflare), REMOTE_ADDR is the proxy — set CLIENT_IP_HEADER in * config.php to the header carrying the real address instead. Optional and * off by default: installs whose config.php predates the setting keep using * REMOTE_ADDR. */ function clientIp(): string { if (defined('CLIENT_IP_HEADER') && CLIENT_IP_HEADER !== '') { $key = 'HTTP_' . strtoupper(str_replace('-', '_', CLIENT_IP_HEADER)); $ip = trim(explode(',', $_SERVER[$key] ?? '')[0]); if ($ip !== '' && filter_var($ip, FILTER_VALIDATE_IP)) { return $ip; } } return $_SERVER['REMOTE_ADDR'] ?? ''; } /** Seconds until this username/IP may try again — 0 means go ahead. */ function loginRetryAfter(string $username, string $ip): int { // The clock math stays inside MySQL: TIMESTAMP values come back in the // connection's time zone, so comparing them against PHP's clock would be // off by the server's UTC offset. $recent = function (string $col, string $value): array { $stmt = db()->prepare( "SELECT COUNT(*) AS fails, COALESCE(TIMESTAMPDIFF(SECOND, NOW(), MAX(attempted_at) + INTERVAL " . LOGIN_FAIL_WINDOW . " SECOND), 0) AS retry FROM login_attempts WHERE $col = ? AND attempted_at > NOW() - INTERVAL " . LOGIN_FAIL_WINDOW . " SECOND" ); $stmt->execute([$value]); return $stmt->fetch(); }; try { $user = $recent('username', $username); $byIp = $recent('ip', $ip); } catch (Throwable $e) { return 0; // table not created yet — no failures recorded so far } $retry = 0; if ((int)$user['fails'] >= LOGIN_MAX_USER_FAILS) { $retry = max($retry, (int)$user['retry']); } if ((int)$byIp['fails'] >= LOGIN_MAX_IP_FAILS) { $retry = max($retry, (int)$byIp['retry']); } return max(0, $retry); } function recordLoginFailure(string $username, string $ip): void { $db = db(); // Lazy creation keeps installs made before this table existed working // (same pattern as app_meta). $db->exec( 'CREATE TABLE IF NOT EXISTS login_attempts ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, ip VARCHAR(45) NOT NULL, attempted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_la_user_time (username, attempted_at), INDEX idx_la_ip_time (ip, attempted_at) ) ENGINE=InnoDB' ); $db->prepare('DELETE FROM login_attempts WHERE attempted_at < NOW() - INTERVAL 1 HOUR')->execute(); $db->prepare('INSERT INTO login_attempts (username, ip) VALUES (?, ?)')->execute([$username, $ip]); } function clearLoginFailures(string $username): void { try { db()->prepare('DELETE FROM login_attempts WHERE username = ?')->execute([$username]); } catch (Throwable $e) { // table doesn't exist yet — nothing to clear } } /** Returns true on success, or an error-message string. */ function registerUser(string $username, string $password): string|bool { if (!preg_match('/^[A-Za-z0-9_.-]{3,50}$/', $username)) { return 'Username must be 3-50 characters (letters, digits, _ . -).'; } if (strlen($password) < 6) { return 'Password must be at least 6 characters.'; } $stmt = db()->prepare('SELECT id FROM users WHERE username = ?'); $stmt->execute([$username]); if ($stmt->fetch()) { return 'That username is already taken.'; } $stmt = db()->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)'); $stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT)]); return true; }