Replace Basic Auth with multi-user login, roles, groups, and Lib\Access

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>
This commit is contained in:
code
2026-07-15 00:17:54 +00:00
parent cb64836901
commit b882c304b1
39 changed files with 1476 additions and 318 deletions
+117 -47
View File
@@ -100,35 +100,77 @@ site-identity string that shows up in a shared template (as opposed to a
per-page override) should become a `config.php` key the same way, not stay
hardcoded in the template.
`config['admin_username']` / `config['admin_password_hash']` (username
defaults to `'admin'`, password hash defaults to `''`) gate every
`/admin/*` route behind HTTP Basic Auth — this replaced the old
`docs_enabled` flag entirely (removed); a single gate over all of
`/admin/*` (docs included) made a docs-only toggle redundant. Unlike the
Twig-global pattern above, the gate itself is enforced in `bootstrap.php`,
before rendering: `AdminAuth::requireLogin(...)`
(`novaconium/src/AdminAuth.php`) is called once for any resolved route
whose path is `admin` or starts with `admin/`. **Any new admin page
dropped under `App/pages/admin/` or `novaconium/pages/admin/` is
automatically protected — no per-page wiring needed.** `bootstrap.php`
also special-cases the literal path `/admin/logout` *before* router
resolution — no page exists there — to call `AdminAuth::logout()`, which
always issues a fresh 401 so the browser drops its cached credentials
(there's no server-side session to invalidate). `Renderer` separately
exposes an `admin_auth_enabled` Twig global (true when a password hash is
set) so `admin/index.twig` can conditionally show the "Logout" link — this
is a derived display flag, not the enforcement mechanism itself, which
never depends on Twig. `novaconium/pages/admin/password-hash/` is a
built-in `password_hash()` form (no CLI needed) for generating
`admin_password_hash` — a normal admin page, so it's covered by the same
gate: reachable while no password is set yet (to generate the first
one), then protected like everything else under `/admin/*` afterward. It
computes and displays the hash per-request only; nothing is persisted or
logged. This is a single-user HTTP Basic Auth stopgap, not
the full multi-user system tracked in `novaconium/ISSUES.md` ("Admin login & user
management"); don't extend this class toward multi-user/session-based
auth — that's a separate, larger feature that
will replace it.
`config['admin_auth_enabled']` (default `false`) gates every `/admin/*`
route behind a session login against the `users` table on `Lib\Db`'s
default connection (`novaconium/migrations/0002_create_users.sql`) — the
multi-user system from `novaconium/ISSUES.md`'s "Admin login & user
management" entry, which **replaced** the old single-user HTTP Basic Auth
stopgap (the `admin_username`/`admin_password_hash` config keys and the
`/admin/password-hash` page are gone; that stopgap had itself replaced
the even older `docs_enabled` flag). Same off-by-default posture as
`content_index_enabled`, for the same reason: it depends on SQLite, so
when the flag is false, `/admin/*` is wide open, the three auth routes
(`/admin/login`, `/admin/logout`, `/admin/users`) 404 as if they didn't
exist, their sidecars check the flag (via the same self-loaded two-step
config read `/search` uses) *before* touching `Lib\Db`, and no
`data/novaconium.sqlite` is ever created by this feature — verified
end-to-end. **Two roles** (`users.role`): the **first user ever created
is `'admin'`; everyone created after is `'registered'`**, each with an
optional single group (`users.user_group`, a plain text label — no
groups table). Admins run the site: `/admin/*`, drafts, and every
`Lib\Access` rule passes for them. Registered users log in at the same
`/admin/login` and see whatever content `Lib\Access` (below) grants
their account or group — but `/admin/*` renders a plain 404 for them
(they're authenticated; what they lack is the role, so bouncing them to
the login form would be wrong). Unlike the Twig-global pattern above,
the gate itself is enforced in `bootstrap.php`, before rendering, in two
steps: `AdminAuth::requireLogin($config['admin_auth_enabled'])`
(`novaconium/src/AdminAuth.php`) redirects anyone not logged in, then
`AdminAuth::isAdmin(...)` 404s logged-in non-admins — for any resolved
route whose path is `admin` or starts with `admin/`, **except
`admin/login` itself, which must stay reachable logged-out or the
redirect to it would loop.** **Any new admin page dropped under
`App/pages/admin/` or `novaconium/pages/admin/` is automatically
protected — no per-page wiring needed.** Bootstrapping the first user:
while the `users` table is empty, the gate deliberately returns open
access so the first user (the admin) can be created at `/admin/users`
(which auto-logs its creator in, closing the gate), mirroring the old
"wide open until a password is configured" posture;
`novaconium/bin/create-admin-user.php` creates an admin from the CLI
instead (password via stdin), which lets a deploy create the user
*before* flipping the flag so the open window never exists — it's also
the lockout-recovery path (usage: `<username> <email>`, password via
stdin). Every account has a **unique email address** (`users.email`),
stored normalized via `Lib\Validate::isEmail()` (trim + lowercase) so
the planned email-verification feature (see `novaconium/ISSUES.md`
Backlog) can match case-insensitively — not used for login (username)
or any mail yet. `/admin/users` is the management UI (create — always
`'registered'` except the first / disable / enable / delete / change
group / promote-demote / change email / change password); a disabled
**or deleted** user fails login and any existing session dies on its
next request (`currentUser()` re-checks the row per request), and
disabling, demoting, *or deleting* the last **active admin** is refused
— the empty-table setup window doesn't reopen once users exist, so that
would be a permanent lockout. Delete is a hard delete (username/email
become reusable); disable is the keep-but-shut-out option. `/admin/login` accepts a
`?return=` path (how `Lib\Access` sends someone back to the gated page
after login), validated to a local path (must start `/`, not `//`, no
`\`) so a crafted login link can't bounce a fresh login to another
site; with no return path, admins land on `/admin`, registered users on
`/`. Login
regenerates the session id (`Lib\Session::regenerate()`, added for this)
against session fixation; logout is a real page now — the pre-router
`/admin/logout` special case in `bootstrap.php` is gone — and it is
**POST-only with a GET confirm form** (same shape as
`/admin/clear-cache`), not logout-on-GET: the content-index crawl runs
every page's sidecar as a GET, so a GET side effect there would end the
crawling admin's own session mid-reindex. Passwords are read from `$_POST` directly, not
`Lib\Input` (the documented exact-value exception — see `Lib\Input`'s
doc-comment, which now points at the login/users sidecars). `Renderer`
still exposes the `admin_auth_enabled` Twig global (now mirroring the
config flag) so `admin/index.twig` can conditionally show the
"Admin users"/"Logout" links — a derived display flag, not the
enforcement mechanism itself, which never depends on Twig.
`Lib\Db` (`novaconium/lib/Db.php`) is the SQLite/MySQL groundwork tracked
in `novaconium/ISSUES.md` — a thin, no-ORM PDO wrapper, `Lib\` (not `App\`)
@@ -256,19 +298,45 @@ check (paywall content is the next one on the roadmap likely to hit this)
needs to make the same check here, not just at the point where the
request is first authorized.
`AdminAuth::isAuthenticated(string $username, string $passwordHash): bool`
(`novaconium/src/AdminAuth.php`) is the credential check on its own, with
no response side effects, extracted out of `requireLogin()` (which still
does the same check, then issues the `401` challenge on failure) so a
different caller can react to failure differently. The draft-page gate in
`bootstrap.php` is the first such caller: on failure it renders a plain
404 via the same path an unmatched route takes, not a login prompt —
prompting for credentials at a draft URL would itself reveal that
something is gated there, which defeats the point of hiding it. Returns
`true` (open access) when `$passwordHash` is empty, mirroring
`requireLogin()`'s existing no-op-when-unset posture, so a draft behaves
consistently with the rest of `/admin/*`: wide open until a password is
configured, gated once one is.
`AdminAuth::isAdmin(bool $enabled): bool` / `::isLoggedIn(bool
$enabled): bool` (`novaconium/src/AdminAuth.php`) are the access checks
on their own, with no response side effects `requireLogin()` is
`isLoggedIn()` plus a 303 redirect to `/admin/login` on failure, and a
different caller can react to failure differently. The draft-page gate
in `bootstrap.php` is the first such caller, and it uses `isAdmin()`
(drafts are admin-only — a logged-in registered user gets the same 404
as an anonymous visitor): on failure it renders a plain 404 via the same
path an unmatched route takes, not a login redirect — bouncing to a
login at a draft URL would itself reveal that something is gated there,
which defeats the point of hiding it. Both return `true` (open access)
when `$enabled` is false or while the `users` table is empty, mirroring
`requireLogin()`'s posture, so a draft behaves consistently with the
rest of `/admin/*`: wide open until the feature is enabled and a first
user exists, gated after that.
`Lib\Access` (`novaconium/lib/Access.php`) is the sidecar-level content
gate — how a page (or a section, one line per page; a shared
`_access.php` in the section directory is the documented pattern, since
non-`index.*` files are invisible to the router) is assigned to a user
or group: `Access::require('group:members', 'user:bob')` returns `null`
(allowed — no rules at all means any logged-in user, and **admins pass
every rule**) or a `Response` the sidecar returns as-is (anonymous → 303
to `/admin/login?return=<path>`; logged-in-but-not-allowed → plain-text
404, same hide-don't-tease posture as drafts). See
`/admin/docs/access-control`. **Public is the default, and static pages
are always public**: a page with no sidecar can't call `Access` — and
that's load-bearing, since only sidecar-less pages are written to the
static HTML cache; a gated page necessarily has a sidecar, so it can
never leak through the cache — the caching/auth standing rule below is
satisfied by construction, with no bootstrap exclusion needed. Same
open-until-configured / zero-DB-footprint posture as the rest of admin
auth when the flag is off or no users exist. Deliberately **no side
effects on deny** (the return path travels in the redirect URL, not the
session): the content-index crawl runs every sidecar as an anonymous
GET, so gated pages drop out of `/search`/`/sitemap.xml` automatically
(the crawler discards `Response`s) and a crawl must never scribble on
the visiting user's session — the same reasoning that made
`/admin/logout` POST-only.
`App\ContentIndexer` (`novaconium/src/ContentIndexer.php`) is the shared
crawler behind `/sitemap.xml`, `/search`, and blog tag browsing (see
@@ -442,13 +510,15 @@ site behavior for the next person.
query — use PDO prepared statements once a database layer exists); don't
add an `sqlSafe()`-style method to `Input`. One documented exception: a
field needing an exact, unmodified value (e.g. a password about to be
hashed) should read `$_POST` directly instead — see
`novaconium/pages/admin/password-hash/index.php`. `Lib\Csrf`
hashed or verified) should read `$_POST` directly instead — see the
password fields in `novaconium/pages/admin/users/index.php` and
`novaconium/pages/admin/login/index.php`. `Lib\Csrf`
(`novaconium/lib/Csrf.php`) is standalone session-token CSRF protection, not
wired into `FormValidator` — a sidecar calls `Csrf::verify()` directly.
It's the first thing in the framework to start a native PHP session (only
lazily, when a form actually calls it), which is otherwise unrelated to
`AdminAuth`'s own session-free Basic Auth.
It was the first thing in the framework to start a native PHP session
(only lazily, when a form actually calls it); `Lib\Session` and
`AdminAuth`'s session login now share that same native session, safely
in any order.
- Don't use Twig's `|slice` filter on a **string** (as opposed to an
array) — it unconditionally calls PHP's `mb_substr()` with no fallback
(`novaconium/vendor/twig/src/Extension/CoreExtension.php`), which