diff --git a/.env.example b/.env.example index 1d18f27..79f3be1 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,23 @@ ALLOWED_ORIGINS= # so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate # per-IP login rate-limiting (otherwise req.ip is always the nginx IP). TRUST_PROXY=false + +# Google OAuth (OIDC) sign-in — OPTIONAL. Leave the client id/secret blank to +# disable; the "Sign in with Google" button and the /auth/google routes only +# activate when all three of CLIENT_ID, CLIENT_SECRET, and REDIRECT_URL are set. +# Create an OAuth 2.0 Client (type: Web application) in Google Cloud Console and +# add OAUTH_REDIRECT_URL to its authorized redirect URIs. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +# Must exactly match a redirect URI on the OAuth client, e.g. +# https://dragonflight.live/api/v1/auth/google/callback +OAUTH_REDIRECT_URL= +# Restrict sign-in to one Google Workspace domain (recommended). First login from +# an allowed-domain account auto-provisions a NEW 'viewer' account (matched only +# by Google's stable subject id, never by email — so a Google login can never +# seize a pre-existing local account). An admin then grants project access. +# Leave blank to allow any verified Google account to self-provision (NOT advised). +GOOGLE_ALLOWED_DOMAIN= +# Note: if a Google-linked account also has TOTP enabled, sign-in still requires +# the authenticator code (Google is treated as the first factor). Accounts without +# TOTP complete sign-in in one Google step. diff --git a/services/mam-api/package.json b/services/mam-api/package.json index 4dca16b..47a260e 100644 --- a/services/mam-api/package.json +++ b/services/mam-api/package.json @@ -23,7 +23,8 @@ "multer": "^1.4.5-lts.1", "uuid": "^9.0.1", "dotenv": "^16.4.5", - "qrcode": "^1.5.4" + "qrcode": "^1.5.4", + "google-auth-library": "^9.14.0" }, "engines": { "node": ">=22.0.0" diff --git a/services/mam-api/src/auth/google-oauth.js b/services/mam-api/src/auth/google-oauth.js new file mode 100644 index 0000000..f8f55aa --- /dev/null +++ b/services/mam-api/src/auth/google-oauth.js @@ -0,0 +1,86 @@ +// Google OAuth (OIDC) sign-in helpers. +// +// Entirely config-gated: if GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / +// OAUTH_REDIRECT_URL aren't set, isConfigured() is false and the routes 404, so +// a deployment without Google SSO behaves exactly as before. google-auth-library +// is imported lazily so the dependency is only required when the feature is on. +// +// Flow: /auth/google redirects to Google's consent screen with a signed `state`; +// /auth/google/callback exchanges the code, verifies the ID token, enforces the +// allowed Workspace domain, and auto-provisions a viewer account on first login. + +const SCOPES = ['openid', 'email', 'profile']; + +export function isConfigured() { + return !!(process.env.GOOGLE_CLIENT_ID + && process.env.GOOGLE_CLIENT_SECRET + && process.env.OAUTH_REDIRECT_URL); +} + +export function allowedDomain() { + return (process.env.GOOGLE_ALLOWED_DOMAIN || '').trim().toLowerCase() || null; +} + +// Lazily build an OAuth2 client (throws a clear error if the dep is missing). +async function makeClient() { + let OAuth2Client; + try { + ({ OAuth2Client } = await import('google-auth-library')); + } catch { + const err = new Error('google-auth-library is not installed'); + err.status = 500; + throw err; + } + return new OAuth2Client({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + redirectUri: process.env.OAUTH_REDIRECT_URL, + }); +} + +// URL of Google's consent screen. `state` is an opaque anti-CSRF token we also +// stash in the session and re-check on callback. +export async function buildAuthUrl(state) { + const client = await makeClient(); + return client.generateAuthUrl({ + access_type: 'online', + scope: SCOPES, + state, + prompt: 'select_account', + // If a Workspace domain is configured, hint Google to scope the picker to it. + ...(allowedDomain() ? { hd: allowedDomain() } : {}), + }); +} + +// Exchange the authorization code and verify the returned ID token. Returns the +// verified { sub, email, name, hd } payload. Throws { status } on any failure. +export async function exchangeAndVerify(code) { + const client = await makeClient(); + const { tokens } = await client.getToken(code); + if (!tokens.id_token) { + const err = new Error('no id_token from Google'); err.status = 401; throw err; + } + const ticket = await client.verifyIdToken({ + idToken: tokens.id_token, + audience: process.env.GOOGLE_CLIENT_ID, + }); + const p = ticket.getPayload(); + if (!p || !p.sub) { + const err = new Error('invalid id_token'); err.status = 401; throw err; + } + // Require an explicitly verified email — a missing/undefined claim is NOT + // treated as verified, since the email drives account linking/provisioning. + if (!p.email || p.email_verified !== true) { + const err = new Error('email not verified'); err.status = 403; throw err; + } + const domain = allowedDomain(); + if (domain) { + const emailDomain = String(p.email).split('@')[1]?.toLowerCase(); + // Prefer Google's hosted-domain claim; fall back to the email domain. + const hd = (p.hd || emailDomain || '').toLowerCase(); + if (hd !== domain) { + const err = new Error('domain not allowed'); err.status = 403; throw err; + } + } + return { sub: p.sub, email: p.email, name: p.name || p.email, hd: p.hd || null }; +} diff --git a/services/mam-api/src/db/migrations/028-google-oauth.sql b/services/mam-api/src/db/migrations/028-google-oauth.sql new file mode 100644 index 0000000..a7864df --- /dev/null +++ b/services/mam-api/src/db/migrations/028-google-oauth.sql @@ -0,0 +1,13 @@ +-- Migration 028 — Google OAuth (OIDC) sign-in. +-- +-- google_sub is Google's stable subject identifier — the join key for a linked +-- or auto-provisioned account (unique, but NULL for password-only users). +-- email is captured for display + domain checks. password_hash becomes nullable +-- so an OAuth-only account can exist without a local password; such an account +-- simply can't use the password login path until an admin sets one. + +ALTER TABLE users ADD COLUMN IF NOT EXISTS google_sub TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT; +ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_sub ON users(google_sub) WHERE google_sub IS NOT NULL; diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 7722d02..51169fe 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -104,7 +104,10 @@ app.get('/health', (_req, res) => res.json({ status: 'ok' })); // ── Auth gate ───────────────────────────────────────────────────────────────── // req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login. -const UNAUTH_PATHS = new Set(['/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required']); +const UNAUTH_PATHS = new Set([ + '/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required', + '/auth/google', '/auth/google/callback', '/auth/google/enabled', +]); // node-agent now authenticates /cluster/heartbeat with a bound api_token // (migration 019 + bound_hostname on the token). requireAuth handles the // bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 889bb38..09f1f37 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -7,6 +7,10 @@ import { generateSecret, verifyToken, otpauthURI, generateRecoveryCodes, } from '../auth/totp.js'; import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js'; +import { + isConfigured as googleConfigured, buildAuthUrl, exchangeAndVerify, +} from '../auth/google-oauth.js'; +import { randomBytes } from 'node:crypto'; const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K'; @@ -125,15 +129,26 @@ async function establishSession(req, user) { .catch(err => console.error('[auth] last_login_at update failed:', err.message)); } -// POST /api/v1/auth/login/totp { ticket, code } — second login step. `code` is -// either a 6-digit TOTP or a one-time recovery code. +// POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is +// either a 6-digit TOTP or a one-time recovery code. The ticket comes from the +// request body (password-login path) or req.session.mfa_ticket (Google path). router.post('/login/totp', async (req, res, next) => { try { const ip = req.ip || req.socket?.remoteAddress || 'unknown'; - const { ticket, code } = req.body || {}; + // Rate-limit the second factor with the same per-IP backoff as /login so + // the 6-digit code space can't be hammered. + const delay = ipBackoff.delayMs(ip); + if (delay > 0) await new Promise(r => setTimeout(r, delay)); + + const { ticket: bodyTicket, code } = req.body || {}; + const ticket = bodyTicket || req.session?.mfa_ticket; + if (req.session?.mfa_ticket) delete req.session.mfa_ticket; const userId = redeemTicket(ticket); - if (!userId) return res.status(401).json({ error: 'invalid or expired ticket' }); - if (!code) return res.status(400).json({ error: 'code required' }); + if (!userId) { + ipBackoff.recordFailure(ip); + return res.status(401).json({ error: 'invalid or expired ticket' }); + } + if (!code) return res.status(400).json({ error: 'code required' }); const { rows } = await pool.query( `SELECT id, username, display_name, totp_secret, totp_enabled FROM users WHERE id = $1`, [userId]); @@ -150,12 +165,15 @@ router.post('/login/totp', async (req, res, next) => { return res.status(401).json({ error: 'invalid code' }); } + ipBackoff.recordSuccess(ip); await establishSession(req, user); res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); } }); // Check a recovery code against the user's unused codes; mark it spent on match. +// The marking is atomic (UPDATE ... WHERE used_at IS NULL with a rowCount check) +// so two concurrent redemptions of the same code can't both succeed. async function consumeRecoveryCode(userId, code) { const cleaned = String(code).trim().toLowerCase(); if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false; @@ -163,8 +181,10 @@ async function consumeRecoveryCode(userId, code) { `SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]); for (const row of rows) { if (await comparePassword(cleaned, row.code_hash)) { - await pool.query(`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1`, [row.id]); - return true; + const upd = await pool.query( + `UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1 AND used_at IS NULL`, [row.id]); + // Lost the race if another request already consumed it. + return upd.rowCount === 1; } } return false; @@ -296,5 +316,105 @@ router.post('/totp/disable', requireAuth, async (req, res, next) => { } catch (err) { next(err); } }); +// ── Google OAuth (OIDC) sign-in ───────────────────────────────────────────── +// All Google routes are config-gated: without GOOGLE_CLIENT_ID/SECRET and +// OAUTH_REDIRECT_URL they 404, so a deployment without SSO is unaffected. + +// GET /api/v1/auth/google/enabled — cheap, no auth. Lets the login screen decide +// whether to render the "Sign in with Google" button. +router.get('/google/enabled', (_req, res) => { + res.json({ enabled: googleConfigured() }); +}); + +// GET /api/v1/auth/google — kick off the OAuth dance. Stores an anti-CSRF state +// in the session and redirects to Google's consent screen. +router.get('/google', async (req, res, next) => { + try { + if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' }); + const state = randomBytes(16).toString('hex'); + req.session.oauth_state = state; + await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); + res.redirect(await buildAuthUrl(state)); + } catch (err) { next(err); } +}); + +// GET /api/v1/auth/google/callback — Google redirects back here with ?code&state. +// Verifies the ID token, enforces the allowed domain, auto-provisions a viewer +// on first login, establishes the session, then redirects to the SPA. +router.get('/google/callback', async (req, res, next) => { + try { + if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' }); + const { code, state } = req.query; + const expected = req.session.oauth_state; + delete req.session.oauth_state; + if (!code || !state || !expected || state !== expected) { + return res.status(400).json({ error: 'invalid oauth state' }); + } + + const profile = await exchangeAndVerify(code); + const user = await resolveGoogleUser(profile); + + // If this account has TOTP enabled, Google is only the FIRST factor — route + // through the same second-factor step as password login. The ticket lives in + // the session (not the URL) and the SPA prompts for the code. + if (user.totp_enabled) { + req.session.mfa_ticket = issueTicket(user.id); + await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); + return res.redirect('/?mfa=1'); + } + + await establishSession(req, user); + + // Redirect to the SPA root; AuthGate will re-check /auth/me and render the app. + res.redirect('/'); + } catch (err) { + // Surface a friendly message on the login screen rather than a raw 500. + if (err.status === 403) return res.redirect('/?auth_error=domain'); + if (err.status === 401) return res.redirect('/?auth_error=google'); + next(err); + } +}); + +// Map a verified Google profile to a Dragonflight user row. +// +// Resolution order: +// 1. Existing link by google_sub → that user. +// 2. Otherwise auto-provision a fresh 'viewer'. +// +// We deliberately do NOT auto-link to an existing account by matching email: +// that would let anyone who controls a Google address with the same email sign +// in as a pre-existing local (possibly admin) account, bypassing its password +// and TOTP. Linking an existing account to Google is an explicit, authenticated +// action (a future "connect Google" under Settings), not something a login does. +async function resolveGoogleUser(profile) { + const found = await pool.query( + `SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]); + if (found.rows.length) return found.rows[0]; + + const base = (profile.email.split('@')[0] || 'user').replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user'; + let username = base, n = 1; + while ((await pool.query(`SELECT 1 FROM users WHERE username = $1`, [username])).rows.length) { + username = base + (++n); + } + + try { + const ins = await pool.query( + `INSERT INTO users (username, password_hash, display_name, role, email, google_sub) + VALUES ($1, NULL, $2, 'viewer', $3, $4) + RETURNING id, username, display_name, totp_enabled`, + [username, profile.name, profile.email, profile.sub]); + return ins.rows[0]; + } catch (err) { + // Concurrent first-login race: the unique google_sub index rejected our + // INSERT because a sibling request just created the row. Re-resolve. + if (err.code === '23505') { + const retry = await pool.query( + `SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]); + if (retry.rows.length) return retry.rows[0]; + } + throw err; + } +} + export default router; -export { realUserCount }; +export { realUserCount, resolveGoogleUser, consumeRecoveryCode }; diff --git a/services/mam-api/test/auth/google-oauth.test.js b/services/mam-api/test/auth/google-oauth.test.js new file mode 100644 index 0000000..26766be --- /dev/null +++ b/services/mam-api/test/auth/google-oauth.test.js @@ -0,0 +1,40 @@ +// Unit tests for the config-gating + domain helpers in google-oauth.js. The +// token-exchange / ID-token-verify path requires Google's servers and is covered +// by manual verification (see .env.example); here we lock down the pure logic +// that decides whether the feature is on and which domain is allowed. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isConfigured, allowedDomain } from '../../src/auth/google-oauth.js'; + +function withEnv(vars, fn) { + const saved = {}; + for (const k of Object.keys(vars)) { saved[k] = process.env[k]; + if (vars[k] === undefined) delete process.env[k]; else process.env[k] = vars[k]; } + try { return fn(); } + finally { + for (const k of Object.keys(vars)) { + if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; + } + } +} + +test('isConfigured is false unless client id, secret, and redirect are all set', () => { + withEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => { + assert.equal(isConfigured(), false); + }); + withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => { + assert.equal(isConfigured(), false); + }); + withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: undefined }, () => { + assert.equal(isConfigured(), false); + }); + withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: 'https://h/cb' }, () => { + assert.equal(isConfigured(), true); + }); +}); + +test('allowedDomain normalizes and defaults to null', () => { + withEnv({ GOOGLE_ALLOWED_DOMAIN: undefined }, () => assert.equal(allowedDomain(), null)); + withEnv({ GOOGLE_ALLOWED_DOMAIN: '' }, () => assert.equal(allowedDomain(), null)); + withEnv({ GOOGLE_ALLOWED_DOMAIN: ' WildDragon.NET ' }, () => assert.equal(allowedDomain(), 'wilddragon.net')); +}); diff --git a/services/mam-api/test/routes/google-link.test.js b/services/mam-api/test/routes/google-link.test.js new file mode 100644 index 0000000..9c5e069 --- /dev/null +++ b/services/mam-api/test/routes/google-link.test.js @@ -0,0 +1,63 @@ +// Security regression test for resolveGoogleUser: a Google sign-in must NEVER +// adopt a pre-existing local account by matching email (that would be account +// takeover). It links only by google_sub, otherwise provisions a fresh viewer. +// Skips without TEST_DATABASE_URL. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; +import { hashPassword } from '../../src/auth/passwords.js'; + +const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set'; + +async function loadResolve() { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + return (await import('../../src/routes/auth.js?v=' + Date.now())).resolveGoogleUser; +} + +test('a Google login with an email matching a local admin does NOT take over that account', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + // Pre-existing local admin with a password and the same email the attacker controls. + const adminId = (await pool.query( + `INSERT INTO users (username, password_hash, role, email) + VALUES ('boss', $1, 'admin', 'boss@wilddragon.net') RETURNING id`, + [await hashPassword('a-real-password-12')])).rows[0].id; + + const resolveGoogleUser = await loadResolve(); + const user = await resolveGoogleUser({ sub: 'google-attacker-sub', email: 'boss@wilddragon.net', name: 'Not The Boss' }); + + // Must be a brand-new account, NOT the admin. + assert.notEqual(user.id, adminId, 'Google login must not resolve to the existing admin'); + const row = (await pool.query(`SELECT role, google_sub FROM users WHERE id = $1`, [user.id])).rows[0]; + assert.equal(row.role, 'viewer', 'auto-provisioned account must be a viewer'); + assert.equal(row.google_sub, 'google-attacker-sub'); + // The admin row is untouched (no google_sub linked onto it). + const admin = (await pool.query(`SELECT google_sub FROM users WHERE id = $1`, [adminId])).rows[0]; + assert.equal(admin.google_sub, null, 'the existing admin must not have been linked'); + } finally { await pool.end(); } +}); + +test('a returning Google user resolves to the same account by google_sub', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + const resolveGoogleUser = await loadResolve(); + const first = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' }); + const second = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' }); + assert.equal(first.id, second.id, 'same google_sub must map to the same user'); + const count = (await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE google_sub = 'sub-123'`)).rows[0].n; + assert.equal(count, 1, 'must not create a duplicate on second login'); + } finally { await pool.end(); } +}); + +test('username collisions get a numeric suffix', { skip: SKIP }, async () => { + const pool = await setupTestDb(); + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + try { + await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('sam', 'x', 'viewer')`); + const resolveGoogleUser = await loadResolve(); + const u = await resolveGoogleUser({ sub: 'sub-sam', email: 'sam@wilddragon.net', name: 'Sam' }); + assert.match(u.username, /^sam\d+$/, 'colliding username should get a numeric suffix'); + } finally { await pool.end(); } +}); diff --git a/services/web-ui/public/screens-auth.jsx b/services/web-ui/public/screens-auth.jsx index 8d7eff2..f3bbd1a 100644 --- a/services/web-ui/public/screens-auth.jsx +++ b/services/web-ui/public/screens-auth.jsx @@ -116,15 +116,73 @@ ); } + // Google sign-in availability + a friendly message for the callback's + // ?auth_error redirect (domain-not-allowed / generic google failure). + function useGoogleAndAuthError(setError) { + const [googleEnabled, setGoogleEnabled] = React.useState(false); + React.useEffect(() => { + fetch(API_BASE + '/auth/google/enabled', { credentials: 'include' }) + .then(r => r.json()).then(d => setGoogleEnabled(!!d.enabled)).catch(() => {}); + const params = new URLSearchParams(location.search); + const e = params.get('auth_error'); + if (e === 'domain') setError('That Google account is not in an allowed domain.'); + else if (e === 'google') setError('Google sign-in failed. Please try again.'); + if (e) { + // Clean the query string so a reload doesn't re-show the error. + const url = location.pathname + location.hash; + history.replaceState(null, '', url); + } + }, [setError]); + return googleEnabled; + } + + function GoogleButton() { + return ( + + G + Sign in with Google + + ); + } + + function Divider() { + return ( +