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
|
||||
|
||||
+8
-8
@@ -15,11 +15,11 @@ return [
|
||||
// 'matomo_url' => 'https://matomo.example.com/',
|
||||
// 'matomo_site_id' => '1',
|
||||
|
||||
// Docs: /admin/docs/admin-auth — generate a hash with:
|
||||
// php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT), PHP_EOL;"
|
||||
// or use the built-in /admin/password-hash form.
|
||||
// 'admin_username' => 'admin',
|
||||
// 'admin_password_hash' => '$2y$10$...',
|
||||
// Docs: /admin/docs/admin-auth — session login for /admin/* against
|
||||
// the SQLite-backed users table. After enabling, create the first user
|
||||
// at /admin/users, or beforehand (safer) with:
|
||||
// php novaconium/bin/create-admin-user.php <username>
|
||||
// 'admin_auth_enabled' => true,
|
||||
|
||||
// Docs: /admin/docs/database — adds (or overrides) named Lib\Db
|
||||
// connections. This merges into db_connections by name rather than
|
||||
@@ -38,9 +38,9 @@ return [
|
||||
// ],
|
||||
// ],
|
||||
|
||||
// Docs: /admin/docs/drafts — requires admin_password_hash above to be
|
||||
// set to actually gate anything; open access otherwise, same as the
|
||||
// rest of /admin/*.
|
||||
// Docs: /admin/docs/drafts — requires admin_auth_enabled above (and at
|
||||
// least one user) to actually gate anything; open access otherwise,
|
||||
// same as the rest of /admin/*.
|
||||
// 'draft_routes' => ['blog/upcoming-post'],
|
||||
|
||||
// Docs: /admin/docs/content-index — powers /sitemap.xml, /search, and
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<p>Everything the framework itself ships — default pages, default library classes, the root layout — lives under <code>novaconium/</code>, and can be overridden by placing a same-named file under <code>App/</code>, the only directory a site author is expected to touch. The same override mechanism covers configuration, too: any key in <code>novaconium/config.php</code> can be replaced piecemeal from <code>App/config.php</code>.</p>
|
||||
|
||||
<p>Beyond routing and templating, Novaconium ships with SEO meta tags (canonical links, Open Graph, Twitter Card), optional Matomo analytics with automatic 404 tracking, and an HTTP Basic Auth gate reusable across every <code>/admin/*</code> page — all off or sensible by default, and all documented at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a>, rendered live from this same running instance rather than a separate website.</p>
|
||||
<p>Beyond routing and templating, Novaconium ships with SEO meta tags (canonical links, Open Graph, Twitter Card), optional Matomo analytics with automatic 404 tracking, and a multi-user admin login (with browser-based user management) covering every <code>/admin/*</code> page — all off or sensible by default, and all documented at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a>, rendered live from this same running instance rather than a separate website.</p>
|
||||
|
||||
<p>It's a good fit for small marketing sites, blogs, and internal tools where a full framework would be overkill but a flat-file site generator alone falls short of real server-side logic. The source is on Git if you want to see how it's put together: <a class="icon-link" href="https://git.4lt.ca/4lt/novaconium">{{ icons.git() }}git.4lt.ca/4lt/novaconium</a>.</p>
|
||||
</article>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Matomo & admin auth</h2>
|
||||
<p>Built-in analytics tracking and an HTTP Basic Auth gate for <code>/admin/*</code> — both off until you turn them on in <code>App/config.php</code>.</p>
|
||||
<p>Built-in analytics tracking and a multi-user session login for <code>/admin/*</code> — both off until you turn them on in <code>App/config.php</code>.</p>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<h2>Override anything</h2>
|
||||
|
||||
@@ -12,11 +12,12 @@ A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages
|
||||
- **Layout inheritance** — `_layout/layout.twig` directories are resolved by walking upward from the matched page, so you can override the layout for a whole subtree.
|
||||
- **SEO boilerplate out of the box** — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.
|
||||
- **Built-in Matomo analytics** — set `matomo_url` and `matomo_site_id` in `App/config.php` to enable tracking site-wide, including automatic 404 tracking. Off by default.
|
||||
- **Admin authentication** — gate every `/admin/*` route behind HTTP Basic Auth by setting `admin_username`/`admin_password_hash` in `App/config.php`; reusable for any admin page a project adds later, with a `/admin/logout` link to clear cached credentials and a built-in `/admin/password-hash` form so generating the hash doesn't require the CLI. Off by default.
|
||||
- **Admin authentication** — gate every `/admin/*` route behind a session login with multi-user management: a SQLite-backed `users` table, `/admin/login`/`/admin/logout`, and an `/admin/users` page to create, disable, delete, group, promote/demote, and change the email or password of accounts (plus a `novaconium/bin/create-admin-user.php` CLI for the first user or deploy scripts). Two roles: the first user created is the admin; everyone after is a registered user with an optional group. Every account has a unique, normalized email address — groundwork for email verification later. Enabled with a single `admin_auth_enabled` flag in `App/config.php`; off by default, and reusable for any admin page a project adds later.
|
||||
- **Access control** — assign a page (or a section, one line per page) to a user or group from its sidecar: `Access::require('group:members')` returns `null` or a ready-made `Response` (login redirect with a return path, or a 404 for the wrong account). Public is the default — a sidecar that never calls it is untouched, and static (sidecar-less, cached) pages are always public by construction. Gated pages stay out of `/search` and `/sitemap.xml` automatically.
|
||||
- **Draft pages** — list a route under `draft_routes` in `App/config.php` to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt. Reuses the admin auth check directly, and is excluded from static caching so a cached copy can't leak the draft to the public. See `/admin/docs/drafts`.
|
||||
- **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else.
|
||||
- **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form.
|
||||
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`.
|
||||
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, `/admin/login`, and `/admin/users`.
|
||||
- **SQLite/MySQL database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) supporting multiple named connections open at once — e.g. this site's own SQLite data plus a MySQL connection to a legacy database, usable in the same sidecar — each with its own plain-SQL migration convention, applied automatically on first use or via `php novaconium/bin/migrate.php`. SQLite data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`.
|
||||
- **Sessions with flash data** — `Lib\Session`, a thin wrapper around native PHP sessions with CodeIgniter-style flash values (set now, readable on exactly the next request) for post/redirect/GET flows without a query-string flag. Lazy-start, same mechanism `Lib\Csrf` already uses. See `/admin/docs/session`.
|
||||
- **Content index: sitemap, search, tags** — `/sitemap.xml`, full-text `/search` (SQLite FTS5), and blog tag browsing all share one crawler that renders every page and harvests `keywords`/`tags`/`changefreq`/`priority` Twig blocks via Twig's own `renderBlock()` — no front-matter, no separate metadata files. Off by default (depends on SQLite); reindexes lazily on demand or via `php novaconium/bin/index-content.php`. See `/admin/docs/content-index`.
|
||||
@@ -98,6 +99,7 @@ The full framework documentation — routing, sidecars, libraries, database, ses
|
||||
- [SEO](http://127.0.0.1:8000/admin/docs/seo)
|
||||
- [Matomo](http://127.0.0.1:8000/admin/docs/matomo)
|
||||
- [Admin authentication](http://127.0.0.1:8000/admin/docs/admin-auth)
|
||||
- [Access control](http://127.0.0.1:8000/admin/docs/access-control)
|
||||
- [Draft pages](http://127.0.0.1:8000/admin/docs/drafts)
|
||||
- [Styling](http://127.0.0.1:8000/admin/docs/styling)
|
||||
- [Project layout](http://127.0.0.1:8000/admin/docs/project-layout)
|
||||
|
||||
+167
-37
@@ -51,19 +51,17 @@ expected vs. actual behavior. For features, include the motivating use case.>
|
||||
|
||||
## Backlog
|
||||
|
||||
Suggested build order (foundations first, since admin login builds on
|
||||
three of the others):
|
||||
Suggested build order (foundations first):
|
||||
|
||||
1. **Media/file manager** — no hard dependency; usable standalone, though
|
||||
best gated behind admin login once that exists.
|
||||
2. **Admin login & user management** — SQLite groundwork and session
|
||||
handling it needed are both done; ready to build.
|
||||
3. **In-house comments** — needs admin login & user management (comments
|
||||
are tied to real user accounts, not anonymous); build right after it.
|
||||
4. **Ecommerce functionality** — needs admin login & user management
|
||||
(order/product admin, and customer accounts); SQLite groundwork and
|
||||
session handling (cart) it needed are both done.
|
||||
5. **Paywall functionality** — needs everything Ecommerce needs, plus
|
||||
1. **Media/file manager** — no hard dependency; usable standalone, and
|
||||
already gated behind admin login the moment it exists under `/admin/*`.
|
||||
2. **In-house comments** — admin login & user management (Done
|
||||
2026-07-14) unblocked this; comments are tied to real user accounts,
|
||||
not anonymous.
|
||||
3. **Ecommerce functionality** — its dependencies (SQLite groundwork,
|
||||
session handling for the cart, admin login for order/product admin)
|
||||
are all done now.
|
||||
4. **Paywall functionality** — needs everything Ecommerce needs, plus
|
||||
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
|
||||
build after it rather than in parallel — also now has a concrete
|
||||
precedent to follow for the "gated content must skip the static cache"
|
||||
@@ -74,10 +72,30 @@ three of the others):
|
||||
MySQL support, Session handling (with flash sessions), Draft pages
|
||||
(admin-only preview), Blog tags/categories + Internal search + XML
|
||||
sitemap (shipped together as one content index — see Done), Blog RSS
|
||||
feed, and Syntax highlighting on code blocks all shipped 2026-07-14.
|
||||
feed, Syntax highlighting on code blocks, and Admin login & user
|
||||
management all shipped 2026-07-14.
|
||||
|
||||
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
||||
|
||||
### Email verification for user accounts
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management (Done)
|
||||
- **Added:** 2026-07-14
|
||||
|
||||
Verify a user's email address by sending a confirmation link — the
|
||||
groundwork is already in place: every account has a required, unique,
|
||||
normalized email (`users.email`, added the same day as user deletion —
|
||||
see User deletion & email addresses under Done). Needs a `verified_at`
|
||||
(or token) column, a token-generation/expiry scheme, a send path
|
||||
(`Lib\Mailer` is currently a log-to-file stand-in — this feature is
|
||||
probably what forces it to grow a real mail transport), and a decision
|
||||
on what an unverified account may do (log in but fail `Lib\Access`
|
||||
rules? not log in at all?). Also the natural home for password-reset-
|
||||
by-email later, which shares all the same plumbing.
|
||||
|
||||
### Media/file manager
|
||||
|
||||
- **Type:** Feature
|
||||
@@ -97,31 +115,12 @@ wanted later, in which case that part could ride on SQLite groundwork).
|
||||
Needs basic safety handling: extension allowlist, filename sanitization,
|
||||
and a max upload size, since this is a file-write surface.
|
||||
|
||||
### Admin login & user management
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done)
|
||||
- **Added:** 2026-07-12
|
||||
|
||||
A single-user HTTP Basic Auth stopgap now gates `/admin/*`
|
||||
(`novaconium/src/AdminAuth.php`, `admin_username`/`admin_password_hash` in
|
||||
`App/config.php` — see `/admin/docs/admin-auth`), so `/admin` is no longer
|
||||
wide open by default choice. This entry is the real, larger replacement:
|
||||
multiple accounts, a user store (the SQLite groundwork above — a `users`
|
||||
table with hashed passwords via `password_hash()`/`password_verify()`, no
|
||||
external auth library), proper sessions instead of Basic Auth (rides on
|
||||
session handling above), and basic user management (create/disable a
|
||||
user, change password). Ship this by replacing `AdminAuth::requireLogin()`
|
||||
with the new mechanism, not layering on top of it.
|
||||
|
||||
### In-house comments
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management, SQLite groundwork (Done)
|
||||
- **Depends on:** Admin login & user management (Done), SQLite groundwork (Done)
|
||||
- **Added:** 2026-07-14
|
||||
|
||||
A self-hosted comments library — no third-party service (Disqus,
|
||||
@@ -149,7 +148,7 @@ the same way the contact form does).
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Low
|
||||
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
|
||||
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
|
||||
- **Added:** 2026-07-12
|
||||
|
||||
Product catalog, cart, checkout, and order storage — a `products` /
|
||||
@@ -169,14 +168,18 @@ planning it all up front here.
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Low
|
||||
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
|
||||
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
|
||||
- **Added:** 2026-07-12
|
||||
|
||||
Subscription/membership content gating, similar to OnlyFans/Patreon:
|
||||
recurring billing tied to a user account, content (posts, pages, media)
|
||||
marked as gated behind an active subscription, and access checks in
|
||||
sidecars (`$_SESSION`'s logged-in user + subscription status, similar to
|
||||
how admin login gates `/admin/*`). Reuses Ecommerce's payment-gateway
|
||||
sidecars (`$_SESSION`'s logged-in user + subscription status) — the
|
||||
access-check half of this now has a shipped foundation to build on:
|
||||
`Lib\Access` (see User roles, groups & page access control in Done)
|
||||
already handles login-gated/group-gated sidecar content; a paywall
|
||||
mostly adds "does this account have an active subscription" as a rule
|
||||
source on top of it. Reuses Ecommerce's payment-gateway
|
||||
plumbing for the recurring-charge side rather than integrating a payment
|
||||
provider a second time — build after Ecommerce rather than in parallel.
|
||||
Also needs a decision on how gated content is authored (a `gated: true`
|
||||
@@ -192,6 +195,133 @@ _Nothing yet._
|
||||
|
||||
## Done
|
||||
|
||||
### User deletion & email addresses
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management (Done)
|
||||
- **Added:** 2026-07-14
|
||||
- **Shipped:** 2026-07-14
|
||||
|
||||
Second same-day follow-up to Admin login & user management (below),
|
||||
also pre-commit — so the `email` column went into the existing
|
||||
`0002_create_users.sql` like the roles change before it. `/admin/users`
|
||||
gained a hard-delete action (username/email become reusable; any live
|
||||
session dies on its next request via the same per-request row re-check
|
||||
disabling uses; the last-active-admin guard covers delete as well as
|
||||
disable/demote) and a change-email action. Every account now requires a
|
||||
unique email address — validated and normalized (trim + lowercase) via
|
||||
the existing `Lib\Validate::isEmail()`, so uniqueness is
|
||||
case-insensitive by construction (verified: `BOB@example.com` collides
|
||||
with `bob@example.com`) — on the create form, the change-email action,
|
||||
and `bin/create-admin-user.php` (now `<username> <email>`). Not used
|
||||
for login or any mail yet; it exists so the planned email-verification
|
||||
flow (new Backlog entry above) has an address for every account that
|
||||
predates it. Delete stays deliberately distinct from disable in the UI
|
||||
(inside a confirm-style `<details>` with a warning): disable is
|
||||
keep-but-shut-out, delete is gone-for-good.
|
||||
|
||||
### User roles, groups & page access control
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management (Done)
|
||||
- **Added:** 2026-07-14
|
||||
- **Shipped:** 2026-07-14
|
||||
|
||||
Follow-up to Admin login & user management (below), shipped the same day
|
||||
before any of it was committed — so the `users` schema change went into
|
||||
the existing `0002_create_users.sql` rather than a third migration. Two
|
||||
roles (`users.role`): the first user created is `'admin'`, everyone
|
||||
after is `'registered'` with an optional single group
|
||||
(`users.user_group`, a plain text label matched exactly — deliberately
|
||||
no groups table). `/admin/*` and draft preview are admin-only now — a
|
||||
logged-in registered user gets a plain 404 there (not a login redirect;
|
||||
they're authenticated, what they lack is the role) — and the
|
||||
last-active-user lockout guard became a last-active-*admin* guard,
|
||||
applied to both the disable and the new demote action (`/admin/users`
|
||||
also grew group-assignment and promote/demote).
|
||||
|
||||
`Lib\Access` (`novaconium/lib/Access.php`) is the sidecar-level content
|
||||
gate, per the "assign a page/section to a user or group" spec:
|
||||
`Access::require('group:members', 'user:bob')` at the top of a sidecar
|
||||
returns `null` or a ready-made `Response` (anonymous → login redirect
|
||||
carrying a `?return=` path, validated against open redirects;
|
||||
wrong-account → 404, drafts' hide-don't-tease posture). No rules = any
|
||||
logged-in user; admins pass everything. Public is the default twice
|
||||
over: sidecars that never call it are untouched, and static
|
||||
(sidecar-less) pages *can't* call it — always public, which also means
|
||||
a gated page necessarily has a sidecar and is therefore never written
|
||||
to the static HTML cache: the caching/auth standing rule satisfied by
|
||||
construction, no bootstrap exclusion needed. Sections are gated by
|
||||
composition (a shared `_access.php` file `require`'d by each sidecar in
|
||||
the section), not per-directory config. `Access::require()` has no side
|
||||
effects on deny, so the content-index crawl (anonymous GET per sidecar)
|
||||
both drops gated pages from `/search`/`/sitemap.xml` automatically and
|
||||
can't touch the visiting user's session. Verified end-to-end with three
|
||||
real accounts (admin / registered-with-group / registered-without) and
|
||||
a cookie jar each: rule matrix, return-path round trip,
|
||||
`//evil.com`-style return rejection, registered-user 404s on `/admin/*`
|
||||
and drafts, promote/demote + guards, group reassignment taking effect
|
||||
immediately, crawl exclusion, and flag-off zero-footprint posture.
|
||||
Documented at `/admin/docs/access-control` (new topic, linked from the
|
||||
docs nav/index), with supporting updates to `admin-auth`, `drafts`,
|
||||
`sidecars`, `config`, and `libraries`.
|
||||
|
||||
### Admin login & user management
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done)
|
||||
- **Added:** 2026-07-12
|
||||
- **Shipped:** 2026-07-14
|
||||
|
||||
Shipped as specified: the single-user HTTP Basic Auth stopgap that gated
|
||||
`/admin/*` was **replaced, not layered on** — `AdminAuth` keeps its name
|
||||
and call sites (`bootstrap.php`'s admin gate and draft gate) but is now a
|
||||
session login (`Lib\Session`, with a new `Session::regenerate()` against
|
||||
session fixation) against a `users` table
|
||||
(`novaconium/migrations/0002_create_users.sql`, the second
|
||||
framework-shipped migration) with `password_hash()`/`password_verify()`
|
||||
and no external auth library. The `admin_username`/`admin_password_hash`
|
||||
config keys and the `/admin/password-hash` page are gone, superseded by a
|
||||
single `admin_auth_enabled` flag (default `false`) plus new
|
||||
`/admin/login`, `/admin/logout` (a real page now — the pre-router special
|
||||
case in `bootstrap.php` is gone too — and POST-only with a GET confirm
|
||||
form, because the content-index crawl runs every sidecar as a GET and a
|
||||
logout-on-GET would have ended the crawling admin's own session the first
|
||||
time a lazy reindex rendered it), and `/admin/users`
|
||||
(create/disable/enable/change-password) pages, and a
|
||||
`novaconium/bin/create-admin-user.php` CLI (password via stdin, for
|
||||
deploy scripts and lockout recovery). Documented at
|
||||
`/admin/docs/admin-auth`.
|
||||
|
||||
Decisions worth recording: same zero-footprint posture as the content
|
||||
index (flag off → the three auth routes 404 and `Lib\Db` is never
|
||||
touched, so no `data/novaconium.sqlite` appears — the route sidecars
|
||||
self-load config the same way `/search` does); first-user bootstrap keeps
|
||||
the gate open only while the `users` table is empty (creating the first
|
||||
user at `/admin/users` auto-logs you in as it and closes the gate —
|
||||
running the CLI *before* enabling the flag avoids the window entirely);
|
||||
`admin/login` is the one `/admin/*` route exempted from
|
||||
`requireLogin()`, or its redirect would loop; disabling a user kills any
|
||||
live session on its next request (`currentUser()` re-checks the row per
|
||||
request), and disabling the last active user is refused since the
|
||||
empty-table window never reopens — that would be a permanent lockout.
|
||||
Login/user passwords read `$_POST` directly, extending the documented
|
||||
`Lib\Input` exact-value exception (verified with a password containing
|
||||
`<>` end-to-end). Verified route-by-route with real HTTP requests and a
|
||||
cookie jar: flag off (404s, no DB file), setup window, first-user
|
||||
auto-login, fresh-client redirect, wrong/right password, logout,
|
||||
enable/disable/change-password, live-session lockout on disable,
|
||||
last-user guard, CSRF failure paths, CLI creation, and draft gating via
|
||||
the session cookie (including confirming a pre-existing static-cache copy
|
||||
of a page still serves after the page is marked a draft until the cache
|
||||
is cleared — the documented cache-clear step, not a new bug).
|
||||
|
||||
### Syntax highlighting on code blocks
|
||||
|
||||
- **Type:** Feature
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use Lib\Db;
|
||||
use Lib\Validate;
|
||||
|
||||
require __DIR__ . '/../autoload.php';
|
||||
|
||||
// Creates an admin user from the command line (e.g. from a deploy script,
|
||||
// or to fix a lockout) — the CLI counterpart to /admin/users, and the way
|
||||
// to create the first user *before* flipping admin_auth_enabled on, which
|
||||
// avoids the brief open-access setup window /admin/users otherwise relies
|
||||
// on (see /admin/docs/admin-auth).
|
||||
//
|
||||
// php novaconium/bin/create-admin-user.php <username> <email>
|
||||
//
|
||||
// The password is read from stdin — typed at the prompt (echo suppressed
|
||||
// where the terminal supports it), or piped:
|
||||
//
|
||||
// echo 'the-password' | php novaconium/bin/create-admin-user.php admin admin@example.com
|
||||
//
|
||||
// Deliberately not gated on admin_auth_enabled: creating the row is
|
||||
// harmless while the gate is off, and doing it first is the safer order.
|
||||
|
||||
$username = trim((string) ($argv[1] ?? ''));
|
||||
$email = Validate::isEmail((string) ($argv[2] ?? ''));
|
||||
if ($username === '' || strlen($username) > 64 || $email === false) {
|
||||
fwrite(STDERR, "Usage: php novaconium/bin/create-admin-user.php <username> <email>\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Db::connection() runs pending migrations on first touch, so the users
|
||||
// table exists after this even on a fresh clone.
|
||||
$exists = (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)', [$username])->fetchColumn();
|
||||
if ($exists) {
|
||||
fwrite(STDERR, "A user named '{$username}' already exists.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$emailExists = (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)', [$email])->fetchColumn();
|
||||
if ($emailExists) {
|
||||
fwrite(STDERR, "A user with the email '{$email}' already exists.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$interactive = stream_isatty(STDIN);
|
||||
if ($interactive) {
|
||||
fwrite(STDOUT, "Password for '{$username}': ");
|
||||
// Suppress echo while the password is typed; restore afterwards.
|
||||
// shell_exec() may be unavailable/no-op on some setups — then the
|
||||
// password just echoes, same as any basic CLI prompt.
|
||||
shell_exec('stty -echo 2> /dev/null');
|
||||
}
|
||||
|
||||
$password = rtrim((string) fgets(STDIN), "\r\n");
|
||||
|
||||
if ($interactive) {
|
||||
shell_exec('stty echo 2> /dev/null');
|
||||
fwrite(STDOUT, "\n");
|
||||
}
|
||||
|
||||
if (strlen($password) < 8) {
|
||||
fwrite(STDERR, "Use a password of at least 8 characters.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Always role 'admin', as the script name says — /admin/users is the
|
||||
// place to create registered users; this exists for first-user setup and
|
||||
// lockout recovery, both of which need an admin.
|
||||
Db::query(
|
||||
"INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at) VALUES (?, ?, ?, 'admin', '', 0, ?)",
|
||||
[$username, $email, password_hash($password, PASSWORD_DEFAULT), gmdate('Y-m-d\TH:i:s\Z')]
|
||||
);
|
||||
|
||||
echo "User '{$username}' created.\n";
|
||||
echo "If admin auth isn't enabled yet, set 'admin_auth_enabled' => true in App/config.php.\n";
|
||||
+37
-39
@@ -4,11 +4,9 @@
|
||||
* Front-controller wiring. Every request — via public/index.php (Apache) or
|
||||
* public/router.php (php -S) — ends up require'ing this one file, which:
|
||||
* 1. loads config (framework defaults + optional App/ override)
|
||||
* 2. handles the one hardcoded route (/admin/logout) that exists outside
|
||||
* the normal page tree
|
||||
* 3. resolves the URL to a page directory (Router)
|
||||
* 4. gates /admin/* behind Basic Auth, if configured (AdminAuth)
|
||||
* 5. renders the matched page, or a 404 (Renderer)
|
||||
* 2. resolves the URL to a page directory (Router)
|
||||
* 3. gates /admin/* behind session login, if enabled (AdminAuth)
|
||||
* 4. renders the matched page, or a 404 (Renderer)
|
||||
* There's no framework "kernel" class doing this — it's just a plain
|
||||
* top-to-bottom script, deliberately, so a dev can read the whole
|
||||
* request lifecycle in one file without chasing an abstraction.
|
||||
@@ -37,20 +35,7 @@ if ($config['debug']) {
|
||||
ini_set('display_errors', '1');
|
||||
}
|
||||
|
||||
// Trim the query string and any trailing slash so /admin/logout/ and
|
||||
// /admin/logout?x=1 both match the check below the same way $router does
|
||||
// internally for real page routes.
|
||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$requestPath = rtrim(parse_url($requestUri, PHP_URL_PATH) ?: '/', '/');
|
||||
|
||||
// /admin/logout isn't a real page under App/pages/ or novaconium/pages/ —
|
||||
// there's nothing to route to, so it's special-cased here before the
|
||||
// router even runs. AdminAuth::logout() always sends a fresh 401 challenge
|
||||
// (the standard trick for "logging out" of HTTP Basic Auth, which has no
|
||||
// real server-side session to invalidate) and exits immediately.
|
||||
if ($requestPath === '/admin/logout') {
|
||||
AdminAuth::logout();
|
||||
}
|
||||
|
||||
// Router::resolve() only answers "does a page exist at this URL, and if
|
||||
// so which directory / what params?" — it never touches Twig, sidecars, or
|
||||
@@ -58,36 +43,49 @@ if ($requestPath === '/admin/logout') {
|
||||
$router = new Router($config['pages_dirs']);
|
||||
$route = $router->resolve($requestUri);
|
||||
|
||||
// Every route under /admin/* — clear-cache, docs, password-hash, and any
|
||||
// admin page a project adds later — is gated here, once, rather than in
|
||||
// each page individually. A new admin page is automatically protected the
|
||||
// moment it exists; nothing to remember to wire up. No-op (open access)
|
||||
// when admin_password_hash is empty, which is the default. See
|
||||
// novaconium/src/AdminAuth.php and /admin/docs/admin-auth.
|
||||
$isAdminRoute = $route->found && ($route->dir === 'admin' || str_starts_with((string) $route->dir, 'admin/'));
|
||||
if ($isAdminRoute) {
|
||||
AdminAuth::requireLogin($config['admin_username'], $config['admin_password_hash']);
|
||||
}
|
||||
|
||||
// Both of these are derived, request-independent config values that get
|
||||
// handed to the Renderer so it can expose them to every Twig template as
|
||||
// globals (matomo_url/matomo_site_id/admin_auth_enabled) — see
|
||||
// Renderer::__construct(). Normalizing the trailing slash here means every
|
||||
// template can safely do `matomo_url + 'matomo.php'` without checking.
|
||||
$matomoUrl = $config['matomo_url'] !== '' ? rtrim($config['matomo_url'], '/') . '/' : '';
|
||||
$adminAuthEnabled = $config['admin_password_hash'] !== '';
|
||||
$adminAuthEnabled = (bool) $config['admin_auth_enabled'];
|
||||
|
||||
$cache = new Cache($config['cache_dir']);
|
||||
$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name'], $config['content_index_enabled']);
|
||||
|
||||
// A route listed in draft_routes is only visible to an authenticated admin
|
||||
// — anyone else gets treated exactly like a route that doesn't exist at
|
||||
// all (a plain 404, not a login prompt), so a draft's existence isn't
|
||||
// revealed to anyone poking at the URL. See /admin/docs/drafts. Reuses the
|
||||
// same credential check /admin/* uses (AdminAuth::isAuthenticated()) —
|
||||
// in practice an admin authenticates by visiting /admin once first; the
|
||||
// browser then resends those same Basic Auth credentials to draft URLs
|
||||
// too, since they share the same origin/realm.
|
||||
// Every route under /admin/* — clear-cache, docs, users, and any admin
|
||||
// page a project adds later — is gated here, once, rather than in each
|
||||
// page individually. A new admin page is automatically protected the
|
||||
// moment it exists; nothing to remember to wire up. Two steps: nobody
|
||||
// logged in → redirect to the login form (requireLogin() exits); logged
|
||||
// in but not an admin (a 'registered' user — see /admin/docs/admin-auth)
|
||||
// → the same plain 404 an unmatched route gets, since bouncing an
|
||||
// already-authenticated user back to the login form would be a lie (what
|
||||
// they lack is the admin role, not a session). The one exemption is the
|
||||
// login form itself, which has to stay reachable logged-out or
|
||||
// requireLogin()'s redirect to it would loop forever. No-op (open access)
|
||||
// when admin_auth_enabled is false (the default), or while no users exist
|
||||
// yet (so the first user can be created at /admin/users). See
|
||||
// novaconium/src/AdminAuth.php and /admin/docs/admin-auth.
|
||||
$isAdminRoute = $route->found && ($route->dir === 'admin' || str_starts_with((string) $route->dir, 'admin/'));
|
||||
if ($isAdminRoute && $route->dir !== 'admin/login') {
|
||||
AdminAuth::requireLogin($config['admin_auth_enabled']);
|
||||
|
||||
if (!AdminAuth::isAdmin($config['admin_auth_enabled'])) {
|
||||
$renderer->renderNotFound($requestUri);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A route listed in draft_routes is only visible to a logged-in admin —
|
||||
// anyone else (including logged-in registered users) gets treated exactly
|
||||
// like a route that doesn't exist at all (a plain 404, not a login
|
||||
// prompt), so a draft's existence isn't revealed to anyone poking at the
|
||||
// URL. See /admin/docs/drafts. Reuses the same access check /admin/* uses
|
||||
// (AdminAuth::isAdmin()) — an admin logs in once at /admin/login, and the
|
||||
// session cookie covers draft URLs too, since it's scoped to the whole
|
||||
// origin.
|
||||
$isDraftRoute = $route->found && in_array($route->dir, $config['draft_routes'], true);
|
||||
|
||||
// $route->found is false for anything Router couldn't match to a real page
|
||||
@@ -103,7 +101,7 @@ $isDraftRoute = $route->found && in_array($route->dir, $config['draft_routes'],
|
||||
// therefore AdminAuth::requireLogin()) ever runs again — permanently
|
||||
// serving the admin panel to anyone, unauthenticated, straight from the
|
||||
// static cache. See novaconium/src/Renderer.php.
|
||||
if (!$route->found || ($isDraftRoute && !AdminAuth::isAuthenticated($config['admin_username'], $config['admin_password_hash']))) {
|
||||
if (!$route->found || ($isDraftRoute && !AdminAuth::isAdmin($config['admin_auth_enabled']))) {
|
||||
$renderer->renderNotFound($requestUri);
|
||||
return;
|
||||
}
|
||||
|
||||
+15
-10
@@ -28,16 +28,21 @@ return [
|
||||
'matomo_url' => '',
|
||||
'matomo_site_id' => '',
|
||||
|
||||
// Gates every /admin/* route (clear-cache, docs, and any future admin
|
||||
// page) behind HTTP Basic Auth. Leave admin_password_hash empty (the
|
||||
// default) to disable the gate entirely — matches this project's
|
||||
// existing wide-open behavior until a project opts in. Generate a hash
|
||||
// with: php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT), PHP_EOL;"
|
||||
// and set both via App/config.php, e.g.:
|
||||
// 'admin_username' => 'admin',
|
||||
// 'admin_password_hash' => '$2y$10$...',
|
||||
'admin_username' => 'admin',
|
||||
'admin_password_hash' => '',
|
||||
// Gates every /admin/* route (clear-cache, docs, users, and any future
|
||||
// admin page) behind a session login against the `users` table on
|
||||
// Lib\Db's default connection — see /admin/docs/admin-auth. The first
|
||||
// user created is the admin; users after that are 'registered', each
|
||||
// with an optional group, and see whatever content sidecars grant via
|
||||
// Lib\Access (see /admin/docs/access-control) — /admin/* itself 404s
|
||||
// for them. Off by default because it depends on SQLite (same
|
||||
// reasoning as content_index_enabled below): when false, /admin/* is
|
||||
// wide open, /admin/login, /admin/logout, and /admin/users 404,
|
||||
// Access::require() allows everything, and nothing ever touches
|
||||
// Lib\Db because of this feature. After enabling it via
|
||||
// App/config.php, create the first user at /admin/users (open access
|
||||
// until at least one user exists) or with:
|
||||
// php novaconium/bin/create-admin-user.php <username>
|
||||
'admin_auth_enabled' => false,
|
||||
|
||||
// Lib\Db (see /admin/docs/database) — named, simultaneously-usable
|
||||
// connections, keyed by name; 'default' is the only one required. A
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,12 @@ namespace Lib;
|
||||
*
|
||||
* <input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
*
|
||||
* This is the first thing in the framework that starts a native PHP session
|
||||
* This was the first thing in the framework to start a native PHP session
|
||||
* — but only lazily, the moment token()/verify() is actually called. A page
|
||||
* that never touches Csrf never gets a session cookie. This is unrelated to
|
||||
* Lib\AdminAuth, which stays fully stateless (HTTP Basic Auth, no sessions
|
||||
* for login state) — see novaconium/src/AdminAuth.php.
|
||||
* that never touches Csrf never gets a session cookie. Lib\Session and
|
||||
* App\AdminAuth (session-based admin login) now touch the same native
|
||||
* session the same lazy way — safe in any order, since ensureSession()
|
||||
* no-ops when a session is already active.
|
||||
*/
|
||||
final class Csrf
|
||||
{
|
||||
|
||||
@@ -89,6 +89,9 @@ final class Db
|
||||
*/
|
||||
private static function connectSqlite(array $config): PDO
|
||||
{
|
||||
self::requireDriver('sqlite', 'pdo_sqlite',
|
||||
'bundled with PHP but sometimes not enabled — Debian/Ubuntu: `apt install php-sqlite3`; Arch: uncomment `extension=pdo_sqlite` in php.ini');
|
||||
|
||||
$path = $config['path'];
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
@@ -109,6 +112,9 @@ final class Db
|
||||
*/
|
||||
private static function connectMysql(array $config): PDO
|
||||
{
|
||||
self::requireDriver('mysql', 'pdo_mysql',
|
||||
'Debian/Ubuntu: `apt install php-mysql`; Arch: uncomment `extension=pdo_mysql` in php.ini');
|
||||
|
||||
$charset = $config['charset'] ?? 'utf8mb4';
|
||||
$dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']};charset={$charset}";
|
||||
|
||||
@@ -118,6 +124,23 @@ final class Db
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing PDO driver otherwise surfaces as a bare PDOException
|
||||
* ("could not find driver") from deep inside a connect call — hit for
|
||||
* real the first time this ran on a PHP install without pdo_sqlite
|
||||
* enabled. Checking PDO::getAvailableDrivers() up front turns that
|
||||
* into an error that names the extension and how to install it.
|
||||
*/
|
||||
private static function requireDriver(string $driver, string $extension, string $installHint): void
|
||||
{
|
||||
if (!in_array($driver, PDO::getAvailableDrivers(), true)) {
|
||||
throw new RuntimeException(
|
||||
"PHP is missing the {$extension} extension, which this connection's '{$driver}' driver needs ({$installHint}). " .
|
||||
'Verify with `php -m`, and restart PHP after enabling it. See /admin/docs/database.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies any *.sql file under $migrationsDirs not yet recorded in this
|
||||
* connection's own schema_migrations table. $migrationsDirs is an
|
||||
|
||||
@@ -22,11 +22,13 @@ namespace Lib;
|
||||
* dangerous.
|
||||
*
|
||||
* One documented exception: a field that needs an exact, unmodified value
|
||||
* (e.g. a password about to be hashed) should read $_POST directly instead —
|
||||
* cleaning would silently strip characters like < and > before hashing,
|
||||
* producing a hash that doesn't match what the user actually typed. See
|
||||
* novaconium/pages/admin/password-hash/index.php for the one place this
|
||||
* framework does that on purpose.
|
||||
* (e.g. a password about to be hashed or verified) should read $_POST
|
||||
* directly instead — cleaning would silently strip characters like < and >
|
||||
* before hashing, producing a hash that doesn't match what the user
|
||||
* actually typed (or failing a login whose password actually matches). See
|
||||
* the password fields in novaconium/pages/admin/users/index.php and
|
||||
* novaconium/pages/admin/login/index.php for the places this framework
|
||||
* does that on purpose.
|
||||
*/
|
||||
final class Input
|
||||
{
|
||||
|
||||
@@ -61,6 +61,20 @@ final class Session
|
||||
unset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the session id for a fresh one, keeping the session's data.
|
||||
* Call on any privilege change — after a successful login, and on
|
||||
* logout — so a session id an attacker planted or observed before the
|
||||
* change is worthless after it (session fixation). App\AdminAuth does
|
||||
* exactly this.
|
||||
*/
|
||||
public static function regenerate(): void
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores $value so it's readable via getFlash($key) on the next
|
||||
* request only, then gone — regardless of whether getFlash() was
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'registered',
|
||||
user_group TEXT NOT NULL DEFAULT '',
|
||||
is_disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -92,6 +92,15 @@
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro users(class) %}
|
||||
<svg class="icon icon-users {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro trash(class) %}
|
||||
<svg class="icon icon-trash {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M4 7h16" />
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<li><a class="icon-link" href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Access control{% endblock %}
|
||||
|
||||
{% block description %}Assign a page or section to a user or group from its sidecar with Lib\Access — public by default, static pages always public.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Access control</h1>
|
||||
|
||||
<p><code>Lib\Access</code> (<code>novaconium/lib/Access.php</code>) assigns a page to a user, a group, or just "anyone logged in" — from the page's own sidecar, using the accounts <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> manages at <a href="/admin/users">/admin/users</a>. One call at the top of <code>index.php</code>:</p>
|
||||
|
||||
<pre><code><?php
|
||||
|
||||
use Lib\Access;
|
||||
|
||||
if ($denied = Access::require('group:members')) {
|
||||
return $denied;
|
||||
}
|
||||
|
||||
return [
|
||||
// ...normal sidecar context...
|
||||
];</code></pre>
|
||||
|
||||
<p><code>Access::require()</code> returns <code>null</code> when the request may proceed, or a <code>Response</code> the sidecar returns as-is: nobody logged in → a <code>303</code> to <a href="/admin/login">/admin/login</a> carrying a <code>?return=</code> path so a successful login lands right back on the page they wanted; logged in but not allowed → a plain <code>404</code>, the same hide-don't-tease posture as <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft pages</a>.</p>
|
||||
|
||||
<h2>Rules</h2>
|
||||
|
||||
<ul>
|
||||
<li><code>Access::require()</code> — no rules: any logged-in user (admin or registered).</li>
|
||||
<li><code>Access::require('group:members')</code> — users whose group (assigned at <a href="/admin/users">/admin/users</a>) is <code>members</code>. Each user has at most one group; a group is just a text label, matched exactly — there's no groups table to manage.</li>
|
||||
<li><code>Access::require('user:bob')</code> — exactly that account.</li>
|
||||
<li><code>Access::require('group:members', 'user:bob')</code> — several rules mean <em>any</em> of them grants access.</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Admins always pass every rule</strong> — the admin role exists to run the site, so there's no way to write a rule that locks an admin out of content.</p>
|
||||
|
||||
<h2>Public is the default — and static pages are always public</h2>
|
||||
|
||||
<p>A sidecar that never calls <code>Access::require()</code> is completely untouched by all of this, and a page with no sidecar at all <em>can't</em> call it — so sidecar-less pages are always public. That's load-bearing rather than incidental: only sidecar-less pages are ever written to the <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}static HTML cache</a>, which Apache serves before PHP (and therefore any access check) runs. Because a gated page necessarily has a sidecar, it's never statically cached, so there's no way to leak a gated page through the cache — the caching/auth rule that drafts and <code>/admin/*</code> need explicit cache exclusions for is satisfied here by construction.</p>
|
||||
|
||||
<h2>Gating a section</h2>
|
||||
|
||||
<p>There's no per-directory config for this — a "section" is gated by giving each page in it a sidecar with the same check, which stays visible and greppable at the page level. To keep the rule itself in one place, put a <code>_access.php</code> file in the section directory (any file that isn't <code>index.php</code>/<code>index.twig</code> is invisible to the router) and <code>require</code> it from each sidecar — a PHP file can return a value, so it composes exactly like a direct call:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/pages/members/_access.php — the section's one shared rule
|
||||
use Lib\Access;
|
||||
|
||||
return Access::require('group:members');</code></pre>
|
||||
|
||||
<pre><code><?php
|
||||
// App/pages/members/anything/index.php — each page in the section
|
||||
if ($denied = require dirname(__DIR__) . '/_access.php') {
|
||||
return $denied;
|
||||
}
|
||||
|
||||
return [];</code></pre>
|
||||
|
||||
<h2>Interactions worth knowing</h2>
|
||||
|
||||
<ul>
|
||||
<li><strong>Off means open.</strong> With <code>admin_auth_enabled</code> off (or while no users exist yet), <code>Access::require()</code> allows everything — there'd be nothing to log in as. Same open-until-configured posture as the rest of admin auth, and the same zero-footprint guarantee: it never touches <code>Lib\Db</code> in that state.</li>
|
||||
<li><strong>Gated pages stay out of <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}search and the sitemap</a> automatically.</strong> The content-index crawl runs every sidecar as an anonymous GET, so a gated sidecar short-circuits to the login redirect and the crawler skips the page — nothing to configure, verified for real. <code>require()</code> also has no side effects on deny (the return path travels in the redirect URL, not the session), so a crawl can't scribble on the visiting user's session.</li>
|
||||
<li><strong>Who's logged in?</strong> A sidecar that wants to greet the user (or vary content by account) can read <code>App\AdminAuth::currentUser()</code> — <code>id</code>/<code>username</code>/<code>email</code>/<code>role</code>/<code>user_group</code>, or <code>null</code> — and pass what it needs into its template context.</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -4,49 +4,67 @@
|
||||
|
||||
{% block title %}Admin authentication{% endblock %}
|
||||
|
||||
{% block description %}Gating /admin/* behind HTTP Basic Auth, reusable for any future admin page.{% endblock %}
|
||||
{% block description %}Session-based login with admin/registered roles, groups, and user management, gating /admin/* and draft pages.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Admin authentication</h1>
|
||||
|
||||
<p>Every route under <code>/admin/*</code> — <code>/admin</code>, <code>/admin/clear-cache</code>, the docs section, and any admin page a project adds later — can be gated behind HTTP Basic Auth with two <code>App/config.php</code> keys. It's off by default (same posture as Matomo): an empty <code>admin_password_hash</code> means no gate at all, matching this project's original wide-open behavior.</p>
|
||||
<p>Every route under <code>/admin/*</code> — <code>/admin</code>, <code>/admin/clear-cache</code>, the docs section, and any admin page a project adds later — can be gated behind a session-based login against a <code>users</code> table in the <a class="icon-link" href="/admin/docs/database">{{ icons.book() }}database</a>'s <code>default</code> connection. It's off by default (same posture as the <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}content index</a>, and for the same reason: it depends on SQLite, a real dependency plenty of sites won't want): with <code>admin_auth_enabled</code> left <code>false</code>, <code>/admin/*</code> is wide open, <code>/admin/login</code>, <code>/admin/logout</code>, and <code>/admin/users</code> all <code>404</code> as if they didn't exist, and nothing ever touches <code>Lib\Db</code> because of this feature — no <code>data/novaconium.sqlite</code> gets created just because the code exists.</p>
|
||||
|
||||
<p>This replaced the old <code>docs_enabled</code> config flag, which only hid the docs section specifically. Since this gate covers all of <code>/admin/*</code> — including docs — there's no need for a separate docs-only toggle anymore; set a password and the whole admin area (docs included) requires login.</p>
|
||||
<p>This replaced the original single-user HTTP Basic Auth stopgap (an <code>admin_username</code>/<code>admin_password_hash</code> config pair — both keys, and the <code>/admin/password-hash</code> hash-generator page, are gone). Same call sites in <code>bootstrap.php</code>, new mechanism: multiple accounts, real server-side login state (riding on <a class="icon-link" href="/admin/docs/session">{{ icons.book() }}<code>Lib\Session</code></a>), and user management in the browser.</p>
|
||||
|
||||
<h2>Two roles: admin and registered</h2>
|
||||
|
||||
<p>The <strong>first user ever created is the admin</strong>; every user created after that is <strong>registered</strong>. An admin runs the site: full <code>/admin/*</code> access, sees <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft pages</a>, and passes every <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}<code>Lib\Access</code></a> rule. A registered user can log in at the same <a href="/admin/login">/admin/login</a> and sees whatever content <code>Lib\Access</code> assigns to their account or their group — but <code>/admin/*</code> returns a plain <code>404</code> for them, not a login prompt (they <em>are</em> logged in; what they lack is the role). Each registered user can be assigned at most one <strong>group</strong> — a plain text label (e.g. <code>members</code>) that <code>Access::require('group:members')</code> matches exactly; there's no groups table to manage.</p>
|
||||
|
||||
<h2>Enabling it</h2>
|
||||
|
||||
<p>Generate a password hash once — either on the command line:</p>
|
||||
|
||||
<pre><code>php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT), PHP_EOL;"</code></pre>
|
||||
|
||||
<p>...or, if you'd rather not touch a terminal, at <a class="icon-link" href="/admin/password-hash">{{ icons.lock() }}/admin/password-hash</a> — a small built-in form that does the same <code>password_hash()</code> call and hands back a ready-to-paste config snippet. Nothing typed there is stored or logged. Since it lives under <code>/admin/*</code> like everything else here, it's automatically covered by this same gate once a password is set — reachable while <code>admin_password_hash</code> is still empty (so you can generate your first one), then protected like any other admin page afterward.</p>
|
||||
|
||||
<p>Then set both keys in <code>App/config.php</code>:</p>
|
||||
<p>Turn it on in <code>App/config.php</code>:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'admin_username' => 'admin',
|
||||
'admin_password_hash' => '$2y$10$...',
|
||||
'admin_auth_enabled' => true,
|
||||
];</code></pre>
|
||||
|
||||
<p>Every request into <code>/admin/*</code> now requires that username/password via the browser's built-in Basic Auth prompt; anything outside <code>/admin</code> is unaffected.</p>
|
||||
<p>Since this is the first SQLite-backed feature most sites turn on, make sure PHP has the <code>pdo_sqlite</code> extension enabled first (<code>php -m | grep -i sqlite</code>) — it's bundled with PHP but not always enabled; Debian/Ubuntu package it as <code>php-sqlite3</code>. Without it, the first request into <code>/admin/*</code> fails naming the missing extension. See <a href="/admin/docs">Overview</a>'s requirements list.</p>
|
||||
|
||||
<p>Enabling the flag alone protects nothing yet — <strong>while zero users exist, the whole admin area stays open</strong>, precisely so the first user (the admin) can be created. Do that either in the browser at <a href="/admin/users">/admin/users</a> (you're logged in as the first user automatically the moment it's created, and the gate closes), or from the command line:</p>
|
||||
|
||||
<pre><code>php novaconium/bin/create-admin-user.php admin admin@example.com</code></pre>
|
||||
|
||||
<p>The CLI reads the password from stdin (echo suppressed at an interactive prompt, and pipeable from a deploy script), and always creates an <em>admin</em> — it exists for first-user setup and lockout recovery, both of which need one; registered users are created at <code>/admin/users</code>. Running it <em>before</em> flipping <code>admin_auth_enabled</code> on is the safer order — the open setup window then never exists at all.</p>
|
||||
|
||||
<p>The <code>users</code> table ships as a framework migration (<code>novaconium/migrations/0002_create_users.sql</code>) and is created automatically the first time anything touches the <code>default</code> connection — no manual schema step. Passwords are stored as <code>password_hash()</code> hashes and checked with <code>password_verify()</code>; no plaintext, and nothing here ever selects the hash back out except to verify a login. Every account also has a <strong>unique email address</strong>, stored normalized (trimmed, lowercased, via <code>Lib\Validate::isEmail()</code>) — not used for login (that's the username) or for any mail yet, but required up front so the planned email-verification flow (see <code>novaconium/ISSUES.md</code>) has an address for every account that already exists by then.</p>
|
||||
|
||||
<h2>Managing users</h2>
|
||||
|
||||
<p><a href="/admin/users">/admin/users</a> lists every account and handles the rest: create a user (registered, with an email address and an optional group — only the very first is the admin), disable/enable one, assign or change a group, promote/demote between admin and registered, change an email address, change a password, and delete an account outright. Behaviors worth knowing:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Disabling is immediate.</strong> A disabled user can't log in, and any session they already had is re-checked against the table on its next request — there's no "still logged in until the session expires" window. The same immediate re-check applies to a role or group change.</li>
|
||||
<li><strong>The last active admin can't be disabled, demoted, or deleted.</strong> Any of the three would lock everyone out of <code>/admin/*</code> permanently (the zero-users setup window doesn't reopen — the table isn't empty), leaving only the CLI or hand-editing the database as recovery. The form refuses instead. Registered users carry no such guard.</li>
|
||||
<li><strong>Delete is a hard delete</strong> — the row is gone, the username and email become reusable, and any live session dies on its next request, same as disabling. Disable is the right tool for "shut this account out but keep it"; delete is for accounts that shouldn't exist at all.</li>
|
||||
<li><strong>More admins are allowed</strong> — "first user is the admin" is the default, not a cap; promote a registered user any time.</li>
|
||||
</ul>
|
||||
|
||||
<h2>How it's wired</h2>
|
||||
|
||||
<p><code>novaconium/src/AdminAuth.php</code> is a single reusable check — <code>AdminAuth::requireLogin($username, $passwordHash)</code> — called once from <code>novaconium/bootstrap.php</code> for any resolved route whose path is <code>admin</code> or starts with <code>admin/</code>, and only for routes that actually resolved (no login prompt on an unrelated 404). Because the check lives in <code>bootstrap.php</code> rather than on each page, <strong>a new admin page needs zero extra wiring</strong> to be protected — dropping a new directory under <code>App/pages/admin/</code> or <code>novaconium/pages/admin/</code> is automatically gated the moment it exists.</p>
|
||||
<p><code>novaconium/src/AdminAuth.php</code> is a pair of reusable checks called once from <code>novaconium/bootstrap.php</code> for any resolved route whose path is <code>admin</code> or starts with <code>admin/</code>, and only for routes that actually resolved (no redirect on an unrelated 404): <code>AdminAuth::requireLogin($enabled)</code> redirects anyone not logged in to the login form, then <code>AdminAuth::isAdmin($enabled)</code> 404s anyone who <em>is</em> logged in but isn't an admin — a registered user is authenticated, so bouncing them back to the login form would be a lie. Because the checks live in <code>bootstrap.php</code> rather than on each page, <strong>a new admin page needs zero extra wiring</strong> to be protected — dropping a new directory under <code>App/pages/admin/</code> or <code>novaconium/pages/admin/</code> is automatically gated the moment it exists. The one exemption is <code>admin/login</code> itself, which has to stay reachable logged-out or the redirect to it would loop.</p>
|
||||
|
||||
<p>The credential check itself is a separate method, <code>AdminAuth::isAuthenticated($username, $passwordHash)</code> — <code>requireLogin()</code> is just that check plus the <code>401</code>-challenge response on failure. <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> reuse <code>isAuthenticated()</code> directly with a different failure response (a plain <code>404</code>, not a login prompt), rather than duplicating the credential logic.</p>
|
||||
<p>The login form is a normal page with a normal form: CSRF-protected like every other form here (see <a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a>), reading the username via <code>Lib\Input</code> and the password straight from <code>$_POST</code> (the documented exact-value exception — cleaning would strip characters like <code><</code>/<code>></code> and fail a login whose password actually matches). A successful login regenerates the session id (<code>Session::regenerate()</code>) before storing the user id, so a session id planted or observed pre-login never becomes an authenticated one — then lands on <code>?return=</code> (how <code>Lib\Access</code> sends someone back to the gated page they wanted; validated to be a local path, never another site), or, with no return path, on <code>/admin</code> for admins and the homepage for registered users.</p>
|
||||
|
||||
<p><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> reuse <code>isAdmin()</code> directly with a different failure response (a plain <code>404</code> for <em>everyone</em> unauthorized, never a login redirect), rather than duplicating the logic. The session cookie is scoped to the whole origin, so logging in once at <code>/admin/login</code> covers draft URLs and <code>Lib\Access</code>-gated pages too.</p>
|
||||
|
||||
<p>Templates can read the <code>admin_auth_enabled</code> Twig global (it mirrors the config flag) — <code>admin/index.twig</code> uses it to show the "Admin users" and "Logout" links only when the feature is on. It's a display flag only; enforcement never depends on Twig.</p>
|
||||
|
||||
<h2>Logging out</h2>
|
||||
|
||||
<p>HTTP Basic Auth has no real server-side logout — the browser just keeps resending the same cached credentials on every request to that realm. Visiting <code>/admin/logout</code> works around this: <code>AdminAuth::logout()</code> always issues a fresh <code>401</code> challenge, regardless of what credentials were sent, which makes the browser discard what it had cached and prompt again the next time <code>/admin</code> is visited. Credentials themselves aren't invalidated server-side (there's nothing to invalidate — it's just a password check on every request), so this is a client-side-only logout, same as any Basic Auth site. The "Logout" link only appears on <code>/admin</code> when <code>admin_auth_enabled</code> is true (i.e. a password is actually set).</p>
|
||||
<p><a href="/admin/logout">/admin/logout</a> is a real server-side logout now (its Basic Auth predecessor could only trick the browser into forgetting cached credentials with a fresh <code>401</code>): a POST — with a small GET confirm form, same shape as <code>/admin/clear-cache</code> — that drops the logged-in user id from the session, regenerates the session id, and redirects to the login form. It's deliberately not logout-on-GET: sidecars must stay side-effect-free on GET, both as ordinary HTTP hygiene and because the <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}content index</a>'s crawl invokes every page's sidecar the way a real GET would — a logout-on-GET would end the crawling admin's own session the moment a lazy reindex rendered this page.</p>
|
||||
|
||||
<h2>What this is (and isn't)</h2>
|
||||
|
||||
<p>This is HTTP Basic Auth against a single username/password pair in config — no sessions, no user table, no password reset, no multiple accounts. It's a deliberate stopgap: see <code>novaconium/ISSUES.md</code>'s "Admin login & user management" entry for the planned real multi-user system (backed by SQLite, with proper sessions). That feature will <em>replace</em> this mechanism, not layer on top of it. Until then, this is enough to keep the general public out of <code>/admin</code> on a production site.</p>
|
||||
|
||||
<p>Basic Auth credentials are sent base64-encoded on every request (not encrypted) — always serve <code>/admin</code> over HTTPS in production, same as any password-protected page.</p>
|
||||
<p>This is authentication plus a deliberately small authorization model: two roles and one group label per user, matched by <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}<code>Lib\Access</code></a> rules in sidecars — no permissions matrix, no role hierarchy, no per-admin capability flags. Registration is admin-driven only; there's no self-serve sign-up form. Sessions are PHP's native ones via <code>Lib\Session</code>, with the same cookie hardening it always applies (<code>httponly</code>, <code>SameSite=Lax</code>, <code>secure</code> on HTTPS). As with any password form, serve <code>/admin</code> over HTTPS in production.</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
|
||||
<p>If a page has <strong>no</strong> sidecar, its rendered HTML is written to <code>public/cache/<path>/index.html</code> after the first request. <code>.htaccess</code> checks for that file before PHP ever runs, so repeat visits are served straight by Apache with zero PHP/Twig overhead. Pages with a sidecar are never cached this way, since their output can vary per request.</p>
|
||||
|
||||
<p>Two kinds of route are excluded from the cache unconditionally, regardless of whether they have a sidecar: every <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft page</a> and every <code>/admin/*</code> route. Both are gated by <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}HTTP Basic Auth</a>, and a cached copy would bypass that check entirely — <code>.htaccess</code> serves a cached file before PHP (and therefore any auth check) ever runs, so a cached admin or draft page would be served to anyone, unauthenticated, forever after the first authenticated view. See <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> for the full write-up.</p>
|
||||
<p>Two kinds of route are excluded from the cache unconditionally, regardless of whether they have a sidecar: every <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft page</a> and every <code>/admin/*</code> route. Both are gated by the <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin login</a>, and a cached copy would bypass that check entirely — <code>.htaccess</code> serves a cached file before PHP (and therefore any auth check) ever runs, so a cached admin or draft page would be served to anyone, unauthenticated, forever after the first authenticated view. See <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> for the full write-up.</p>
|
||||
|
||||
<p>To force a single page to re-render, delete its file under <code>public/cache/</code>. To clear everything at once, there are two equivalent options:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>CLI:</strong> <code>php novaconium/bin/clear-cache.php</code> — a standalone script for deploys, cron jobs, or anywhere you'd rather not go through a browser. Prints <code>Cache cleared.</code> and exits.</li>
|
||||
<li><strong>Web:</strong> <a href="/admin/clear-cache">/admin/clear-cache</a> — a POST form under <code>/admin</code>, covered by the same <a href="/admin/docs/admin-auth">HTTP Basic Auth gate</a> as the rest of <code>/admin/*</code> once a password is set.</li>
|
||||
<li><strong>Web:</strong> <a href="/admin/clear-cache">/admin/clear-cache</a> — a POST form under <code>/admin</code>, covered by the same <a href="/admin/docs/admin-auth">admin login gate</a> as the rest of <code>/admin/*</code> once admin auth is enabled.</li>
|
||||
</ul>
|
||||
|
||||
<p>Both end up calling the same underlying <code>Cache::clear()</code> — see <code>/admin/docs/config</code>'s "For developers: using <code>Cache.php</code> directly" section for how each entry point constructs it. Clearing the cache only deletes the generated static HTML; it doesn't affect <code>App/pages/</code> or any other source. Any project change that should show up on an already-cached page — a new <code>site_name</code>, a new Sass color, a new admin toggle — needs a cache clear before it's visible, since the old <code>index.html</code> would otherwise keep being served as-is.</p>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% block docs_content %}
|
||||
<h1>Configuration</h1>
|
||||
|
||||
<p><code>novaconium/config.php</code> holds the framework defaults — <code>pages_dirs</code>, <code>cache_dir</code>, <code>debug</code>, <code>site_name</code>, <code>matomo_url</code>, <code>matomo_site_id</code>, <code>admin_username</code>, <code>admin_password_hash</code>, <code>db_connections</code>, <code>draft_routes</code> — and is not meant to be edited per-project, same as everything else under <code>novaconium/</code>.</p>
|
||||
<p><code>novaconium/config.php</code> holds the framework defaults — <code>pages_dirs</code>, <code>cache_dir</code>, <code>debug</code>, <code>site_name</code>, <code>matomo_url</code>, <code>matomo_site_id</code>, <code>admin_auth_enabled</code>, <code>db_connections</code>, <code>draft_routes</code> — and is not meant to be edited per-project, same as everything else under <code>novaconium/</code>.</p>
|
||||
|
||||
<p><code>App/config.php</code> ships with the skeleton as an empty, commented placeholder — uncomment (or add) whichever keys you want to change, returning an array of just those:</p>
|
||||
|
||||
@@ -37,11 +37,11 @@ return [
|
||||
|
||||
<h2>Admin authentication</h2>
|
||||
|
||||
<p><code>admin_username</code> / <code>admin_password_hash</code> gate every <code>/admin/*</code> route behind HTTP Basic Auth — see <a href="/admin/docs/admin-auth">Admin authentication</a> for the full write-up. Both are set via <code>App/config.php</code>; leaving <code>admin_password_hash</code> empty (the default) disables the gate.</p>
|
||||
<p><code>admin_auth_enabled</code> (default <code>false</code>) gates every <code>/admin/*</code> route behind a session login against the <code>users</code> table — see <a href="/admin/docs/admin-auth">Admin authentication</a> for the full write-up, including roles (the first user is the admin; the rest are registered) and how the first user gets created. The same flag powers <a href="/admin/docs/access-control">Access control</a> (<code>Lib\Access</code>) for member/group-gated content. When left off, <code>/admin/*</code> is open, the login/users routes <code>404</code>, and <code>Access::require()</code> allows everything.</p>
|
||||
|
||||
<h2>Draft pages</h2>
|
||||
|
||||
<p><code>draft_routes</code> (default <code>[]</code>) is a list of routes only an authenticated admin can see — everyone else gets a plain <code>404</code>. Requires <code>admin_password_hash</code> above to be set to actually gate anything. See <a href="/admin/docs/drafts">Draft pages</a> for the full write-up, including why a cached draft page would be a security problem and how that's avoided.</p>
|
||||
<p><code>draft_routes</code> (default <code>[]</code>) is a list of routes only an authenticated admin can see — everyone else gets a plain <code>404</code>. Requires <code>admin_auth_enabled</code> above (and at least one user) to actually gate anything. See <a href="/admin/docs/drafts">Draft pages</a> for the full write-up, including why a cached draft page would be a security problem and how that's avoided.</p>
|
||||
|
||||
<h2>For developers: using <code>Cache.php</code> directly</h2>
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'admin_username' => 'admin',
|
||||
'admin_password_hash' => '$2y$10$...',
|
||||
'admin_auth_enabled' => true,
|
||||
'draft_routes' => ['blog/upcoming-post'],
|
||||
];</code></pre>
|
||||
|
||||
@@ -25,9 +24,9 @@ return [
|
||||
|
||||
<h2>Not a login prompt</h2>
|
||||
|
||||
<p>An unauthenticated visitor to a draft route gets the site's normal 404 page — not a <code>401</code> Basic Auth challenge like <code>/admin/*</code> gives. This is deliberate: prompting for a login would itself reveal that something is gated at that URL. A draft is indistinguishable from a URL that was never routable in the first place.</p>
|
||||
<p>An unauthenticated visitor to a draft route gets the site's normal 404 page — not a redirect to the login form like <code>/admin/*</code> gives. This is deliberate: bouncing to a login would itself reveal that something is gated at that URL. A draft is indistinguishable from a URL that was never routable in the first place.</p>
|
||||
|
||||
<p>There's no separate login flow for drafts, and none is needed — <code>AdminAuth::isAuthenticated()</code> (the same credential check <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a>'s <code>requireLogin()</code> uses) is reused directly. In practice, an admin authenticates once by visiting <code>/admin</code> and entering credentials there; HTTP Basic Auth credentials are scoped to the whole origin/realm, not a single path, so the browser then resends those same credentials automatically on later requests to a draft URL too, without a second prompt.</p>
|
||||
<p>There's no separate login flow for drafts, and none is needed — <code>AdminAuth::isAdmin()</code> (the same check <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a>'s admin gate uses) is reused directly, so drafts are admin-only: a logged-in <em>registered</em> user gets the same 404 as everyone else (previewing unpublished work is a site-running privilege, not a membership perk — use <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}<code>Lib\Access</code></a> for members-only content). In practice, an admin logs in once at <code>/admin/login</code>; the session cookie is scoped to the whole origin, not a single path, so it covers later requests to a draft URL too. With <code>admin_auth_enabled</code> left off (or while no users exist yet), drafts are open to everyone, consistent with the rest of <code>/admin/*</code>.</p>
|
||||
|
||||
<h2>The caching interaction</h2>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% block docs_content %}
|
||||
<h1>Getting started</h1>
|
||||
|
||||
<p><strong>Requirements:</strong> PHP 8.1+ (uses <code>readonly</code> constructor-promoted properties) and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>. The <a href="/admin/docs/database">Database</a>/<a href="/admin/docs/content-index">Content index</a> features are optional and off by default — see <a href="/admin/docs">Overview</a> for the extensions they need (<code>pdo_sqlite</code>, optionally <code>pdo_mysql</code>/FTS5) if you turn them on.</p>
|
||||
<p><strong>Requirements:</strong> PHP 8.1+ (uses <code>readonly</code> constructor-promoted properties) and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>. The <a href="/admin/docs/database">Database</a>/<a href="/admin/docs/content-index">Content index</a>/<a href="/admin/docs/admin-auth">Admin authentication</a> features are optional and off by default — see <a href="/admin/docs">Overview</a> for the extensions they need (<code>pdo_sqlite</code>, optionally <code>pdo_mysql</code>/FTS5) if you turn them on.</p>
|
||||
|
||||
<h2>Run it locally (no Apache needed)</h2>
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
<li><a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}Content index</a> — the shared crawler behind <code>/sitemap.xml</code>, <code>/search</code>, and blog tag browsing. Off by default.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a> — per-page <code>changefreq</code>/<code>priority</code>, what's included, and submitting it to search engines.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a> — <code>Lib\Rss</code>, building one feed or several for any content collection.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind HTTP Basic Auth, reusable for any future admin page.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind a session login, with admin/registered roles, groups, and user management.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a> — assign a page or section to a user or group from its sidecar with <code>Lib\Access</code>; public by default, static pages always public.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>
|
||||
|
||||
@@ -26,5 +26,9 @@
|
||||
<li><code>Lib\Validate</code> — the lower-level validation primitives <code>FormValidator</code> calls into (<code>isEmail()</code>, <code>minLength()</code>/<code>maxLength()</code>, <code>isMatch()</code>, <code>isPhone()</code>, <code>isPostalCode()</code>/<code>isZipCode()</code>) — call these directly from a sidecar when you just need a validated/normalized value back rather than an accumulated field-error. See <a href="/admin/docs/sidecars">Sidecars</a>' "Spam prevention" section.</li>
|
||||
<li><code>Lib\Input</code> — a cleaning accessor for <code>$_POST</code>/<code>$_GET</code> (<code>Input::post()</code>/<code>Input::get()</code>), used by every sidecar instead of the superglobals directly. Defense-in-depth against HTML/script injection, <strong>not</strong> a defense against SQL injection — see <a href="/admin/docs/sidecars">Sidecars</a>' "Form security" section for the full caveat.</li>
|
||||
<li><code>Lib\Csrf</code> — standalone session-token CSRF protection (<code>Csrf::token()</code>/<code>::verify()</code>), called directly from a sidecar rather than through <code>FormValidator</code>. See <a href="/admin/docs/sidecars">Sidecars</a>' "Form security" section.</li>
|
||||
<li><code>Lib\Db</code> — the thin no-ORM PDO wrapper behind everything database-backed here. See <a href="/admin/docs/database">Database</a>.</li>
|
||||
<li><code>Lib\Session</code> — native PHP sessions with a consistent get/set/flash API. See <a href="/admin/docs/session">Session</a>.</li>
|
||||
<li><code>Lib\Access</code> — sidecar-level access control: assign a page to a user or group (<code>Access::require('group:members')</code>). See <a href="/admin/docs/access-control">Access control</a>.</li>
|
||||
<li><code>Lib\Rss</code> — a generic RSS 2.0 envelope builder. See <a href="/admin/docs/rss">RSS feeds</a>.</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
@@ -44,9 +44,8 @@ novaconium/ the framework itself — boilerplate, not meant to be edite
|
||||
<ol>
|
||||
<li>Requires <strong><code>novaconium/autoload.php</code></strong>, registering the <code>Twig\</code>/<code>App\</code>/<code>Lib\</code> class autoloader (see <a href="/admin/docs/libraries">Libraries</a>) before anything below tries to instantiate a class.</li>
|
||||
<li>Requires <strong><code>novaconium/config.php</code></strong>, then shallow-merges <strong><code>App/config.php</code></strong> over it if that file exists — see <a href="/admin/docs/config">Configuration</a>.</li>
|
||||
<li>Special-cases <code>/admin/logout</code> directly against the request path — see <a href="/admin/docs/admin-auth">Admin authentication</a> — since it isn't a real page for the router to find.</li>
|
||||
<li>Constructs a <strong><code>Router</code></strong> (<code>novaconium/src/Router.php</code>) and calls <code>resolve()</code> to turn the URL into a <strong><code>Route</code></strong> (<code>novaconium/src/Route.php</code>) — see <a href="/admin/docs/routing">Routing</a>.</li>
|
||||
<li>If the resolved route is under <code>admin</code>/<code>admin/*</code>, calls <strong><code>AdminAuth::requireLogin()</code></strong> (<code>novaconium/src/AdminAuth.php</code>), reading that same <code>Route</code>.</li>
|
||||
<li>If the resolved route is under <code>admin</code>/<code>admin/*</code> (except <code>admin/login</code> itself), calls <strong><code>AdminAuth::requireLogin()</code></strong> (<code>novaconium/src/AdminAuth.php</code>), reading that same <code>Route</code>.</li>
|
||||
<li>Constructs a <strong><code>Cache</code></strong> (<code>novaconium/src/Cache.php</code>) and a <strong><code>Renderer</code></strong> (<code>novaconium/src/Renderer.php</code>), then calls <code>renderNotFound()</code> or <code>render($route, ...)</code> depending on <code>$route->found</code> — see <a href="/admin/docs/sidecars">Sidecars</a> for what happens inside <code>Renderer</code> itself (running the matched directory's <code>index.php</code>, if any; resolving the nearest layout; rendering <code>index.twig</code>; writing the static cache for sidecar-less pages).</li>
|
||||
</ol>
|
||||
</li>
|
||||
|
||||
@@ -37,9 +37,8 @@
|
||||
|
||||
<ol>
|
||||
<li>Config loads (framework defaults + optional <code>App/config.php</code> override).</li>
|
||||
<li><code>/admin/logout</code> is special-cased directly against the raw request path, before routing even runs — it isn't a real page, so there'd be no <code>Route</code> for it anyway.</li>
|
||||
<li><strong><code>Router::resolve()</code> runs</strong> and returns a <code>Route</code> — this is the only place a <code>Route</code> gets created.</li>
|
||||
<li>If <code>$route->dir</code> is under <code>admin</code>/<code>admin/*</code>, <code>AdminAuth::requireLogin()</code> gates it — reading <code>$route->dir</code> directly off the value object <code>Router</code> handed back.</li>
|
||||
<li>If <code>$route->dir</code> is under <code>admin</code>/<code>admin/*</code> (except <code>admin/login</code>, which must stay reachable logged-out), <code>AdminAuth::requireLogin()</code> gates it — reading <code>$route->dir</code> directly off the value object <code>Router</code> handed back.</li>
|
||||
<li><code>Renderer</code> takes over, also just reading the same <code>Route</code>: <code>renderNotFound()</code> if <code>$route->found</code> is <code>false</code>, otherwise <code>render($route, ...)</code> — using <code>$route->dir</code> to find the sidecar/layout/template and <code>$route->params</code> as template context.</li>
|
||||
</ol>
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ Session::remove('user_id');</code></pre>
|
||||
|
||||
<p>The session is started lazily — nothing calls <code>session_start()</code> until the first real call to any <code>Session</code> method, so a page that never touches <code>Session</code> never gets a session cookie. <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\Csrf</a> uses the exact same lazy-start mechanism to run its own session-token CSRF protection — both classes can touch the same native session in the same request without conflict, since <code>session_start()</code> is only ever actually called once (PHP no-ops a second call).</p>
|
||||
|
||||
<p><code>Session::regenerate()</code> swaps the session id for a fresh one while keeping the session's data — call it on any privilege change, so a session id an attacker planted or observed before the change is worthless after it (session fixation). <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> does exactly this on login and logout.</p>
|
||||
|
||||
<h2>Flash data</h2>
|
||||
|
||||
<p>A flashed value is readable on exactly the next request, then gone — useful for post/redirect/GET flows (a "message sent" banner after a redirect) without a query-string flag like <code>?sent=1</code>:</p>
|
||||
|
||||
@@ -65,7 +65,9 @@ $all = Input::post(); // the whole cleaned $_POST array</code><
|
||||
|
||||
<p><strong>This is not SQL-injection protection.</strong> The cleaning <code>Input</code> does (trim + strip tags, via <code>Lib\Validate::clean()</code>, plus null-byte stripping) is defense-in-depth against HTML/script injection in output contexts — Twig already autoescapes <code>{{ '{{ }}' }}</code> output by default (see <code>novaconium/src/Renderer.php</code>), so this is a second layer, not the only one. No string transform makes arbitrary input safe to concatenate into a SQL query; the real defense is parameterized queries. <a href="/admin/docs/database">Lib\Db</a>'s <code>query()</code> method uses PDO prepared statements exclusively for exactly this reason. <code>Input</code> deliberately has no <code>sqlSafe()</code>-style method, since a method implying "cleaned = safe to interpolate into SQL" would be actively dangerous.</p>
|
||||
|
||||
<p>One documented exception: a field that needs an exact, unmodified value — a password about to be hashed, say — should read <code>$_POST</code> directly instead of going through <code>Input::post()</code>. Cleaning would silently strip characters like <code><</code>/<code>></code> before hashing, producing a hash that doesn't match what's actually typed later. See <code>novaconium/pages/admin/password-hash/index.php</code> for the one place this framework does that on purpose.</p>
|
||||
<p>One documented exception: a field that needs an exact, unmodified value — a password about to be hashed or verified, say — should read <code>$_POST</code> directly instead of going through <code>Input::post()</code>. Cleaning would silently strip characters like <code><</code>/<code>></code> before hashing, producing a hash that doesn't match what's actually typed later (or failing a login whose password actually matches). See the password fields in <code>novaconium/pages/admin/users/index.php</code> and <code>novaconium/pages/admin/login/index.php</code> for the places this framework does that on purpose.</p>
|
||||
|
||||
<p>A sidecar is also where a page gets assigned to a user or group: one <code>Lib\Access</code> call at the top, returning its <code>Response</code> when access is denied — see <a href="/admin/docs/access-control">Access control</a>.</p>
|
||||
|
||||
<h3>CSRF protection</h3>
|
||||
|
||||
@@ -140,7 +142,7 @@ $phone = Validate::isPhone($old['phone']); // '5551234567' or false</code></pr
|
||||
|
||||
<p>All three classes live under <code>novaconium/lib/</code> as framework defaults — like any other <code>Lib\</code> class, a project can override any of them by dropping a same-named file in <code>App/lib/</code> (see <a href="/admin/docs/libraries">Libraries</a>).</p>
|
||||
|
||||
<h2>Copy-paste starter: a sidecar, three ways</h2>
|
||||
<h2>Copy-paste starter: a sidecar, four ways</h2>
|
||||
|
||||
<p>Drop this in as <code>App/pages/example/index.php</code> (next to an <code>App/pages/example/index.twig</code> using the <a href="/admin/docs/seo">SEO starter template</a>) and delete whichever example you don't need:</p>
|
||||
|
||||
@@ -166,9 +168,26 @@ return [
|
||||
//
|
||||
// ob_start();
|
||||
// phpinfo();
|
||||
// return Response::html(ob_get_clean());{% endverbatim %}</code></pre>
|
||||
// return Response::html(ob_get_clean());
|
||||
|
||||
<p>Only one of the three <code>return</code>s in a real sidecar ever runs, obviously — pick one, or branch between them with an <code>if</code>. The IP example works with or without a sidecar-only page (no <code>index.twig</code>); the <code>phpinfo()</code> example needs <em>no</em> <code>index.twig</code> at all, since <code>Response::html()</code> bypasses Twig — see the JSON-only example above for another sidecar-only page.</p>
|
||||
// 4. Require a login — gate the page to a group, a user, or any
|
||||
// logged-in account before doing anything else. Access::require()
|
||||
// returns null when the visitor may proceed, or a ready-made Response
|
||||
// (a login redirect that comes back here afterwards, or a 404 for the
|
||||
// wrong account) for you to return as-is. Needs admin_auth_enabled and
|
||||
// at least one user — see /admin/docs/access-control for the rules and
|
||||
// /admin/docs/admin-auth for accounts and groups.
|
||||
// use Lib\Access;
|
||||
//
|
||||
// if ($denied = Access::require('group:members')) {
|
||||
// return $denied;
|
||||
// }
|
||||
//
|
||||
// return [
|
||||
// 'message' => 'Hello, member!',
|
||||
// ];{% endverbatim %}</code></pre>
|
||||
|
||||
<p>Only one of the numbered <code>return</code>s in a real sidecar ever runs, obviously — pick one, or branch between them with an <code>if</code>. The IP example works with or without a sidecar-only page (no <code>index.twig</code>); the <code>phpinfo()</code> example needs <em>no</em> <code>index.twig</code> at all, since <code>Response::html()</code> bypasses Twig — see the JSON-only example above for another sidecar-only page. The login gate in example 4 isn't really an alternative to the other three — it's a first line that composes with any of them: gate first, then return whatever the page normally would. Swap the rule for <code>Access::require('user:bob')</code>, several rules (any one grants access), or no rules at all for "anyone logged in"; admins always pass.</p>
|
||||
|
||||
<p><strong>Never ship <code>phpinfo()</code> to production</strong> — it dumps environment variables, file paths, loaded extensions, and configuration values that are useful to an attacker mapping your server. Delete the page after you're done with it, or at minimum gate it behind <a href="/admin/docs/admin-auth">admin authentication</a> the same way <code>/admin/*</code> already is, so it's never reachable by the public.</p>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<ul>
|
||||
<li><a class="icon-link" href="/admin/clear-cache">{{ icons.trash() }}Clear cache</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Project docs</a></li>
|
||||
<li><a class="icon-link" href="/admin/password-hash">{{ icons.lock() }}Generate admin password hash</a></li>
|
||||
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/users">{{ icons.users() }}Users</a></li>{% endif %}
|
||||
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/logout">{{ icons.external_link() }}Logout</a></li>{% endif %}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\AdminAuth;
|
||||
use App\Response;
|
||||
use Lib\Csrf;
|
||||
use Lib\Input;
|
||||
use Lib\Session;
|
||||
|
||||
// Same two-step config load bootstrap.php/bin scripts use — this sidecar
|
||||
// isn't handed $config, so it loads its own copy to read
|
||||
// admin_auth_enabled before touching Lib\Db at all (same pattern as
|
||||
// /search reading content_index_enabled).
|
||||
$config = require __DIR__ . '/../../../config.php';
|
||||
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
// Admin auth is off by default (depends on SQLite) — see
|
||||
// /admin/docs/admin-auth. When it's off there's nothing to log in to:
|
||||
// this route must 404 exactly like a page that doesn't exist, and never
|
||||
// construct a Lib\Db connection (which would otherwise create
|
||||
// data/novaconium.sqlite just because this file exists, even on a site
|
||||
// that never opted in).
|
||||
if (!$config['admin_auth_enabled']) {
|
||||
return Response::html('404 Not Found', 404);
|
||||
}
|
||||
|
||||
// This is the one /admin/* route bootstrap.php exempts from the admin
|
||||
// gate — it has to be reachable logged-out, or the redirect here would
|
||||
// loop. It serves registered users too, not just admins: Lib\Access (see
|
||||
// /admin/docs/access-control) sends anyone hitting a gated page here.
|
||||
|
||||
// Where to go after a successful login. Lib\Access passes the gated
|
||||
// page's path along as ?return=, carried through the form as a hidden
|
||||
// field. Local paths only — must start with '/' but not '//' (a
|
||||
// protocol-relative URL) and contain no backslash — so a crafted login
|
||||
// link can never bounce someone to another site after they've typed
|
||||
// their password here.
|
||||
$sanitizeReturn = static function (?string $path): ?string {
|
||||
if ($path === null || $path === '' || $path[0] !== '/') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '//') || str_contains($path, '\\')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $path;
|
||||
};
|
||||
|
||||
// Where a login (or an already-logged-in visit) lands when there's no
|
||||
// return path: admins on the admin panel, registered users on the
|
||||
// homepage (there's nothing for them under /admin — it 404s). During the
|
||||
// zero-users setup window, the admin panel — that's where the "create
|
||||
// the first user" path starts.
|
||||
$defaultTarget = static function (): string {
|
||||
if (!AdminAuth::hasUsers()) {
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
$user = AdminAuth::currentUser();
|
||||
|
||||
return $user !== null && $user['role'] === 'admin' ? '/admin' : '/';
|
||||
};
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$return = $sanitizeReturn(Input::post('return'));
|
||||
$failureUrl = '/admin/login' . ($return !== null ? '?return=' . rawurlencode($return) : '');
|
||||
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
Session::flash('login_error', 'Your session expired before submitting — please try again.');
|
||||
|
||||
return Response::redirect($failureUrl, 303);
|
||||
}
|
||||
|
||||
// Password read directly, not via Input::post() — cleaning would
|
||||
// silently strip characters like < and > before password_verify(),
|
||||
// failing a login the password actually matches. Same documented
|
||||
// exception /admin/users makes when hashing — see
|
||||
// /admin/docs/sidecars' "Form security" section.
|
||||
$ok = AdminAuth::attempt(
|
||||
(string) Input::post('username', ''),
|
||||
(string) ($_POST['password'] ?? '')
|
||||
);
|
||||
|
||||
if ($ok) {
|
||||
return Response::redirect($return ?? $defaultTarget(), 303);
|
||||
}
|
||||
|
||||
Session::flash('login_error', 'Wrong username or password.');
|
||||
|
||||
return Response::redirect($failureUrl, 303);
|
||||
}
|
||||
|
||||
// Already logged in (or the gate is open because no users exist yet) —
|
||||
// the form is pointless, move along.
|
||||
if (AdminAuth::isLoggedIn(true)) {
|
||||
return Response::redirect($defaultTarget(), 303);
|
||||
}
|
||||
|
||||
return [
|
||||
'return' => $sanitizeReturn(Input::get('return')),
|
||||
'error' => Session::getFlash('login_error'),
|
||||
'notice' => Session::getFlash('admin_notice'),
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Log in{% endblock %}
|
||||
|
||||
{% block description %}Log in to your account.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1 class="icon-heading">{{ icons.lock() }}Log in</h1>
|
||||
|
||||
{% if notice %}
|
||||
<p><strong>{{ notice }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<p><strong>{{ error }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/login">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
{% if return %}<input type="hidden" name="return" value="{{ return }}">{% endif %}
|
||||
<p>
|
||||
<label for="username">Username</label><br>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required>
|
||||
</p>
|
||||
<p>
|
||||
<label for="password">Password</label><br>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</p>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use App\AdminAuth;
|
||||
use App\Response;
|
||||
use Lib\Csrf;
|
||||
use Lib\Input;
|
||||
use Lib\Session;
|
||||
|
||||
// Same two-step config load as /admin/login — see that sidecar. 404 when
|
||||
// admin auth is off so this route has zero footprint (no session cookie,
|
||||
// no Lib\Db touch) on a site that never opted in.
|
||||
$config = require __DIR__ . '/../../../config.php';
|
||||
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
if (!$config['admin_auth_enabled']) {
|
||||
return Response::html('404 Not Found', 404);
|
||||
}
|
||||
|
||||
// A real page now, replacing the hardcoded pre-router special case
|
||||
// bootstrap.php needed back when logout meant tricking the browser into
|
||||
// dropping cached Basic Auth credentials. Unlike then, this is a real
|
||||
// server-side logout: the session's user id is gone afterwards, whatever
|
||||
// the browser resends.
|
||||
//
|
||||
// POST-only, with a GET confirm form, same shape as /admin/clear-cache —
|
||||
// NOT a logout-on-GET link. Sidecars must be side-effect-free on GET
|
||||
// (ordinary HTTP hygiene, and the content-index crawl relies on it: it
|
||||
// invokes every page's sidecar the way a real GET would, so a
|
||||
// logout-on-GET here would silently end the crawling admin's own session
|
||||
// the first time a lazy reindex rendered this page).
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
return Response::redirect('/admin/logout?error=security', 303);
|
||||
}
|
||||
|
||||
AdminAuth::logout();
|
||||
Session::flash('admin_notice', 'You have been logged out.');
|
||||
|
||||
return Response::redirect('/admin/login', 303);
|
||||
}
|
||||
|
||||
return [
|
||||
'securityError' => Input::get('error') === 'security',
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Log out{% endblock %}
|
||||
|
||||
{% block description %}Log out of the admin area.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1 class="icon-heading">{{ icons.external_link() }}Log out</h1>
|
||||
<p>Ends your admin session on the server — you'll need to log in again at <a href="/admin/login">/admin/login</a> to get back in.</p>
|
||||
{% if securityError %}
|
||||
<p><strong>Your session expired before submitting — please try again.</strong></p>
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<button type="submit">Log out</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Response;
|
||||
use Lib\Csrf;
|
||||
use Lib\Input;
|
||||
|
||||
$hash = null;
|
||||
$error = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
return Response::redirect('/admin/password-hash?error=security');
|
||||
}
|
||||
|
||||
// Read directly, not via Input::post() — that cleaning would silently
|
||||
// strip characters like < and > before hashing, producing a hash that
|
||||
// doesn't match what's actually typed later at the Basic Auth prompt
|
||||
// (which does zero sanitization).
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
if ($password === '') {
|
||||
$error = 'Enter a password to hash.';
|
||||
} elseif (strlen($password) < 8) {
|
||||
$error = 'Use at least 8 characters.';
|
||||
} else {
|
||||
// Computed once per request and never stored/logged — the page
|
||||
// that renders this is the only place it's ever seen.
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'hash' => $hash,
|
||||
'error' => $error,
|
||||
'securityError' => Input::get('error') === 'security',
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
@@ -1,42 +0,0 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Generate admin password hash{% endblock %}
|
||||
{% block description %}Generate a password_hash() value for admin_password_hash without using the CLI.{% endblock %}
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1 class="icon-heading">{{ icons.lock() }}Generate admin password hash</h1>
|
||||
|
||||
<p>A browser-based alternative to <code>php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT);"</code>. Enter a password, get back the hash <code>App/config.php</code> expects for <code>admin_password_hash</code> — see <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.book() }}Admin authentication</a>. Nothing typed here is stored, logged, or sent anywhere except computed once for this response.</p>
|
||||
|
||||
<form method="post" action="/admin/password-hash">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<p>
|
||||
<label for="password">Password</label><br>
|
||||
<input type="password" id="password" name="password" autocomplete="new-password">
|
||||
</p>
|
||||
<button type="submit">Generate hash</button>
|
||||
</form>
|
||||
|
||||
{% if securityError %}
|
||||
<p><strong>Your session expired before submitting — please try again.</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<p><strong>{{ error }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if hash %}
|
||||
<p>Add this to <code>App/config.php</code>:</p>
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'admin_username' => 'admin',
|
||||
'admin_password_hash' => '{{ hash }}',
|
||||
];</code></pre>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
use App\AdminAuth;
|
||||
use App\Response;
|
||||
use Lib\Csrf;
|
||||
use Lib\Db;
|
||||
use Lib\Input;
|
||||
use Lib\Session;
|
||||
use Lib\Validate;
|
||||
|
||||
// Same two-step config load as /admin/login — see that sidecar. 404 when
|
||||
// admin auth is off, before anything here touches Lib\Db, so the feature
|
||||
// has zero footprint on a site that never opted in.
|
||||
$config = require __DIR__ . '/../../../config.php';
|
||||
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
if (!$config['admin_auth_enabled']) {
|
||||
return Response::html('404 Not Found', 404);
|
||||
}
|
||||
|
||||
// No auth check here — bootstrap.php's admin gate already covers this
|
||||
// route like every other /admin/* page (admins only; registered users get
|
||||
// a 404 there). While zero users exist that gate is deliberately open,
|
||||
// which is exactly what makes creating the *first* user here possible.
|
||||
|
||||
// Disabling or demoting the last active admin would lock everyone out of
|
||||
// /admin/* permanently (the zero-users setup window doesn't reopen — the
|
||||
// table isn't empty), recoverable only via the CLI or editing the
|
||||
// database by hand. Both actions below refuse when this returns 1 and
|
||||
// the target is an active admin.
|
||||
$activeAdminCount = static fn (): int => (int) Db::query(
|
||||
"SELECT COUNT(*) FROM users WHERE is_disabled = 0 AND role = 'admin'"
|
||||
)->fetchColumn();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
Session::flash('users_error', 'Your session expired before submitting — please try again.');
|
||||
|
||||
return Response::redirect('/admin/users', 303);
|
||||
}
|
||||
|
||||
$action = Input::post('action', '');
|
||||
|
||||
if ($action === 'create') {
|
||||
$username = trim((string) Input::post('username', ''));
|
||||
// Validate::isEmail() returns the normalized (trimmed, lowercased)
|
||||
// address, or false — stored normalized so the future
|
||||
// email-verification flow (see novaconium/ISSUES.md) can match
|
||||
// addresses case-insensitively for free.
|
||||
$email = Validate::isEmail((string) Input::post('email', ''));
|
||||
$group = trim((string) Input::post('group', ''));
|
||||
// Passwords read directly, not via Input::post() — cleaning would
|
||||
// silently strip characters like < and > before hashing, producing
|
||||
// a hash that doesn't match what's actually typed at /admin/login
|
||||
// later. See /admin/docs/sidecars' "Form security" section.
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
|
||||
$exists = $username !== ''
|
||||
&& (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)', [$username])->fetchColumn();
|
||||
$emailExists = $email !== false
|
||||
&& (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)', [$email])->fetchColumn();
|
||||
|
||||
if ($username === '' || strlen($username) > 64) {
|
||||
Session::flash('users_error', 'Enter a username (64 characters max).');
|
||||
} elseif ($email === false) {
|
||||
Session::flash('users_error', 'Enter a valid email address.');
|
||||
} elseif ($emailExists) {
|
||||
Session::flash('users_error', 'That email address is already in use.');
|
||||
} elseif (strlen($group) > 64) {
|
||||
Session::flash('users_error', 'Group names are 64 characters max.');
|
||||
} elseif ($exists) {
|
||||
Session::flash('users_error', 'That username is already taken.');
|
||||
} elseif (strlen($password) < 8) {
|
||||
Session::flash('users_error', 'Use a password of at least 8 characters.');
|
||||
} else {
|
||||
// The first user ever created is the admin; everyone after is
|
||||
// a registered user (optionally in a group) — Lib\Access rules
|
||||
// decide what content they can see, and /admin/* 404s for
|
||||
// them. See /admin/docs/admin-auth and
|
||||
// /admin/docs/access-control.
|
||||
$wasFirstUser = !AdminAuth::hasUsers();
|
||||
$role = $wasFirstUser ? 'admin' : 'registered';
|
||||
|
||||
Db::query(
|
||||
'INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)',
|
||||
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $role, $group, gmdate('Y-m-d\TH:i:s\Z')]
|
||||
);
|
||||
|
||||
// Creating the first user is what closes the open setup gate —
|
||||
// log its creator in as it, or their very next request would
|
||||
// bounce them to the login form they were just typing into.
|
||||
if ($wasFirstUser) {
|
||||
AdminAuth::attempt($username, $password);
|
||||
}
|
||||
|
||||
Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created.");
|
||||
}
|
||||
} elseif ($action === 'disable' || $action === 'enable') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$target = Db::query('SELECT id, username, role, is_disabled FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif ($action === 'disable' && (int) $target['is_disabled'] === 0 && $target['role'] === 'admin' && $activeAdminCount() <= 1) {
|
||||
Session::flash('users_error', 'Cannot disable the last active admin — that would lock everyone out of /admin.');
|
||||
} else {
|
||||
Db::query('UPDATE users SET is_disabled = ? WHERE id = ?', [$action === 'disable' ? 1 : 0, $id]);
|
||||
Session::flash('users_notice', sprintf("User \u{201C}%s\u{201D} %sd.", $target['username'], $action));
|
||||
}
|
||||
} elseif ($action === 'role') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$role = Input::post('role', '');
|
||||
$target = Db::query('SELECT id, username, role, is_disabled FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif (!in_array($role, ['admin', 'registered'], true)) {
|
||||
Session::flash('users_error', 'No such role.');
|
||||
} elseif ($role === 'registered' && $target['role'] === 'admin' && (int) $target['is_disabled'] === 0 && $activeAdminCount() <= 1) {
|
||||
Session::flash('users_error', 'Cannot demote the last active admin — that would lock everyone out of /admin.');
|
||||
} else {
|
||||
Db::query('UPDATE users SET role = ? WHERE id = ?', [$role, $id]);
|
||||
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} is now {$role}.");
|
||||
}
|
||||
} elseif ($action === 'delete') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$target = Db::query('SELECT id, username, role, is_disabled FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif ((int) $target['is_disabled'] === 0 && $target['role'] === 'admin' && $activeAdminCount() <= 1) {
|
||||
Session::flash('users_error', 'Cannot delete the last active admin — that would lock everyone out of /admin.');
|
||||
} else {
|
||||
// A hard delete, not a soft one — is_disabled already covers
|
||||
// "keep the account but shut it out", so delete is for
|
||||
// accounts that shouldn't exist at all. Any live session dies
|
||||
// on its next request (currentUser() re-checks the row).
|
||||
Db::query('DELETE FROM users WHERE id = ?', [$id]);
|
||||
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} deleted.");
|
||||
}
|
||||
} elseif ($action === 'email') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$email = Validate::isEmail((string) Input::post('email', ''));
|
||||
$target = Db::query('SELECT id, username FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
$emailExists = $email !== false
|
||||
&& (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE email = ? AND id != ?)', [$email, $id])->fetchColumn();
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif ($email === false) {
|
||||
Session::flash('users_error', 'Enter a valid email address.');
|
||||
} elseif ($emailExists) {
|
||||
Session::flash('users_error', 'That email address is already in use.');
|
||||
} else {
|
||||
Db::query('UPDATE users SET email = ? WHERE id = ?', [$email, $id]);
|
||||
Session::flash('users_notice', "Email changed for \u{201C}{$target['username']}\u{201D}.");
|
||||
}
|
||||
} elseif ($action === 'group') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$group = trim((string) Input::post('group', ''));
|
||||
$target = Db::query('SELECT id, username FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif (strlen($group) > 64) {
|
||||
Session::flash('users_error', 'Group names are 64 characters max.');
|
||||
} else {
|
||||
Db::query('UPDATE users SET user_group = ? WHERE id = ?', [$group, $id]);
|
||||
Session::flash('users_notice', $group === ''
|
||||
? "User \u{201C}{$target['username']}\u{201D} removed from their group."
|
||||
: "User \u{201C}{$target['username']}\u{201D} assigned to group \u{201C}{$group}\u{201D}.");
|
||||
}
|
||||
} elseif ($action === 'password') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
$target = Db::query('SELECT id, username FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif (strlen($password) < 8) {
|
||||
Session::flash('users_error', 'Use a password of at least 8 characters.');
|
||||
} else {
|
||||
Db::query('UPDATE users SET password_hash = ? WHERE id = ?', [password_hash($password, PASSWORD_DEFAULT), $id]);
|
||||
Session::flash('users_notice', "Password changed for \u{201C}{$target['username']}\u{201D}.");
|
||||
}
|
||||
}
|
||||
|
||||
return Response::redirect('/admin/users', 303);
|
||||
}
|
||||
|
||||
return [
|
||||
'users' => Db::query('SELECT id, username, email, role, user_group, is_disabled, created_at FROM users ORDER BY username')->fetchAll(PDO::FETCH_ASSOC),
|
||||
'currentUserId' => AdminAuth::currentUser()['id'] ?? null,
|
||||
'notice' => Session::getFlash('users_notice'),
|
||||
'error' => Session::getFlash('users_error'),
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
@@ -0,0 +1,145 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Users{% endblock %}
|
||||
|
||||
{% block description %}Create, disable, and manage user accounts, roles, and groups.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1 class="icon-heading">{{ icons.users() }}Users</h1>
|
||||
|
||||
<p>Accounts that can log in at <a href="/admin/login">/admin/login</a>. The first user created is the <strong>admin</strong>; everyone after is <strong>registered</strong> — able to log in and see whatever pages <code>Lib\Access</code> assigns to their account or group (see <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a>), but not the admin area. A disabled user can't log in, and any session they already had is locked out on its next request.</p>
|
||||
|
||||
{% if notice %}
|
||||
<p><strong>{{ notice }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<p><strong>{{ error }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if users is empty %}
|
||||
<p><strong>No users exist yet, so the admin area is open to anyone who can reach it.</strong> Create the first user below to close the gate — it becomes the admin account, and you'll be logged in as it automatically.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Group</th>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.username }}{% if user.id == currentUserId %} <em>(you)</em>{% endif %}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.role == 'admin' ? 'Admin' : 'Registered' }}</td>
|
||||
<td>{{ user.user_group ?: '—' }}</td>
|
||||
<td>{{ user.created_at }}</td>
|
||||
<td>{{ user.is_disabled ? 'Disabled' : 'Active' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="id" value="{{ user.id }}">
|
||||
<input type="hidden" name="action" value="{{ user.is_disabled ? 'enable' : 'disable' }}">
|
||||
<button type="submit">{{ user.is_disabled ? 'Enable' : 'Disable' }}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="id" value="{{ user.id }}">
|
||||
<input type="hidden" name="action" value="role">
|
||||
<input type="hidden" name="role" value="{{ user.role == 'admin' ? 'registered' : 'admin' }}">
|
||||
<button type="submit">{{ user.role == 'admin' ? 'Make registered' : 'Make admin' }}</button>
|
||||
</form>
|
||||
<details>
|
||||
<summary>Change email</summary>
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="id" value="{{ user.id }}">
|
||||
<input type="hidden" name="action" value="email">
|
||||
<p>
|
||||
<label for="email-{{ user.id }}">New email address</label><br>
|
||||
<input type="email" id="email-{{ user.id }}" name="email" value="{{ user.email }}" required>
|
||||
</p>
|
||||
<button type="submit">Change email</button>
|
||||
</form>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Change group</summary>
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="id" value="{{ user.id }}">
|
||||
<input type="hidden" name="action" value="group">
|
||||
<p>
|
||||
<label for="group-{{ user.id }}">Group (empty for none)</label><br>
|
||||
<input type="text" id="group-{{ user.id }}" name="group" value="{{ user.user_group }}">
|
||||
</p>
|
||||
<button type="submit">Change group</button>
|
||||
</form>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Change password</summary>
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="id" value="{{ user.id }}">
|
||||
<input type="hidden" name="action" value="password">
|
||||
<p>
|
||||
<label for="password-{{ user.id }}">New password</label><br>
|
||||
<input type="password" id="password-{{ user.id }}" name="password" autocomplete="new-password" required>
|
||||
</p>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Delete</summary>
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="id" value="{{ user.id }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<p>Permanently removes <strong>{{ user.username }}</strong> — there's no undo. To shut an account out but keep it, use Disable instead.</p>
|
||||
<button type="submit">Delete {{ user.username }}</button>
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>Create a user</h2>
|
||||
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<p>
|
||||
<label for="username">Username</label><br>
|
||||
<input type="text" id="username" name="username" autocomplete="off" required>
|
||||
</p>
|
||||
<p>
|
||||
<label for="email">Email address</label><br>
|
||||
<input type="email" id="email" name="email" autocomplete="off" required>
|
||||
</p>
|
||||
<p>
|
||||
<label for="password">Password (at least 8 characters)</label><br>
|
||||
<input type="password" id="password" name="password" autocomplete="new-password" required>
|
||||
</p>
|
||||
{% if users is not empty %}
|
||||
<p>
|
||||
<label for="group">Group (optional — e.g. <code>members</code>, matched by <code>Access::require('group:members')</code>)</label><br>
|
||||
<input type="text" id="group" name="group" autocomplete="off">
|
||||
</p>
|
||||
{% endif %}
|
||||
<button type="submit">Create user</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
+163
-44
@@ -2,76 +2,195 @@
|
||||
|
||||
namespace App;
|
||||
|
||||
use Lib\Db;
|
||||
use Lib\Session;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Reusable HTTP Basic Auth gate for /admin/*. A single check, called once
|
||||
* from bootstrap.php for any route under admin/, so protecting a new admin
|
||||
* page (clear-cache today, anything future) needs no per-page wiring.
|
||||
* Session-based login backed by the `users` table
|
||||
* (novaconium/migrations/0002_create_users.sql) on Lib\Db's default
|
||||
* connection. This replaced the single-user HTTP Basic Auth stopgap that
|
||||
* originally shipped under this same class name — same call sites
|
||||
* (bootstrap.php's admin gate and draft gate), new mechanism, per the
|
||||
* "replace it, don't layer on top of it" plan in novaconium/ISSUES.md.
|
||||
*
|
||||
* Deliberately not a full user system — no sessions, no user table, no
|
||||
* password reset. It's a stopgap until proper multi-user admin login
|
||||
* (see novaconium/ISSUES.md) lands; that feature will replace this, not extend it.
|
||||
* Two roles: 'admin' (full /admin/* access, sees drafts, passes every
|
||||
* Lib\Access rule) and 'registered' (can log in, and sees whatever
|
||||
* content Lib\Access grants their account or their group — see
|
||||
* /admin/docs/access-control — but /admin/* 404s for them). The first
|
||||
* user ever created is the admin; users created after that are
|
||||
* registered, each optionally assigned one group (users.user_group, a
|
||||
* plain text label — no groups table).
|
||||
*
|
||||
* Note: Lib\Csrf (unrelated to login state here) does start a native PHP
|
||||
* session when a form calls it — so "no sessions" above is specifically
|
||||
* about this class's own login check, not a framework-wide guarantee.
|
||||
* Gated by config['admin_auth_enabled'] (default false — same off-by-
|
||||
* default posture as the content index, and for the same reason: this
|
||||
* depends on SQLite, a real dependency plenty of sites won't want).
|
||||
* Every method that touches Lib\Db is only reachable when the flag is
|
||||
* true, so a site that never enables it never gets a data/ database file
|
||||
* created just because this class exists.
|
||||
*
|
||||
* Bootstrap posture while enabled but with zero users yet: open access,
|
||||
* so the first user can be created at /admin/users (or with
|
||||
* `php novaconium/bin/create-admin-user.php`) — the same window the old
|
||||
* Basic Auth had between deciding to enable it and pasting a hash into
|
||||
* App/config.php. Enabling the flag alone protects nothing; creating the
|
||||
* first user is what closes the gate.
|
||||
*/
|
||||
final class AdminAuth
|
||||
{
|
||||
private const SESSION_KEY = '_admin_user_id';
|
||||
|
||||
/**
|
||||
* Exits with a 401 challenge if the request isn't authenticated.
|
||||
* A no-op (auth disabled) when $passwordHash is empty.
|
||||
* Per-request memo for currentUser() — static state never survives
|
||||
* across requests (same guarantee Lib\Session's flash swap relies on),
|
||||
* so this only saves repeat lookups within one request.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public static function requireLogin(string $username, string $passwordHash): void
|
||||
private static ?array $currentUser = null;
|
||||
|
||||
private static bool $currentUserLoaded = false;
|
||||
|
||||
/**
|
||||
* Redirects to /admin/login and exits if nobody is logged in. A no-op
|
||||
* (open access) when $enabled is false, or while no users exist yet
|
||||
* (see the class docblock). bootstrap.php calls this for every
|
||||
* /admin/* route except admin/login itself — which must stay
|
||||
* reachable logged-out, or the redirect would loop — and then
|
||||
* separately 404s admin routes for logged-in non-admins (a registered
|
||||
* user is *authenticated*, so bouncing them back to the login form
|
||||
* would be a lie; what they lack is the admin role).
|
||||
*/
|
||||
public static function requireLogin(bool $enabled): void
|
||||
{
|
||||
if (self::isAuthenticated($username, $passwordHash)) {
|
||||
if (self::isLoggedIn($enabled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
header('WWW-Authenticate: Basic realm="Admin"');
|
||||
http_response_code(401);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "401 Unauthorized\n";
|
||||
http_response_code(303);
|
||||
header('Location: /admin/login');
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential check on its own, with no response side effects —
|
||||
* reused by requireLogin() above (401 challenge on failure) and by
|
||||
* novaconium/bootstrap.php's draft-page gate (see /admin/docs/drafts),
|
||||
* which needs a *different* response on failure: a plain 404, not a
|
||||
* login prompt, so an unauthenticated visitor can't tell a draft
|
||||
* exists at all. Returns true (open access) when $passwordHash is
|
||||
* empty, matching requireLogin()'s existing no-op-when-unset posture —
|
||||
* a draft behaves like the rest of /admin/*: wide open until a
|
||||
* password is configured, gated once one is.
|
||||
* Whether the request has any logged-in user at all, admin or
|
||||
* registered. Returns true (open access) when $enabled is false or no
|
||||
* users exist yet.
|
||||
*/
|
||||
public static function isAuthenticated(string $username, string $passwordHash): bool
|
||||
public static function isLoggedIn(bool $enabled): bool
|
||||
{
|
||||
if ($passwordHash === '') {
|
||||
if (!$enabled || !self::hasUsers()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$providedUser = $_SERVER['PHP_AUTH_USER'] ?? null;
|
||||
$providedPass = $_SERVER['PHP_AUTH_PW'] ?? null;
|
||||
|
||||
return $providedUser === $username
|
||||
&& $providedPass !== null
|
||||
&& password_verify($providedPass, $passwordHash);
|
||||
return self::currentUser() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP Basic Auth has no real server-side "log out" — the browser just
|
||||
* keeps resending the cached credentials. The standard workaround: always
|
||||
* issue a fresh 401 challenge here regardless of what was sent, which
|
||||
* makes the browser discard the credentials it had cached for this realm
|
||||
* and prompt again next time /admin is visited.
|
||||
* Whether the request is by a logged-in admin, with no response side
|
||||
* effects — the check behind /admin/* and the draft-page gate in
|
||||
* novaconium/bootstrap.php (see /admin/docs/drafts), which reacts to
|
||||
* failure with a plain 404, not a login redirect, so an
|
||||
* unauthenticated visitor can't tell a draft exists at all. Returns
|
||||
* true (open access) when $enabled is false or no users exist yet, 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.
|
||||
*/
|
||||
public static function isAdmin(bool $enabled): bool
|
||||
{
|
||||
if (!$enabled || !self::hasUsers()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = self::currentUser();
|
||||
|
||||
return $user !== null && $user['role'] === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a username/password against the users table and, on
|
||||
* success, logs the session in. Disabled users fail exactly like a
|
||||
* wrong password — the response never distinguishes "no such user",
|
||||
* "disabled", and "bad password".
|
||||
*/
|
||||
public static function attempt(string $username, string $password): bool
|
||||
{
|
||||
if ($username === '' || $password === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = Db::query(
|
||||
'SELECT id, username, email, password_hash, role, user_group, is_disabled FROM users WHERE username = ?',
|
||||
[$username]
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($user === false || (int) $user['is_disabled'] === 1 || !password_verify($password, $user['password_hash'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fresh session id on login so a pre-login session id (which an
|
||||
// attacker could have planted or observed) never becomes an
|
||||
// authenticated one — see Session::regenerate().
|
||||
Session::regenerate();
|
||||
Session::set(self::SESSION_KEY, (int) $user['id']);
|
||||
|
||||
unset($user['password_hash']);
|
||||
self::$currentUser = $user;
|
||||
self::$currentUserLoaded = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A real server-side logout (unlike the Basic Auth predecessor's
|
||||
* 401-challenge trick): drop the logged-in user id from the session
|
||||
* and regenerate the session id.
|
||||
*/
|
||||
public static function logout(): void
|
||||
{
|
||||
header('WWW-Authenticate: Basic realm="Admin"');
|
||||
http_response_code(401);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<p>You have been logged out. <a href="/">Return home</a>.</p>';
|
||||
exit;
|
||||
Session::remove(self::SESSION_KEY);
|
||||
Session::regenerate();
|
||||
|
||||
self::$currentUser = null;
|
||||
self::$currentUserLoaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The logged-in user's row (id/username/email/role/user_group/
|
||||
* is_disabled — never the password hash), or null. Re-checked against
|
||||
* the users table on every request, not just at login, so disabling
|
||||
* or deleting a user locks their existing session out on their very
|
||||
* next request — no "still logged in until the session expires"
|
||||
* window.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function currentUser(): ?array
|
||||
{
|
||||
if (self::$currentUserLoaded) {
|
||||
return self::$currentUser;
|
||||
}
|
||||
|
||||
self::$currentUserLoaded = true;
|
||||
|
||||
$userId = Session::get(self::SESSION_KEY);
|
||||
if (!is_int($userId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = Db::query(
|
||||
'SELECT id, username, email, role, user_group, is_disabled FROM users WHERE id = ? AND is_disabled = 0',
|
||||
[$userId]
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
self::$currentUser = $user === false ? null : $user;
|
||||
|
||||
return self::$currentUser;
|
||||
}
|
||||
|
||||
public static function hasUsers(): bool
|
||||
{
|
||||
return (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users)')->fetchColumn();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user