b882c304b1
Admin login & user management (novaconium/ISSUES.md): session-based
login against a SQLite users table replaces the single-user HTTP Basic
Auth stopgap (admin_username/admin_password_hash and /admin/password-hash
are gone; one admin_auth_enabled flag, off by default with zero DB
footprint). New /admin/login, /admin/logout (POST-only, real page), and
/admin/users pages plus bin/create-admin-user.php.
First user created is the admin; everyone after is registered with a
unique normalized email and an optional group. /admin/* and drafts are
admin-only; Lib\Access gates page content from sidecars
(Access::require('group:members')) with login-redirect/404 responses —
public by default, static pages always public by construction. User
management covers disable/enable, delete, promote/demote, group, email,
and password, with last-active-admin lockout guards.
Also: Session::regenerate() against fixation, friendly missing-PDO-driver
errors in Lib\Db, docs at /admin/docs/access-control and updates across
admin-auth/drafts/sidecars/config/libraries and README/AGENTS.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
106 lines
4.0 KiB
PHP
106 lines
4.0 KiB
PHP
<?php
|
|
|
|
namespace Lib;
|
|
|
|
use App\AdminAuth;
|
|
use App\Response;
|
|
|
|
/**
|
|
* Sidecar-level access control for page content — the way a page (or a
|
|
* whole section, one line per page) is assigned to a user, a group, or
|
|
* just "anyone logged in". See /admin/docs/access-control. Usage, at the
|
|
* top of a sidecar:
|
|
*
|
|
* if ($denied = Access::require('group:members')) {
|
|
* return $denied;
|
|
* }
|
|
*
|
|
* Rules: 'group:<name>' (the user's users.user_group matches), or
|
|
* 'user:<username>' (that exact account); several rules mean "any of
|
|
* these". No rules at all means any logged-in user. Admins always pass
|
|
* every rule. Returns null when the request may proceed, or a Response
|
|
* for the sidecar to return: a 303 to /admin/login (with a ?return= path
|
|
* back here) when nobody is logged in, or a plain 404 when someone *is*
|
|
* logged in but isn't allowed — same hide-don't-tease posture as draft
|
|
* pages, and the same plain-text 404 /search returns when disabled.
|
|
*
|
|
* Public is the default, twice over: a sidecar that never calls this is
|
|
* untouched, and a page with no sidecar at all *can't* call it — static
|
|
* (cached) pages are always public. That's load-bearing, not incidental:
|
|
* only sidecar-less pages are ever written to the static HTML cache
|
|
* (which .htaccess serves before PHP runs — see /admin/docs/caching), so
|
|
* a gated page, necessarily having a sidecar, is never cached. This
|
|
* satisfies the caching/auth standing rule in AGENTS.md by construction
|
|
* rather than by a bootstrap.php exclusion like drafts/admin need.
|
|
*
|
|
* Same open-until-configured posture as the rest of admin auth: with
|
|
* admin_auth_enabled off, or while no users exist yet, require() allows
|
|
* everything (there'd be nothing to log in as) — and never touches
|
|
* Lib\Db, so a site that never opted in never gets a database file.
|
|
*
|
|
* The content-index crawl runs every sidecar as an anonymous GET, so a
|
|
* gated page's sidecar short-circuits to the login redirect during a
|
|
* crawl — Renderer::renderForIndex() discards Responses, meaning gated
|
|
* pages are automatically absent from /search and /sitemap.xml, with no
|
|
* extra wiring. require() has no side effects on deny (the return path
|
|
* travels in the redirect URL, not the session) for the same reason: a
|
|
* crawl must not scribble on the visitor's session.
|
|
*/
|
|
final class Access
|
|
{
|
|
private static ?bool $enabled = null;
|
|
|
|
public static function require(string ...$rules): ?Response
|
|
{
|
|
if (!self::enabled() || !AdminAuth::hasUsers()) {
|
|
return null;
|
|
}
|
|
|
|
$user = AdminAuth::currentUser();
|
|
|
|
if ($user === null) {
|
|
$path = (string) (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
|
|
|
|
return Response::redirect('/admin/login?return=' . rawurlencode($path), 303);
|
|
}
|
|
|
|
if ($user['role'] === 'admin' || $rules === []) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($rules as $rule) {
|
|
if (str_starts_with($rule, 'user:') && substr($rule, 5) === $user['username']) {
|
|
return null;
|
|
}
|
|
|
|
if (str_starts_with($rule, 'group:') && $user['user_group'] !== '' && substr($rule, 6) === $user['user_group']) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return Response::html('404 Not Found', 404);
|
|
}
|
|
|
|
/**
|
|
* Same two-step config load bootstrap.php/bin scripts and the
|
|
* /search sidecar use — Lib\ classes aren't handed $config, so this
|
|
* loads its own copy to read admin_auth_enabled (memoized per
|
|
* request; static state never survives across requests).
|
|
*/
|
|
private static function enabled(): bool
|
|
{
|
|
if (self::$enabled === null) {
|
|
$config = require __DIR__ . '/../config.php';
|
|
|
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
|
if (is_file($appConfigFile)) {
|
|
$config = array_merge($config, require $appConfigFile);
|
|
}
|
|
|
|
self::$enabled = (bool) $config['admin_auth_enabled'];
|
|
}
|
|
|
|
return self::$enabled;
|
|
}
|
|
}
|