Add email verification for user accounts

Every account created after the first must confirm a 24-hour link
(Lib\Mailer::sendMail(), log-file or MailJet) before AdminAuth::attempt()
allows login, folded into the same generic pass/fail as a disabled account
or wrong password. First user and pre-migration rows are grandfathered;
create-admin-user.php also auto-verifies for lockout recovery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
code
2026-07-15 19:23:20 +00:00
parent c0455241ea
commit e699027b4b
13 changed files with 396 additions and 41 deletions
+9
View File
@@ -51,4 +51,13 @@ return [
// `php novaconium/bin/index-content.php` (e.g. from a deploy step):
// 'content_index_enabled' => true,
// 'content_index_auto' => false,
// Docs: /admin/docs/admin-auth — email verification. Set all four to
// switch Lib\Mailer's transactional mail (verification links, not the
// contact form) from the zero-dependency log fallback to MailJet:
// 'mail_driver' => 'mailjet',
// 'mail_from_email' => 'noreply@example.com',
// 'mail_from_name' => 'Example Site',
// 'mailjet_api_key' => '...',
// 'mailjet_api_secret' => '...',
];
@@ -32,7 +32,7 @@
<li><strong>Layout inheritance</strong> — <code>_layout/layout.twig</code> directories are resolved by walking upward from the matched page, so you can override the layout for a whole subtree.</li>
<li><strong>SEO boilerplate out of the box</strong> — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.</li>
<li><strong>Built-in Matomo analytics</strong> — set <code>matomo_url</code> and <code>matomo_site_id</code> in <code>App/config.php</code> to enable tracking site-wide, including automatic 404 tracking. Off by default.</li>
<li><strong>Admin authentication</strong> — gate every <code>/admin/*</code> route behind a session login with multi-user management: a SQLite-backed <code>users</code> table, <code>/admin/login</code>/<code>/admin/logout</code>, and an <code>/admin/users</code> page to create, disable, delete, group, promote/demote, and change the email or password of accounts (plus a <code>novaconium/bin/create-admin-user.php</code> 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. Enabled with a single <code>admin_auth_enabled</code> flag in <code>App/config.php</code>; off by default.</li>
<li><strong>Admin authentication</strong> — gate every <code>/admin/*</code> route behind a session login with multi-user management: a SQLite-backed <code>users</code> table, <code>/admin/login</code>/<code>/admin/logout</code>, and an <code>/admin/users</code> page to create, disable, delete, group, promote/demote, and change the email or password of accounts (plus a <code>novaconium/bin/create-admin-user.php</code> 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 after the first must verify its email (a link sent via <code>Lib\Mailer</code>, logged to a file by default or sent through MailJet if configured) before it can log in. Enabled with a single <code>admin_auth_enabled</code> flag in <code>App/config.php</code>; off by default.</li>
<li><strong>Access control</strong> — assign a page (or a section, one line per page) to a user or group from its sidecar: <code>Access::require('group:members')</code> returns <code>null</code> or a ready-made <code>Response</code> (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.</li>
<li><strong>Draft pages</strong> — list a route under <code>draft_routes</code> in <code>App/config.php</code> to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt.</li>
<li><strong>Media manager</strong> — <code>/admin/media</code>, an upload/browse/delete UI for files under <code>public/uploads/</code>, covered by the existing <code>/admin/*</code> auth gate with no separate flag needed. Extension allowlist and max upload size are configurable.</li>
+44 -19
View File
@@ -78,25 +78,6 @@ Done) — 2026-07-14 for the first three, 2026-07-15 for Media/file manager.
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.
### In-house comments
- **Type:** Feature
@@ -177,6 +158,50 @@ _Nothing yet._
## Done
### Email verification for user accounts
- **Type:** Feature
- **Status:** Done
- **Priority:** Medium
- **Depends on:** Admin login & user management (Done)
- **Added:** 2026-07-14
- **Shipped:** 2026-07-15
Every user created after the first must click a 24-hour link
(`bin2hex(random_bytes(32))`, same pattern as `Lib\Csrf::token()`) emailed
to their address before `AdminAuth::attempt()` lets them log in at all —
folded into the same generic pass/fail as a disabled account or a wrong
password, no oracle revealing which. Two exemptions, both following the
last-active-admin guard's reasoning that a safety mechanism can't depend on
something that might not be configured: the very first user (auto-verified
at creation, since there's no other admin to vouch for them, and they're
auto-logged-in the same instant) and every row that predates this migration
(`novaconium/migrations/0003_add_users_verification.sql` backfills
`verified_at` from `created_at`). `create-admin-user.php` also auto-
verifies, since lockout recovery can't depend on a working mail transport
either.
`/verify-email` follows `/admin/logout`'s GET-confirms/POST-mutates shape
(the content-index crawl forces a GET on every route, and mail-security
scanners prefetch links — either would burn a GET-mutates token); an
invalid/expired/already-used token never touches the database either way.
Changing an account's email at `/admin/users` resets verification and
re-sends to the new address; `/admin/users` also grew a per-row **Resend
verification** button, visible only when unverified.
`Lib\Mailer` grew a second method, `sendMail()`, for this and future
transactional mail (password-reset-by-email later, per the original spec)
— separate from `send()`, which the contact form keeps using unchanged.
`sendMail()` is driver-dispatched via a new `mail_driver` config key:
`'log'` (default) reuses the same `contact-log.txt` fallback so a fresh
checkout can create and verify accounts with zero external dependency;
`'mailjet'` sends through MailJet's Send API v3.1 (`mailjet_api_key`/
`mailjet_api_secret`/`mail_from_email`/`mail_from_name` in `App/config.php`
— not gitignored, same as every other config key, per the user's decision
that deployer-owned secrets are acceptable here). Adding another provider
later means one more `match` case in `sendMail()` — callers never change.
Documented in `/admin/docs/admin-auth`'s new "Email verification" section.
### Media/file manager
- **Type:** Feature
+8 -3
View File
@@ -65,10 +65,15 @@ if (strlen($password) < 8) {
// 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.
// lockout recovery, both of which need an admin. Auto-verified for the
// same reason /admin/users auto-verifies the very first user: an admin
// created this way has nobody else to have vouched for them, and lockout
// recovery in particular can't depend on a mail transport being
// configured (see /admin/docs/admin-auth's email verification section).
$now = gmdate('Y-m-d\TH:i:s\Z');
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')]
"INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at, verified_at) VALUES (?, ?, ?, 'admin', '', 0, ?, ?)",
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $now, $now]
);
echo "User '{$username}' created.\n";
+20
View File
@@ -114,4 +114,24 @@ return [
// see /admin/docs/media-manager).
'media_upload_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'zip'],
'media_upload_max_bytes' => 10 * 1024 * 1024,
// Lib\Mailer's transactional-mail driver (see /admin/docs/admin-auth's
// email verification section) — separate from Mailer::send(), the
// contact form's own log-to-file stand-in, which this doesn't touch.
// 'log' (default) writes to the same novaconium/contact-log.txt with no
// external dependency, so a fresh checkout with admin_auth_enabled on
// can still create/verify accounts (via the logged link) with zero
// setup. 'mailjet' sends through MailJet's Send API v3.1
// (https://api.mailjet.com/v3.1/send) using the four keys below — set
// all four via App/config.php, e.g.:
// 'mail_driver' => 'mailjet',
// 'mail_from_email' => 'noreply@example.com',
// 'mail_from_name' => 'Example Site',
// 'mailjet_api_key' => '...',
// 'mailjet_api_secret' => '...',
'mail_driver' => 'log',
'mail_from_email' => '',
'mail_from_name' => '',
'mailjet_api_key' => '',
'mailjet_api_secret' => '',
];
+89
View File
@@ -20,4 +20,93 @@ final class Mailer
return true;
}
/**
* Transactional mail (verification links, and later password reset) —
* separate from send() above, which is specifically the contact form's
* "notify the site owner of a submission" shape and stays untouched.
* Driver-dispatched via config['mail_driver'] (see /admin/docs/admin-auth
* and novaconium/config.php): 'log' (default, zero external dependency,
* same novaconium/contact-log.txt as send() above but a distinguishable
* line prefix) or 'mailjet' (Send API v3.1, https://api.mailjet.com/v3.1/send,
* Basic auth mailjet_api_key:mailjet_api_secret). Adding a future
* provider means adding a case here and a private sendVia*() method —
* callers never change.
*/
public function sendMail(string $toEmail, string $subject, string $textBody): bool
{
$config = self::config();
return match ($config['mail_driver']) {
'mailjet' => $this->sendViaMailjet($config, $toEmail, $subject, $textBody),
default => $this->sendViaLog($toEmail, $subject, $textBody),
};
}
private function sendViaLog(string $toEmail, string $subject, string $textBody): bool
{
$line = sprintf(
"[%s] MAIL <%s> %s: %s\n",
date('c'),
$toEmail,
$subject,
str_replace("\n", ' ', $textBody)
);
file_put_contents(__DIR__ . '/../contact-log.txt', $line, FILE_APPEND);
return true;
}
private function sendViaMailjet(array $config, string $toEmail, string $subject, string $textBody): bool
{
$payload = [
'Messages' => [
[
'From' => [
'Email' => $config['mail_from_email'],
'Name' => $config['mail_from_name'],
],
'To' => [
['Email' => $toEmail],
],
'Subject' => $subject,
'TextPart' => $textBody,
],
],
];
$ch = curl_init('https://api.mailjet.com/v3.1/send');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_USERPWD => $config['mailjet_api_key'] . ':' . $config['mailjet_api_secret'],
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 10,
]);
curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $status >= 200 && $status < 300;
}
/**
* Same two-step App-over-novaconium shallow merge every entry point
* duplicates (see novaconium/bootstrap.php, Lib\Db::config()) rather
* than a shared Config class — no such class exists in this codebase.
*/
private static function config(): array
{
$config = require __DIR__ . '/../config.php';
$appConfigFile = __DIR__ . '/../../App/config.php';
if (is_file($appConfigFile)) {
$config = array_merge($config, require $appConfigFile);
}
return $config;
}
}
@@ -0,0 +1,4 @@
ALTER TABLE users ADD COLUMN verified_at TEXT;
ALTER TABLE users ADD COLUMN verification_token TEXT;
ALTER TABLE users ADD COLUMN verification_token_expires_at TEXT;
UPDATE users SET verified_at = created_at WHERE verified_at IS NULL;
@@ -37,7 +37,20 @@ return [
<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>
<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), but every account after the first now must verify it before logging in (see below).</p>
<h2>Email verification</h2>
<p>Every user created after the first must click a link emailed to their address before they can log in at all — <code>AdminAuth::attempt()</code> fails a login the same generic way it fails a disabled account or a wrong password, with no distinction made to an anonymous caller between "wrong password", "disabled", and "unverified" (novaconium/migrations/0003_add_users_verification.sql's <code>verified_at</code> column). Two accounts are exempt by design, both following the same reasoning as the last-active-admin guard elsewhere on this page — verification can't depend on a mail transport actually being configured:</p>
<ul>
<li><strong>The very first user</strong> (created at <a href="/admin/users">/admin/users</a> or via the CLI below) is auto-verified at creation — there's no other admin to have vouched for them, and they're logged in immediately afterward regardless.</li>
<li><strong>Every row that existed before this feature shipped</strong> is grandfathered — the migration backfills <code>verified_at</code> from <code>created_at</code>, so nobody who could already log in gets locked out retroactively.</li>
</ul>
<p>Every other new account gets a 24-hour verification link (<code>bin2hex(random_bytes(32))</code>, the same token pattern <code>Lib\Csrf::token()</code> uses) sent via <code>Lib\Mailer::sendMail()</code> to <code>/verify-email?token=...</code>. That route follows the same GET-confirms/POST-mutates shape as <a href="/admin/logout">/admin/logout</a>, and for the same two reasons: the content-index crawl hits every routable page as a forced GET, and email-security scanners prefetch links before a human clicks — either would burn a GET-mutates link's token. An invalid, already-used, or expired token never touches the database on either method — it just renders an "invalid or expired" page pointing back at <code>/admin/users</code>. Changing an account's email (the <code>email</code> action at <code>/admin/users</code>) resets verification and sends a fresh link to the <em>new</em> address, since an unconfirmed new address shouldn't inherit the old one's verified status — and because <code>AdminAuth::currentUser()</code> re-checks <code>verified_at</code> on every request (the same immediate re-check <code>is_disabled</code> already gets), a live session is locked out on its very next request too, not just future logins.</p>
<p><code>Lib\Mailer::sendMail()</code> is separate from the pre-existing <code>Mailer::send()</code> the contact form uses (unchanged) — it's driver-dispatched via <code>mail_driver</code> in <code>App/config.php</code>: <code>'log'</code> (the default) appends to the same <code>novaconium/contact-log.txt</code> the contact form uses, so a fresh checkout can create and verify accounts with zero external dependency; <code>'mailjet'</code> sends through MailJet's Send API v3.1 using <code>mailjet_api_key</code>/<code>mailjet_api_secret</code> plus <code>mail_from_email</code>/<code>mail_from_name</code>. Adding a different provider later means adding a new case to that dispatch — callers of <code>sendMail()</code> never change.</p>
<h2>Managing users</h2>
+62 -6
View File
@@ -5,6 +5,7 @@ use App\Response;
use Lib\Csrf;
use Lib\Db;
use Lib\Input;
use Lib\Mailer;
use Lib\Session;
use Lib\Validate;
@@ -35,6 +36,32 @@ $activeAdminCount = static fn (): int => (int) Db::query(
"SELECT COUNT(*) FROM users WHERE is_disabled = 0 AND role = 'admin'"
)->fetchColumn();
// Issues a fresh verification token (bin2hex(random_bytes(32)), same
// pattern as Lib\Csrf::token()) for the given user id, emails the link via
// Lib\Mailer::sendMail() (log-file by default, MailJet if configured — see
// /admin/docs/admin-auth), and returns the token for callers that don't
// need it. Used by both the create action (new non-bootstrap users) and
// the resend_verification/email actions below.
$issueVerification = static function (int $id, string $email): void {
$token = bin2hex(random_bytes(32));
$expiresAt = gmdate('Y-m-d\TH:i:s\Z', time() + 86400);
Db::query(
'UPDATE users SET verified_at = NULL, verification_token = ?, verification_token_expires_at = ? WHERE id = ?',
[$token, $expiresAt, $id]
);
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$verifyUrl = "{$scheme}://{$host}/verify-email?token={$token}";
(new Mailer())->sendMail(
$email,
'Verify your account',
"Confirm your email address to activate your account:\n\n{$verifyUrl}\n\nThis link expires in 24 hours."
);
};
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!Csrf::verify(Input::post('csrf_token'))) {
Session::flash('users_error', 'Your session expired before submitting — please try again.');
@@ -83,10 +110,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// /admin/docs/access-control.
$wasFirstUser = !AdminAuth::hasUsers();
$role = $wasFirstUser ? 'admin' : 'registered';
$now = gmdate('Y-m-d\TH:i:s\Z');
// The first user is auto-verified — there's no other admin to
// have vouched for them, and the very next line logs them in
// immediately, which a null verified_at would otherwise block
// (see AdminAuth::attempt() and /admin/docs/admin-auth). Every
// subsequent account starts unverified and gets a verification
// email instead, via $issueVerification below.
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')]
'INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at, verified_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)',
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $role, $group, $now, $wasFirstUser ? $now : null]
);
// Creating the first user is what closes the open setup gate —
@@ -94,9 +128,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 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.");
} else {
$id = (int) Db::query('SELECT id FROM users WHERE username = ?', [$username])->fetchColumn();
$issueVerification($id, $email);
Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created — a verification email was sent to {$email}; they can't log in until it's confirmed.");
}
}
} elseif ($action === 'disable' || $action === 'enable') {
$id = (int) Input::post('id', '0');
@@ -155,8 +192,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} elseif ($emailExists) {
Session::flash('users_error', 'That email address is already in use.');
} else {
// A changed address hasn't been proven owned yet — reset
// verification and send a fresh link to the *new* address,
// rather than silently keeping the old address's verified
// status attached to an unconfirmed one. $issueVerification
// also sets verified_at back to NULL, so the account can't log
// in again until this new address is confirmed.
Db::query('UPDATE users SET email = ? WHERE id = ?', [$email, $id]);
Session::flash('users_notice', "Email changed for \u{201C}{$target['username']}\u{201D}.");
$issueVerification($id, $email);
Session::flash('users_notice', "Email changed for \u{201C}{$target['username']}\u{201D} — a verification email was sent to {$email}; they can't log in again until it's confirmed.");
}
} elseif ($action === 'resend_verification') {
$id = (int) Input::post('id', '0');
$target = Db::query('SELECT id, username, email, verified_at FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
if ($target === false) {
Session::flash('users_error', 'No such user.');
} elseif ($target['verified_at'] !== null) {
Session::flash('users_error', "\u{201C}{$target['username']}\u{201D} is already verified.");
} else {
$issueVerification($id, $target['email']);
Session::flash('users_notice', "Verification email resent to \u{201C}{$target['username']}\u{201D}.");
}
} elseif ($action === 'group') {
$id = (int) Input::post('id', '0');
@@ -192,7 +248,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
return [
'users' => Db::query('SELECT id, username, email, role, user_group, is_disabled, created_at FROM users ORDER BY username')->fetchAll(PDO::FETCH_ASSOC),
'users' => Db::query('SELECT id, username, email, role, user_group, is_disabled, created_at, verified_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'),
+11 -1
View File
@@ -12,7 +12,7 @@
<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>
<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. Every user after the first must also verify their email before they can log in at all — they're sent a verification link on creation (or resend it below), and changing an account's email requires re-verifying the new address.</p>
{% if notice %}
<p><strong>{{ notice }}</strong></p>
@@ -34,6 +34,7 @@
<th>Group</th>
<th>Created</th>
<th>Status</th>
<th>Verified</th>
<th>Actions</th>
</tr>
</thead>
@@ -46,6 +47,7 @@
<td>{{ user.user_group ?: '—' }}</td>
<td>{{ user.created_at }}</td>
<td>{{ user.is_disabled ? 'Disabled' : 'Active' }}</td>
<td>{{ user.verified_at ? 'Verified' : 'Unverified' }}</td>
<td>
<form method="post" action="/admin/users">
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
@@ -53,6 +55,14 @@
<input type="hidden" name="action" value="{{ user.is_disabled ? 'enable' : 'disable' }}">
<button type="submit">{{ user.is_disabled ? 'Enable' : 'Disable' }}</button>
</form>
{% if not user.verified_at %}
<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="resend_verification">
<button type="submit">Resend verification</button>
</form>
{% endif %}
<form method="post" action="/admin/users">
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
<input type="hidden" name="id" value="{{ user.id }}">
+88
View File
@@ -0,0 +1,88 @@
<?php
use App\Response;
use Lib\Csrf;
use Lib\Db;
use Lib\Input;
use Lib\Session;
// Same two-step config load as /admin/login — this sidecar isn't handed
// $config, so it loads its own copy to read admin_auth_enabled before
// touching Lib\Db at all.
$config = require __DIR__ . '/../../config.php';
$appConfigFile = __DIR__ . '/../../../App/config.php';
if (is_file($appConfigFile)) {
$config = array_merge($config, require $appConfigFile);
}
// Nothing to verify against without the users table — 404 exactly like a
// route that doesn't exist, same zero-footprint posture as
// login/logout/users, and never touch Lib\Db on a site that never opted
// in.
if (!$config['admin_auth_enabled']) {
return Response::html('404 Not Found', 404);
}
$findByToken = static function (string $token): array|false {
if ($token === '') {
return false;
}
$row = Db::query(
'SELECT id, username, verification_token_expires_at FROM users WHERE verification_token = ?',
[$token]
)->fetch(PDO::FETCH_ASSOC);
if ($row === false || $row['verification_token_expires_at'] < gmdate('Y-m-d\TH:i:s\Z')) {
return false;
}
return $row;
};
// GET renders an inert confirm page, POST does the mutation — same
// shape as /admin/logout, and for the same two reasons: the content-index
// crawl (ContentIndexer::reindex()) hits every routable page as a forced
// GET, and email-security scanners prefetch links before a human clicks —
// either one would burn the token on a GET-mutates link. A missing,
// wrong, or expired token never touches the database on GET or POST; it
// just renders the "invalid or expired" state.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = (string) Input::post('token', '');
if (!Csrf::verify(Input::post('csrf_token'))) {
return Response::redirect('/verify-email?error=security', 303);
}
$user = $findByToken($token);
if ($user === false) {
return [
'valid' => false,
'token' => '',
'securityError' => false,
'csrfField' => Csrf::fieldName(),
'csrfToken' => Csrf::token(),
];
}
Db::query(
'UPDATE users SET verified_at = ?, verification_token = NULL, verification_token_expires_at = NULL WHERE id = ?',
[gmdate('Y-m-d\TH:i:s\Z'), $user['id']]
);
Session::flash('admin_notice', "Email verified for \u{201C}{$user['username']}\u{201D} — you can log in now.");
return Response::redirect('/admin/login', 303);
}
$token = (string) Input::get('token', '');
$user = $findByToken($token);
return [
'valid' => $user !== false,
'token' => $token,
'securityError' => Input::get('error') === 'security',
'csrfField' => Csrf::fieldName(),
'csrfToken' => Csrf::token(),
];
+30
View File
@@ -0,0 +1,30 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Verify email{% endblock %}
{% block description %}Confirm a new account's email address.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block content %}
<article>
<h1 class="icon-heading">{{ icons.email() }}Verify email</h1>
{% if securityError %}
<p><strong>Your session expired before submitting — please try again.</strong></p>
{% endif %}
{% if valid %}
<p>Click below to confirm this address and finish activating the account.</p>
<form method="post">
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
<input type="hidden" name="token" value="{{ token }}">
<button type="submit">Verify email</button>
</form>
{% else %}
<p><strong>This link is invalid or has expired.</strong> Ask an admin to resend the verification email from <a href="/admin/users">/admin/users</a>.</p>
{% endif %}
</article>
{% endblock %}
+16 -10
View File
@@ -110,9 +110,14 @@ final class AdminAuth
/**
* 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".
* success, logs the session in. Disabled and unverified users fail
* exactly like a wrong password — the response never distinguishes
* "no such user", "disabled", "unverified", and "bad password" (see
* /admin/docs/admin-auth's email verification section). Unset
* verified_at means never verified; the first user ever created and
* every row that existed before this check shipped are backfilled to a
* non-null value (novaconium/migrations/0003_add_users_verification.sql)
* so verification only actually gates accounts created after it.
*/
public static function attempt(string $username, string $password): bool
{
@@ -121,11 +126,11 @@ final class AdminAuth
}
$user = Db::query(
'SELECT id, username, email, password_hash, role, user_group, is_disabled FROM users WHERE username = ?',
'SELECT id, username, email, password_hash, role, user_group, is_disabled, verified_at FROM users WHERE username = ?',
[$username]
)->fetch(PDO::FETCH_ASSOC);
if ($user === false || (int) $user['is_disabled'] === 1 || !password_verify($password, $user['password_hash'])) {
if ($user === false || (int) $user['is_disabled'] === 1 || $user['verified_at'] === null || !password_verify($password, $user['password_hash'])) {
return false;
}
@@ -159,10 +164,11 @@ final class AdminAuth
/**
* 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.
* the users table on every request, not just at login, so disabling,
* deleting, or un-verifying (e.g. an email change reset — see
* /admin/docs/admin-auth) a user locks their existing session out on
* its very next request — no "still logged in until the session
* expires" window.
*
* @return array<string, mixed>|null
*/
@@ -180,7 +186,7 @@ final class AdminAuth
}
$user = Db::query(
'SELECT id, username, email, role, user_group, is_disabled FROM users WHERE id = ? AND is_disabled = 0',
'SELECT id, username, email, role, user_group, is_disabled FROM users WHERE id = ? AND is_disabled = 0 AND verified_at IS NOT NULL',
[$userId]
)->fetch(PDO::FETCH_ASSOC);