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.
120 lines
4.5 KiB
JavaScript
120 lines
4.5 KiB
JavaScript
/**
|
|
* AuthProvider registry — composes every enabled provider for the current
|
|
* DashCaddy instance.
|
|
*
|
|
* Today only `totp` exists. Adding a new provider:
|
|
* 1. Create src/auth/providers/<name>.js exporting a class extending
|
|
* AuthProvider (see ./base.js for the contract).
|
|
* 2. Add a case to `loadProvider()` below.
|
|
* 3. Document in CHANGELOG.md. No route edits, no app.js edits.
|
|
*
|
|
* The registry returns a single object the auth router walks to:
|
|
* - list all enabled providers + their login methods (for the UI)
|
|
* - look up a provider by name (for /api/v1/auth/login/:provider/...)
|
|
* - look up the active provider for the check-session endpoint (every
|
|
* provider's session is the same DashCaddy cookie, so check-session
|
|
* is a global concern, not provider-specific)
|
|
*
|
|
* Sessions are global, not per-provider. Every successful verify() creates
|
|
* the same DashCaddy session cookie. TOTP and email magic link both use
|
|
* session.create() / setCookie() — there's no "TOTP session" vs "email session"
|
|
* distinction. The check-session endpoint therefore doesn't dispatch to a
|
|
* specific provider — it just verifies the cookie's validity (the existing
|
|
* behavior in src/utilities/middleware.js isSessionValid()).
|
|
*/
|
|
|
|
const TotpProvider = require('./totp');
|
|
const EmailMagicLinkProvider = require('./email');
|
|
|
|
/**
|
|
* Instantiate every enabled provider for this instance.
|
|
*
|
|
* @param {Object} deps Shared infrastructure (see TotpProvider constructor)
|
|
* @param {Object} config The site config — providers.enabled.{name} toggles each.
|
|
* @returns {Object} { providers: Map<name, AuthProvider>, listEnabled() }
|
|
*/
|
|
function createAuthProviderRegistry(deps, config) {
|
|
const providers = new Map();
|
|
|
|
// Always load TOTP — it's the default. The legacy /api/v1/totp/* routes
|
|
// and the Caddy forward_auth gate all assume TOTP exists.
|
|
const totpProvider = new TotpProvider({
|
|
...deps,
|
|
config: deps.config.totp, // the existing totpConfig object from app.js
|
|
saveProviderConfig: deps.saveTotpConfig, // existing helper
|
|
// DC-048: user store for bootstrap + audit attribution on TOTP logins.
|
|
userStore: deps.userStore || null,
|
|
});
|
|
providers.set('totp', totpProvider);
|
|
|
|
// DC-047: EmailMagicLinkProvider — second AuthProvider. Always register;
|
|
// enablement is controlled by config.authProviders.email.enabled (defaults
|
|
// to true so dev installs "just work"). Providers are constructed lazily
|
|
// in the sense that even a provider with no SMTP config still issues and
|
|
// verifies tokens — only delivery falls back to the dev console-log path.
|
|
providers.set('email', new EmailMagicLinkProvider({
|
|
...deps,
|
|
config: deps.config.email || { enabled: true, sessionDuration: '24h' },
|
|
saveProviderConfig: deps.saveProviderConfig || (async () => {}),
|
|
emailConfig: deps.emailConfig || null,
|
|
siteConfig: deps.siteConfig || {},
|
|
// DC-048: real authorization check via the user store. Without it,
|
|
// every email is allowed (legacy single-user behavior).
|
|
userStore: deps.userStore || null,
|
|
}));
|
|
|
|
// Future: OIDC, SAML, passkeys — each gated on config.authProviders
|
|
// or its own toggle. The shape is uniform: { enabled: bool, ...rest }.
|
|
//
|
|
// if (config?.authProviders?.oidc?.enabled) {
|
|
// providers.set('oidc', new OidcProvider({
|
|
// ...deps, config: config.authProviders.oidc, saveProviderConfig: ...
|
|
// }));
|
|
// }
|
|
|
|
return {
|
|
providers,
|
|
/**
|
|
* Return the list of enabled providers with their public config.
|
|
* Used by GET /api/v1/auth/methods to drive the login UI.
|
|
*/
|
|
async listEnabled() {
|
|
const out = [];
|
|
for (const [name, provider] of providers) {
|
|
if (!(await provider.isEnabled())) continue;
|
|
const methods = await provider.listMethods();
|
|
out.push({
|
|
name,
|
|
config: await provider.getConfig(),
|
|
methods,
|
|
});
|
|
}
|
|
return out;
|
|
},
|
|
|
|
/**
|
|
* All configured providers, including disabled-but-not-deleted ones.
|
|
* For the settings UI.
|
|
*/
|
|
async listAll() {
|
|
const out = [];
|
|
for (const [name, provider] of providers) {
|
|
out.push({
|
|
name,
|
|
config: await provider.getConfig(),
|
|
methods: await provider.listMethods(),
|
|
});
|
|
}
|
|
return out;
|
|
},
|
|
|
|
/**
|
|
* Look up a provider by name. Returns null if not registered.
|
|
*/
|
|
getProvider(name) {
|
|
return providers.get(name) || null;
|
|
},
|
|
};
|
|
}
|
|
|
|
module.exports = { createAuthProviderRegistry }; |