Registration v2 (email verification, domain restriction, auth audit) — Design
Date: 2026-07-06 · Status: Approved (user directive: password confirmation, email
confirmation, admin domain restriction, auth events in the audit log)
Module: CORE. Builds directly on the auth-hardening feature (spec 2026-07-06-internal-auth-hardening).
1. Password confirmation (web only)
RegisterPage gains a "Confirm password" field with a client-side match check (same UX as
the forced-change gate). No API change — the server never needs the duplicate.
2. Email verification on self-registration
- Migration
00089:users.email_verified_at timestamptz— backfillednow()for every
existing account (nobody is retro-locked);email_verifications(token_hash text PK, user_id uuid FK CASCADE, expires_at timestamptz);auth_settingsgains
require_email_verification bool NOT NULL DEFAULT trueand
allowed_email_domains text NOT NULL DEFAULT ''. - Register (when verification is on): the account is created unverified, NO session is
issued — the response is201 {verification_required:true}and a mail goes out via the
existing notify SMTP channel (dev = mailpit) with a link
{X-Forwarded-Proto|http}://{Host}/verify-email?token=<raw>(32-byte token, sha256
stored, 24h TTL, single-success). A mail-send failure is logged + audited but does not
fail registration (resend covers it). Verification OFF → today's behavior (session). - Login: a
provider='local'account withemail_verified_at IS NULL(verification on)
gets 403auth.email.unverified— deliberately actionable, not generic: open
registration already reveals account existence via the 409, and "check your inbox" is the
correct UX. Dev/directory users and admin-created accounts are exempt (admin create sets
email_verified_at = now()). POST /auth/verify-email {token}(rate-limited, pre-session): consume + mark verified.
POST /auth/resend-verification {email}: ALWAYS returns 200 with a generic body (no
enumeration); internally re-issues (delete old tokens → insert new → send) only for an
existing unverified local account. SPA route/verify-emailposts the?token=and
shows success ("you can now sign in") or an invalid/expired state with a resend box.
3. Admin-restricted email domains
allowed_email_domains — comma-separated, case-insensitive, empty = any. Enforced ONLY on
self-registration (RegisterUser): the part after the final @ must match one of the
listed domains → else 400 auth.register.domain_not_allowed (policy is public, error is
actionable). Admin create bypasses (admins may onboard anyone). Edited in Admin → Security
(a "Registration" group: the existing self-registration toggle + the verification toggle +
a domains text field, e.g. val.id, obscura.local).
4. Auth events in the audit log
The auditMutations middleware already records every successful authenticated mutation —
including logout and all the admin actions (user create/reset/unlock, settings changes).
What's missing is the PRE-SESSION surface; the handlers now audit.Append (partition
auth, best-effort, never failing the request):
| Action | Actor | Payload |
|---|---|---|
auth.login (success) |
user id | {email, ip} |
auth.login.failed |
attempted email | {ip} (reason stays internal — the wire error is generic) |
auth.login.totp_pending |
user id | {ip} (password ok, awaiting code) |
auth.totp.verify / auth.totp.verify_failed |
user id / - |
{ip} |
auth.register |
new user id | {email, ip, verification_required} |
auth.register.denied |
attempted email | {ip, code} (disabled/domain/policy) |
auth.email.verified |
user id | {ip} |
auth.email.resend |
attempted email | {ip} |
IP via the existing clientIP(r) helper. Failed logins deliberately audit the attempted
email as actor (searchable trail) — never the password.
Testing
Build/vet + tsc/vite; e2e on the throwaway dind stack: register (verification on) → no
session + mail captured in mailpit (dind:8025 API) → extract link → verify → login works;
unverified login → 403 auth.email.unverified; resend → 200 generic for both existing and
absent emails; domain restriction on (test.local) → other domains 400, listed domain
passes; audit rows present for login/failed/register (query audit_events); director +
demo untouched; cleanup scratch + settings reset.
Out of scope (YAGNI)
HTML email templates (plain text), email change re-verification, SMTP configuration UI
(env-only as today), verification reminders/expiry sweeps beyond opportunistic delete.