sets admin session * POST /api/adminauth/logout -> clears it * GET /api/adminauth/me -> { admin: bool, username } * * A dedicated single-admin tier, separate from community accounts and the * bearer token. Login sets $_SESSION['admin']; every privileged console * endpoint (built in later phases) will call requireAdmin() before doing * anything. Rate-limited hard, and every attempt is written to cja_admin_log. * * Extends PublicController for origin + throttle; the admin check is bespoke * (session flag), not the framework's token/api auth. */ class AdminAuth extends PublicController { // requireAdmin() is inherited from PublicController (non-static). private function logAttempt(string $action, string $detail): void { try { \Db::insert('cja_admin_log', [ 'actor' => $_SESSION['admin']['username'] ?? 'anon', 'action' => $action, 'detail' => mb_substr($detail, 0, 500), 'ip' => $_SERVER['REMOTE_ADDR'] ?? null, ]); } catch (\Throwable $e) { // logging must never block auth } } public function login(): void { // 8 attempts / 5 min / IP — brute-force resistant, human-friendly. $this->guardPublic('admin_login', 8, 300); $in = json_decode((string) file_get_contents('php://input'), true) ?: []; $username = trim((string) ($in['username'] ?? '')); $password = (string) ($in['password'] ?? ''); $row = \Db::getRow('SELECT admin_id, username, password_hash FROM cja_admin WHERE username = ?', [$username]); // Constant-ish work whether or not the user exists (avoid enumeration). $hash = $row['password_hash'] ?? '$2y$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidin'; $ok = $row && password_verify($password, $hash); if (!$ok) { $this->logAttempt('login_fail', "username={$username}"); $this->json(null, 401, ['code' => 'bad_credentials', 'message' => 'Incorrect username or password.']); } session_regenerate_id(true); $_SESSION['admin'] = ['ok' => true, 'username' => $row['username'], 'at' => time()]; \Db::update('cja_admin', ['last_login_at' => date('Y-m-d H:i:s')], 'admin_id = ?', [$row['admin_id']]); $this->logAttempt('login', "username={$row['username']}"); $this->json(['admin' => true, 'username' => $row['username']]); } /** * Secret-link login: GET /admin/ (nginx routes it here as * ?t=). Validates against the stored token hash, sets the admin * session, and redirects to /admin. On failure, redirects with ?e=1. * * Note: the token appears in this request's path, so it can land in access * logs — rotate it if that matters. A password login remains available. */ public function token(): void { $this->guardPublic('admin_token', 10, 300); $t = (string) ($_GET['t'] ?? ''); // Single admin: fetch the row that has a token set and verify. $row = \Db::getRow('SELECT admin_id, username, token_hash FROM cja_admin WHERE token_hash IS NOT NULL LIMIT 1'); $ok = $row && $t !== '' && password_verify($t, $row['token_hash']); if (!$ok) { $this->logAttempt('token_fail', ''); header('Location: /admin?e=1', true, 302); exit; } session_regenerate_id(true); $_SESSION['admin'] = ['ok' => true, 'username' => $row['username'], 'at' => time()]; \Db::update('cja_admin', ['last_login_at' => date('Y-m-d H:i:s')], 'admin_id = ?', [$row['admin_id']]); $this->logAttempt('login_token', "username={$row['username']}"); header('Location: /admin', true, 302); exit; } public function logout(): void { $this->logAttempt('logout', ''); unset($_SESSION['admin']); session_regenerate_id(true); $this->json(['admin' => false]); } /** * nginx auth_request target. Returns 200 (empty) if the current session is * an admin, 401 otherwise. No body — nginx only reads the status. This is * what gates every /devconsole and /_preview request at the edge. */ public function check(): void { if (empty($_SESSION['admin']['ok'])) { http_response_code(401); exit; } http_response_code(204); exit; } public function me(): void { $admin = $_SESSION['admin']['ok'] ?? false; $this->json([ 'admin' => (bool) $admin, 'username' => $admin ? ($_SESSION['admin']['username'] ?? null) : null, ]); } }