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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user