seedproject-web/api/public/controllers/adminauth.php
Carlos Arias dc69d1fd0d Phase 2: media upload pipeline
- cja_media table; POST /api/media/upload (admin-gated, multipart).
- Images optimised with GD (downscale to 1600px, re-encode; WebP for
  transparency, JPG for photos). Video via ffmpeg (scale, compress, drop audio).
- Stored in app/public/media/ (source tree) so uploads survive rebuilds and are
  copied into public/ on build.
- requireAdmin() moved to PublicController base (fixes a static/non-static
  clash with AdminAuth). Type detection uses getimagesize (fileinfo ext absent).

Note: php-fpm must be in the caweb group to write app/public/media (restart
after adding www to caweb).

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

129 lines
4.8 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
{
// 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/<token> (nginx routes it here as
* ?t=<token>). 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,
]);
}
}