0, // Only sent to the admin pages, not the rest of the site or other apps on the domain 'path' => rtrim(dirname($_SERVER['SCRIPT_NAME']), '/') . '/', 'secure' => $https, 'httponly' => true, 'samesite' => 'Strict', ]); ini_set('session.use_strict_mode', '1'); session_start(); // Admin pages should never be cached, framed or indexed header('Cache-Control: no-store'); header('X-Frame-Options: DENY'); header('X-Robots-Tag: noindex, nofollow'); if (isset($_SESSION['admin_id'], $_SESSION['last_activity']) && time() - $_SESSION['last_activity'] > ADMIN_IDLE_TIMEOUT) { logoutAdmin(); session_start(); } $_SESSION['last_activity'] = time(); } function currentAdmin(): ?array { startAdminSession(); if (!isset($_SESSION['admin_id'])) return null; $stmt = getDB()->prepare('SELECT id, username FROM admins WHERE id = ?'); $stmt->execute([$_SESSION['admin_id']]); return $stmt->fetch() ?: null; } // Call at the top of every admin page; sends anyone not logged in to the login page function requireAdmin(): array { $admin = currentAdmin(); if ($admin === null) { header('Location: login.php'); exit; } return $admin; } function loginLockedOut(PDO $db, string $ip): bool { $stmt = $db->prepare( 'SELECT COUNT(*) FROM login_attempts WHERE ip_address = ? AND attempted_at > NOW() - INTERVAL ' . LOGIN_LOCKOUT_MINUTES . ' MINUTE' ); $stmt->execute([$ip]); return $stmt->fetchColumn() >= LOGIN_MAX_FAILURES; } // Returns null on success, or an error message to show on the login form function attemptLogin(string $username, string $password): ?string { startAdminSession(); $db = getDB(); $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $db->exec('DELETE FROM login_attempts WHERE attempted_at < NOW() - INTERVAL 1 DAY'); if (loginLockedOut($db, $ip)) { return 'Too many failed logins. Please wait ' . LOGIN_LOCKOUT_MINUTES . ' minutes and try again.'; } $stmt = $db->prepare('SELECT id, password_hash FROM admins WHERE username = ?'); $stmt->execute([$username]); $admin = $stmt->fetch(); // Verify against a dummy hash for unknown usernames so response time doesn't reveal which usernames exist $hash = $admin['password_hash'] ?? '$2y$10$lH52eP6Hg.ZFcO9AwUw/lOzZ9REOgwKxOJrLCqF9eKJGV0a4OiYNG'; if (!password_verify($password, $hash) || !$admin) { $db->prepare('INSERT INTO login_attempts (ip_address) VALUES (?)')->execute([$ip]); return 'Incorrect username or password.'; } if (password_needs_rehash($admin['password_hash'], PASSWORD_DEFAULT)) { $db->prepare('UPDATE admins SET password_hash = ? WHERE id = ?') ->execute([password_hash($password, PASSWORD_DEFAULT), $admin['id']]); } $db->prepare('UPDATE admins SET last_login_at = NOW() WHERE id = ?')->execute([$admin['id']]); $db->prepare('DELETE FROM login_attempts WHERE ip_address = ?')->execute([$ip]); // New session ID on login to prevent session fixation session_regenerate_id(true); $_SESSION = ['admin_id' => (int)$admin['id'], 'last_activity' => time()]; return null; } function logoutAdmin(): void { $_SESSION = []; $params = session_get_cookie_params(); setcookie(session_name(), '', [ 'expires' => time() - 3600, 'path' => $params['path'], 'secure' => $params['secure'], 'httponly' => true, 'samesite' => 'Strict', ]); session_destroy(); } function csrfToken(): string { startAdminSession(); if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } return $_SESSION['csrf_token']; } function csrfField(): string { return ''; } // Call at the start of handling any POST; stops the request if the token is missing or wrong function verifyCsrf(): void { startAdminSession(); $token = $_POST['csrf_token'] ?? ''; if (!is_string($token) || empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $token)) { http_response_code(400); exit('Your session has expired. Please go back, reload the page and try again.'); } }