seedproject-web/api/public/controllers/adminauth.php
Carlos Arias e57856173a Phase 1: console runner service + nginx gate + systemd
- agents/console/server.mjs: Node runner (as carlos-arias-agent, 127.0.0.1:3011).
  run/stream(SSE)/publish/discard/ping/auth. Agent edits files (no Bash); the
  runner builds isolated previews and git-commits on publish. Git ops scoped to
  content paths (app, brand, api/db, api/cli) — never infra or secrets.
- adminauth check() — nginx auth_request target (204 admin / 401 not).
- nginx: /devconsole + /_preview gated by auth_request, runner proxied,
  previews admin-only. Runner never exposed directly.
- systemd unit (hardened: NoNewPrivileges, ProtectSystem, scoped ReadWritePaths).

Verified end to end: admin login -> agent edits about.astro -> isolated preview
(admin-gated) -> discard reverts. Unauthenticated access is 401 throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a
2026-07-23 20:53:32 +00:00

108 lines
3.9 KiB
PHP

<?php
use App\Controllers\PublicController;
/**
* Admin console authentication.
*
* POST /api/adminauth/login { username, password } -> 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
{
/** Guard for privileged console endpoints. Emits 401 and stops if not admin. */
public static function requireAdmin(): void
{
if (empty($_SESSION['admin']['ok'])) {
http_response_code(401);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(['ok' => false, 'data' => null, 'error' => [
'code' => 'unauthorized', 'message' => 'Admin login required',
]]);
exit;
}
}
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']]);
}
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,
]);
}
}