DC-048: multi-user bootstrap + admin invites (opt-in)
Implements the user-store + invite-store + admin routes. The whole system is opt-in via siteConfig.authProviders.email.enabled = true; single-user TOTP-only installs see zero behavior change. Backend: - src/security/user-store.js: users + allowlist + bootstrap sentinel, atomic writes, last-admin protection, defensive dataDir resolver. - src/security/invite-store.js: single-use tokens (SHA-256 hashed on disk), TTL, auto-prune, defensive dataDir resolver. - routes/auth/admin.js: /me, /admin/users (CRUD), /admin/allowlist, /admin/invites (CRUD), public /invites/:token (peek + accept). - routes/auth/index.js: wires userStore, gates admin router on email auth being enabled. - src/auth/providers/email.js: verify() enforces allowlist, creates user record, tags req.user; default-enabled flipped to opt-in. - src/auth/providers/totp.js: bootstraps system@totp.local admin on first verify so current DNS2 operator shows in /admin/users. - src/security/audit-logger.js: middleware adds userId/userEmail/ userRole/viaProvider to log details when req.user is tagged. - PUBLIC_ROUTES + CSRF allowlists updated for invite redemption. Frontend: - status/js/admin.js: modal overlay with users list (role-edit, delete), invite form (email/role/TTL), copy-link button, outstanding-invites list with revoke. Exports window.AdminPanel. - status/js/core/init.js: calls AdminPanel.attachTrigger so the Admin button only appears when /me returns isAdmin=true. Tests: 35 new tests across 3 files (user-store, invite-store, auth multistore integration). Full suite: 1298/1298 passing. Docs: BACKLOG.md marks DC-048 done. CHANGELOG.md [Unreleased] section gets the DC-048 entry.
This commit is contained in:
@@ -81,8 +81,9 @@ class EmailMagicLinkProvider extends AuthProvider {
|
||||
this.store.startPruneTimer();
|
||||
|
||||
this.emailConfig = deps.emailConfig || null;
|
||||
// DC-048 will replace this with the real authorized-users check.
|
||||
this.authorizedEmails = deps.authorizedEmails || (() => true);
|
||||
// DC-048: real authorized-users check via the user store. Falls back
|
||||
// to "allow everyone" if no store is wired (dev/legacy installs).
|
||||
this.userStore = deps.userStore || null;
|
||||
// Public URL templates — overridable for testing.
|
||||
this.linkTtlMs = deps.linkTtlMs || DEFAULT_LINK_TTL_MS;
|
||||
this.maxBodyLength = 32_000;
|
||||
@@ -131,10 +132,12 @@ class EmailMagicLinkProvider extends AuthProvider {
|
||||
*/
|
||||
_isProviderEnabled() {
|
||||
const flag = this.deps.config && this.deps.config.enabled;
|
||||
// Default to TRUE: dev installs should "just work". Operators who want
|
||||
// to disable email login set `siteConfig.authProviders.email.enabled = false`
|
||||
// — the same config knob the TOTP provider uses.
|
||||
if (flag === false) return false;
|
||||
// Default to FALSE (DC-048 opt-in): operators must explicitly enable
|
||||
// email auth via `siteConfig.authProviders.email.enabled = true`. Until
|
||||
// then, the email login methods endpoint reports the provider as
|
||||
// disabled and the auth UI doesn't render the email button. TOTP-only
|
||||
// installs see no behavior change.
|
||||
if (flag !== true) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -277,12 +280,64 @@ class EmailMagicLinkProvider extends AuthProvider {
|
||||
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
|
||||
}
|
||||
|
||||
// DC-048: authorization gate. If the email isn't on the allowlist and
|
||||
// bootstrap has already happened, reject. The token is still consumed
|
||||
// so the same generic message is returned for "valid token but you're
|
||||
// not allowed" — prevents a side-channel that distinguishes
|
||||
// "token worked but you're banned" from "token didn't exist".
|
||||
//
|
||||
// NOTE: the DC-047 design comment claimed "initiate" would also silently
|
||||
// drop unauthorized emails. That was aspirational; the real enumeration
|
||||
// prevention lives at verify-time (here). Initiate-time, we still issue
|
||||
// tokens and return success — so an unauthorized user thinks the link
|
||||
// works, but it rejects at click-time. Same as DC-047 claimed; we just
|
||||
// moved the check from initiate to verify where it can actually run.
|
||||
if (this.userStore) {
|
||||
const allowed = await this.userStore.isEmailAuthorized(record.email);
|
||||
if (!allowed) {
|
||||
// Audit the denial.
|
||||
this.deps.log && this.deps.log.warn && this.deps.log.warn('auth', 'email magic link rejected — not authorized', {
|
||||
email: record.email,
|
||||
ip: this._clientIP(req),
|
||||
});
|
||||
// Mark token used so a stolen token can't be replayed by a legit user later.
|
||||
await this.store.markUsed(record.hash).catch(() => {});
|
||||
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
|
||||
}
|
||||
}
|
||||
|
||||
// Side-effect logging (NOT info-disclosure — just that a token was used).
|
||||
this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link verified', {
|
||||
email: record.email,
|
||||
ip: this._clientIP(req),
|
||||
});
|
||||
|
||||
// DC-048: record-or-create the user. First login → bootstrap admin.
|
||||
// After that → must be on allowlist (already checked above).
|
||||
let userRecord = null;
|
||||
let isBootstrap = false;
|
||||
if (this.userStore) {
|
||||
const result = await this.userStore.login({
|
||||
email: record.email,
|
||||
ip: this._clientIP(req),
|
||||
});
|
||||
if (!result.ok) {
|
||||
// Shouldn't reach here — isEmailAuthorized just passed — but
|
||||
// handle the edge case where allowlist was mutated between calls.
|
||||
await this.store.markUsed(record.hash).catch(() => {});
|
||||
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
|
||||
}
|
||||
userRecord = result.user;
|
||||
isBootstrap = result.isBootstrap;
|
||||
if (this.deps.log && this.deps.log.info) {
|
||||
this.deps.log.info('auth', isBootstrap ? 'bootstrap admin first login' : 'user login', {
|
||||
userId: userRecord.id,
|
||||
email: userRecord.email,
|
||||
role: userRecord.role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create the session + cookie. Same shape as TOTP's verify path.
|
||||
this.deps.session.create(req, this.deps.config && this.deps.config.sessionDuration || '24h');
|
||||
this.deps.session.setCookie(res, this.deps.config && this.deps.config.sessionDuration || '24h');
|
||||
@@ -290,11 +345,32 @@ class EmailMagicLinkProvider extends AuthProvider {
|
||||
? this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https')
|
||||
: undefined;
|
||||
|
||||
// DC-048: tag the request with the authenticated user so downstream
|
||||
// middleware + audit log can attribute the session. We mutate req so
|
||||
// the audit logger (which runs as response middleware) sees it.
|
||||
if (userRecord) {
|
||||
req.user = {
|
||||
id: userRecord.id,
|
||||
email: userRecord.email,
|
||||
role: userRecord.role,
|
||||
isAdmin: userRecord.role === 'admin',
|
||||
isBootstrap,
|
||||
};
|
||||
}
|
||||
|
||||
return ok(res, {
|
||||
message: 'Authenticated successfully',
|
||||
method: 'email',
|
||||
email: AuthProvider.maskEmail(record.email),
|
||||
csrfToken: newCsrf,
|
||||
user: userRecord
|
||||
? {
|
||||
id: userRecord.id,
|
||||
email: userRecord.email,
|
||||
role: userRecord.role,
|
||||
isBootstrap,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user