diff --git a/App/config.php b/App/config.php index 1d9ff8b..d173efc 100644 --- a/App/config.php +++ b/App/config.php @@ -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' => '...', ]; diff --git a/App/pages/blog/novaconium-features/index.twig b/App/pages/blog/novaconium-features/index.twig index ecb9e50..02194af 100644 --- a/App/pages/blog/novaconium-features/index.twig +++ b/App/pages/blog/novaconium-features/index.twig @@ -32,7 +32,7 @@
  • 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 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. Enabled with a single admin_auth_enabled flag in App/config.php; 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 after the first must verify its email (a link sent via Lib\Mailer, logged to a file by default or sent through MailJet if configured) before it can log in. Enabled with a single admin_auth_enabled flag in App/config.php; off by default.
  • 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.
  • 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.
  • Media manager/admin/media, an upload/browse/delete UI for files under public/uploads/, covered by the existing /admin/* auth gate with no separate flag needed. Extension allowlist and max upload size are configurable.
  • diff --git a/novaconium/ISSUES.md b/novaconium/ISSUES.md index d4fe37e..79bf02c 100644 --- a/novaconium/ISSUES.md +++ b/novaconium/ISSUES.md @@ -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 diff --git a/novaconium/bin/create-admin-user.php b/novaconium/bin/create-admin-user.php index 308b84c..9310299 100644 --- a/novaconium/bin/create-admin-user.php +++ b/novaconium/bin/create-admin-user.php @@ -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"; diff --git a/novaconium/config.php b/novaconium/config.php index 4c03346..1890309 100644 --- a/novaconium/config.php +++ b/novaconium/config.php @@ -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' => '', ]; diff --git a/novaconium/lib/Mailer.php b/novaconium/lib/Mailer.php index bb6e022..be21dab 100644 --- a/novaconium/lib/Mailer.php +++ b/novaconium/lib/Mailer.php @@ -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; + } } diff --git a/novaconium/migrations/0003_add_users_verification.sql b/novaconium/migrations/0003_add_users_verification.sql new file mode 100644 index 0000000..2132147 --- /dev/null +++ b/novaconium/migrations/0003_add_users_verification.sql @@ -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; diff --git a/novaconium/pages/admin/docs/admin-auth/index.twig b/novaconium/pages/admin/docs/admin-auth/index.twig index 5f40952..0501c44 100644 --- a/novaconium/pages/admin/docs/admin-auth/index.twig +++ b/novaconium/pages/admin/docs/admin-auth/index.twig @@ -37,7 +37,20 @@ return [

    The CLI reads the password from stdin (echo suppressed at an interactive prompt, and pipeable from a deploy script), and always creates an admin — it exists for first-user setup and lockout recovery, both of which need one; registered users are created at /admin/users. Running it before flipping admin_auth_enabled on is the safer order — the open setup window then never exists at all.

    -

    The users table ships as a framework migration (novaconium/migrations/0002_create_users.sql) and is created automatically the first time anything touches the default connection — no manual schema step. Passwords are stored as password_hash() hashes and checked with password_verify(); no plaintext, and nothing here ever selects the hash back out except to verify a login. Every account also has a unique email address, stored normalized (trimmed, lowercased, via Lib\Validate::isEmail()) — not used for login (that's the username) or for any mail yet, but required up front so the planned email-verification flow (see novaconium/ISSUES.md) has an address for every account that already exists by then.

    +

    The users table ships as a framework migration (novaconium/migrations/0002_create_users.sql) and is created automatically the first time anything touches the default connection — no manual schema step. Passwords are stored as password_hash() hashes and checked with password_verify(); no plaintext, and nothing here ever selects the hash back out except to verify a login. Every account also has a unique email address, stored normalized (trimmed, lowercased, via Lib\Validate::isEmail()) — not used for login (that's the username), but every account after the first now must verify it before logging in (see below).

    + +

    Email verification

    + +

    Every user created after the first must click a link emailed to their address before they can log in at all — AdminAuth::attempt() 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 verified_at 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:

    + + + +

    Every other new account gets a 24-hour verification link (bin2hex(random_bytes(32)), the same token pattern Lib\Csrf::token() uses) sent via Lib\Mailer::sendMail() to /verify-email?token=.... That route follows the same GET-confirms/POST-mutates shape as /admin/logout, 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 /admin/users. Changing an account's email (the email action at /admin/users) resets verification and sends a fresh link to the new address, since an unconfirmed new address shouldn't inherit the old one's verified status — and because AdminAuth::currentUser() re-checks verified_at on every request (the same immediate re-check is_disabled already gets), a live session is locked out on its very next request too, not just future logins.

    + +

    Lib\Mailer::sendMail() is separate from the pre-existing Mailer::send() the contact form uses (unchanged) — it's driver-dispatched via mail_driver in App/config.php: 'log' (the default) appends to the same novaconium/contact-log.txt the contact form uses, so a fresh checkout can create and verify accounts with zero external dependency; 'mailjet' sends through MailJet's Send API v3.1 using mailjet_api_key/mailjet_api_secret plus mail_from_email/mail_from_name. Adding a different provider later means adding a new case to that dispatch — callers of sendMail() never change.

    Managing users

    diff --git a/novaconium/pages/admin/users/index.php b/novaconium/pages/admin/users/index.php index d77e21a..d3eb20c 100644 --- a/novaconium/pages/admin/users/index.php +++ b/novaconium/pages/admin/users/index.php @@ -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."); } - - Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created."); } } 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'), diff --git a/novaconium/pages/admin/users/index.twig b/novaconium/pages/admin/users/index.twig index 0597e3b..e13cb3b 100644 --- a/novaconium/pages/admin/users/index.twig +++ b/novaconium/pages/admin/users/index.twig @@ -12,7 +12,7 @@

    {{ icons.users() }}Users

    -

    Accounts that can log in at /admin/login. The first user created is the admin; everyone after is registered — able to log in and see whatever pages Lib\Access assigns to their account or group (see {{ icons.lock() }}Access control), 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.

    +

    Accounts that can log in at /admin/login. The first user created is the admin; everyone after is registered — able to log in and see whatever pages Lib\Access assigns to their account or group (see {{ icons.lock() }}Access control), 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.

    {% if notice %}

    {{ notice }}

    @@ -34,6 +34,7 @@ Group Created Status + Verified Actions @@ -46,6 +47,7 @@ {{ user.user_group ?: '—' }} {{ user.created_at }} {{ user.is_disabled ? 'Disabled' : 'Active' }} + {{ user.verified_at ? 'Verified' : 'Unverified' }}
    @@ -53,6 +55,14 @@
    + {% if not user.verified_at %} +
    + + + + +
    + {% endif %}
    diff --git a/novaconium/pages/verify-email/index.php b/novaconium/pages/verify-email/index.php new file mode 100644 index 0000000..872602a --- /dev/null +++ b/novaconium/pages/verify-email/index.php @@ -0,0 +1,88 @@ +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(), +]; diff --git a/novaconium/pages/verify-email/index.twig b/novaconium/pages/verify-email/index.twig new file mode 100644 index 0000000..34e64b6 --- /dev/null +++ b/novaconium/pages/verify-email/index.twig @@ -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 %} +
    +

    {{ icons.email() }}Verify email

    + + {% if securityError %} +

    Your session expired before submitting — please try again.

    + {% endif %} + + {% if valid %} +

    Click below to confirm this address and finish activating the account.

    + + + + + + {% else %} +

    This link is invalid or has expired. Ask an admin to resend the verification email from /admin/users.

    + {% endif %} +
    +{% endblock %} diff --git a/novaconium/src/AdminAuth.php b/novaconium/src/AdminAuth.php index 0194149..f27f07c 100644 --- a/novaconium/src/AdminAuth.php +++ b/novaconium/src/AdminAuth.php @@ -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|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);