From babb71660c68f1f3a0b3881c6337c636b7ee6983 Mon Sep 17 00:00:00 2001 From: Daniel Messer Date: Thu, 29 Oct 2026 07:13:51 -0500 Subject: [PATCH] Add admin login - admins and login_attempts tables - cli/create_admin.php: create an admin or reset a password over SSH (no email involved) - includes/auth.php: hardened admin sessions, login throttling (5 failures per IP per 15 minutes) and CSRF helpers - admin/: login, logout and a dashboard showing broadcast status - includes/admin_layout.php: shared admin page layout Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 ++ admin/index.php | 27 ++++++++ admin/login.php | 49 +++++++++++++ admin/logout.php | 11 +++ cli/create_admin.php | 54 +++++++++++++++ includes/admin_layout.php | 84 +++++++++++++++++++++++ includes/auth.php | 141 ++++++++++++++++++++++++++++++++++++++ sql/schema.sql | 17 +++++ 8 files changed, 389 insertions(+) create mode 100644 admin/index.php create mode 100644 admin/login.php create mode 100644 admin/logout.php create mode 100644 cli/create_admin.php create mode 100644 includes/admin_layout.php create mode 100644 includes/auth.php diff --git a/CLAUDE.md b/CLAUDE.md index c372f45..e6ff963 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,8 @@ This repo covers the **frontend only** — the public-facing website. Icecast co - [includes/broadcasts.php](includes/broadcasts.php) — Broadcast lifecycle and track history logic. - [cli/poll_icecast.php](cli/poll_icecast.php) — Cron-run poller that records broadcasts and tracks. - [cli/set_show.php](cli/set_show.php) — Sets which show is airing next (or now). +- [cli/create_admin.php](cli/create_admin.php) — Creates an admin account or resets its password. +- [admin/](admin/) — Admin backend. Every page calls `requireAdmin()` from [includes/auth.php](includes/auth.php) and uses `adminHeader()`/`adminFooter()` from [includes/admin_layout.php](includes/admin_layout.php). ## Architecture @@ -43,6 +45,10 @@ MariaDB on localhost. Credentials live in `config/db_credentials.php`, which is `cli/poll_icecast.php` runs from cron every minute and polls Icecast four times, 15 seconds apart. A broadcast starts when the stream comes online and ends after five minutes unseen (so brief drop-outs don't split a show). Before going live, run `php cli/set_show.php ` to choose the show. +## Admin Authentication + +Username and password only; the site sends no email. Accounts are created and passwords reset over SSH with `php cli/create_admin.php `. Passwords are hashed with `password_hash()` (PASSWORD_DEFAULT). Sessions use a `rfe_admin` cookie scoped to the admin directory (HttpOnly, SameSite=Strict, Secure over HTTPS) and expire after four hours idle. Five failed logins from one IP within 15 minutes locks that IP out for the rest of the window. Every POST form must include `csrfField()` and its handler must call `verifyCsrf()` first. + ## Icecast Integration - **Stream URL:** `https://radio.cyberpunklibrarian.nohost.me/rfe` diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..469713b --- /dev/null +++ b/admin/index.php @@ -0,0 +1,27 @@ + +

Logged in as .

+ +

Broadcast status

+

+ + Live now' . e($show['name']) . '' : ' (no show set)' ?>, since UTC. + + Offline. is queued for the next broadcast. + + Offline. No show is queued. + +

+ + +

+ +
+ +
+
+ + +
+
+ + +
+
+
    +
  • +
+
+
+
+ +// The password is prompted for and never echoed or passed on the command line. + +if (PHP_SAPI !== 'cli') { + http_response_code(403); + exit; +} + +require __DIR__ . '/../includes/db.php'; + +const MIN_PASSWORD_LENGTH = 12; + +function promptHidden(string $prompt): string { + echo $prompt; + $interactive = function_exists('posix_isatty') && posix_isatty(STDIN); + if ($interactive) shell_exec('stty -echo'); + $value = rtrim((string)fgets(STDIN), "\r\n"); + if ($interactive) { + shell_exec('stty echo'); + echo "\n"; + } + return $value; +} + +$username = $argv[1] ?? ''; +if (!preg_match('/^[A-Za-z0-9_.-]{1,64}$/', $username)) { + fwrite(STDERR, "Usage: php cli/create_admin.php \n(letters, numbers, dot, dash and underscore only)\n"); + exit(1); +} + +$password = promptHidden('Password: '); +if (strlen($password) < MIN_PASSWORD_LENGTH) { + fwrite(STDERR, 'Password must be at least ' . MIN_PASSWORD_LENGTH . " characters.\n"); + exit(1); +} +if (promptHidden('Confirm password: ') !== $password) { + fwrite(STDERR, "Passwords do not match.\n"); + exit(1); +} + +$db = getDB(); +$hash = password_hash($password, PASSWORD_DEFAULT); + +$stmt = $db->prepare('SELECT id FROM admins WHERE username = ?'); +$stmt->execute([$username]); +if ($stmt->fetch()) { + $db->prepare('UPDATE admins SET password_hash = ? WHERE username = ?')->execute([$hash, $username]); + echo "Password reset for {$username}.\n"; +} else { + $db->prepare('INSERT INTO admins (username, password_hash) VALUES (?, ?)')->execute([$username, $hash]); + echo "Admin {$username} created.\n"; +} diff --git a/includes/admin_layout.php b/includes/admin_layout.php new file mode 100644 index 0000000..a59cf2b --- /dev/null +++ b/includes/admin_layout.php @@ -0,0 +1,84 @@ + + + + + <?= e($title) ?> | RFE Admin + + + + + + + + + +
+
+
+

+
+ +
+
+ + + 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.'); + } +} diff --git a/sql/schema.sql b/sql/schema.sql index 9d74e2e..24ba191 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -40,6 +40,23 @@ CREATE TABLE IF NOT EXISTS played_tracks ( FOREIGN KEY (broadcast_id) REFERENCES broadcasts (id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Admin accounts are created and reset from the command line with cli/create_admin.php +CREATE TABLE IF NOT EXISTS admins ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login_at DATETIME NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Failed admin logins, used to throttle password guessing +CREATE TABLE IF NOT EXISTS login_attempts ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ip_address VARCHAR(45) NOT NULL, + attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX (ip_address, attempted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + INSERT IGNORE INTO shows (slug, name) VALUES ('omorains-pub', 'O''Morain''s Pub'), ('cosmoeuphoria', 'Cosmoeuphoria'),