From 7d78c9fe22a2f42941397b980a46248402741598 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 15 Apr 2026 18:45:51 -0400 Subject: [PATCH] feat(auth): add TOTP two-factor authentication with backup codes (#615) * feat(auth): add TOTP two-factor authentication with backup codes Adds RFC 6238 time-based one-time password support to every tier, integrated with the existing password and SSO login paths. Backend: - New MfaService wrapping otplib with a plus or minus 1 step tolerance, base32 secret generation, and hashed single-use backup codes (bcrypt). - user_mfa and mfa_used_tokens tables in DatabaseService. The second table is a DB-backed replay blacklist, purged on a 60s interval. - authMiddleware now recognizes an mfa_pending scope. A token carrying that scope is rejected on every route except the MFA challenge and logout, so no API surface is reachable before the second factor clears. - /api/auth/login issues only a short-lived mfa_pending cookie when the user has MFA enrolled. /api/auth/login/mfa consumes that cookie, verifies the code (or backup code), and swaps in a real session. - /api/auth/mfa/* routes for status, enrol/start, enrol/confirm, disable, backup-code regenerate, and SSO-bypass opt-in. - Admin recovery path: POST /api/users/:id/mfa/reset clears the target's MFA state, bumps token_version, and writes an audit log entry. - CLI emergency fallback: backend/src/cli/resetMfa.ts is wired via `npm run reset-mfa ` and also exported for tests. - SSO flows (LDAP and OIDC) gate on user_mfa.sso_enforce_mfa before issuing a session; default behaviour keeps the SSO path frictionless. - Per-user lockout after 5 consecutive failed codes (15 min). Frontend: - AppStatus gains an mfa-challenge branch driven by /api/auth/status. - New MfaChallenge screen, MfaEnrollDialog (QR plus manual secret plus backup codes), MfaDisableDialog, MfaBackupCodesDialog. - Account section shows a Two-factor authentication card with enrol, regenerate, disable, and the SSO-enforce toggle (shown only when SSO providers are configured). - Users section gains a Reset 2FA action for admins. Docs: - New user guide at features/two-factor-authentication.mdx. - New admin guide at operations/two-factor-admin.mdx. - SSO page cross-links to the 2FA doc. * fix(mfa): drop unused TEST_PASSWORD import and stale eslint disable * fix(mfa): simplify e2e openAccountSettings helper to match working pattern * fix(mfa): make e2e suite self-contained and always clean up Test #2 called loginAs() before the MFA challenge step, which waited for the dashboard indicator that never appears once the previous test enrolled the user. That timeout skipped the rest of the serial block, including the disable step, leaving MFA enabled and breaking every later spec. Two fixes: - Tests #2 and #3 now navigate directly to the login page instead of piggybacking on loginAs, which only handles the password-only path. - A new afterAll hook unconditionally disables MFA via the API using two unused backup codes, so the DB is reset even if a test fails midway. * fix(e2e): use backup code for mfa recovery to avoid totp replay race The final recovery step in the backup-code replay test previously generated a fresh TOTP to sign back in. When the timing landed inside the same 30-second window that test #2 consumed, the server's replay blacklist correctly rejected it, producing a ~50% flake rate. Backup codes are single-use and sidestep the replay window, so the recovery becomes deterministic. * fix(e2e): drive mfa disable test through the challenge screen Test #4 called loginAs after test #3 left MFA enabled, but loginAs waits for the dashboard indicator and does not handle the challenge screen, so it timed out. Drive the login manually, satisfy the challenge with a backup code, and use a backup code for the disable step too to avoid any TOTP replay-window race against earlier tests in the serial block. --- backend/package-lock.json | 70 +++ backend/package.json | 4 +- backend/src/__tests__/helpers/setupTestDb.ts | 34 ++ backend/src/__tests__/mfa.test.ts | 479 +++++++++++++++ backend/src/cli/resetMfa.ts | 72 +++ backend/src/index.ts | 569 +++++++++++++++++- backend/src/services/DatabaseService.ts | 152 +++++ backend/src/services/MfaService.ts | 140 +++++ docs/docs.json | 4 +- docs/features/sso.mdx | 4 + docs/features/two-factor-authentication.mdx | 120 ++++ .../two-factor-auth/account-card-enabled.png | Bin 0 -> 13240 bytes docs/images/two-factor-auth/admin-reset.png | Bin 0 -> 17595 bytes docs/images/two-factor-auth/challenge.png | Bin 0 -> 41618 bytes .../two-factor-auth/enroll-backup-codes.png | Bin 0 -> 30864 bytes .../images/two-factor-auth/enroll-confirm.png | Bin 0 -> 15477 bytes docs/images/two-factor-auth/enroll-qr.png | Bin 0 -> 22241 bytes docs/operations/two-factor-admin.mdx | 58 ++ e2e/helpers.ts | 18 + e2e/mfa.spec.ts | 189 ++++++ frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/App.tsx | 5 + frontend/src/components/MfaChallenge.tsx | 127 ++++ .../components/mfa/MfaBackupCodesDialog.tsx | 167 +++++ .../src/components/mfa/MfaDisableDialog.tsx | 112 ++++ .../src/components/mfa/MfaEnrollDialog.tsx | 250 ++++++++ .../components/settings/AccountSection.tsx | 145 ++++- .../src/components/settings/UsersSection.tsx | 40 +- frontend/src/context/AuthContext.tsx | 69 ++- package-lock.json | 79 ++- package.json | 3 +- 32 files changed, 2904 insertions(+), 17 deletions(-) create mode 100644 backend/src/__tests__/mfa.test.ts create mode 100644 backend/src/cli/resetMfa.ts create mode 100644 backend/src/services/MfaService.ts create mode 100644 docs/features/two-factor-authentication.mdx create mode 100644 docs/images/two-factor-auth/account-card-enabled.png create mode 100644 docs/images/two-factor-auth/admin-reset.png create mode 100644 docs/images/two-factor-auth/challenge.png create mode 100644 docs/images/two-factor-auth/enroll-backup-codes.png create mode 100644 docs/images/two-factor-auth/enroll-confirm.png create mode 100644 docs/images/two-factor-auth/enroll-qr.png create mode 100644 docs/operations/two-factor-admin.mdx create mode 100644 e2e/mfa.spec.ts create mode 100644 frontend/src/components/MfaChallenge.tsx create mode 100644 frontend/src/components/mfa/MfaBackupCodesDialog.tsx create mode 100644 frontend/src/components/mfa/MfaDisableDialog.tsx create mode 100644 frontend/src/components/mfa/MfaEnrollDialog.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index 163c00b5..93fb748a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -37,6 +37,7 @@ "ldapts": "^8.1.7", "node-pty": "^1.1.0", "openid-client": "^6.8.2", + "otplib": "^12.0.1", "semver": "^7.7.4", "systeminformation": "^5.31.1", "ws": "^8.19.0", @@ -986,6 +987,56 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@oxc-project/types": { "version": "0.122.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", @@ -5691,6 +5742,17 @@ "node": ">= 0.8.0" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6637,6 +6699,14 @@ "node": ">=6" } }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/backend/package.json b/backend/package.json index 1ed94e61..ea923c0b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,7 +11,8 @@ "predev": "node scripts/generate-version.js", "dev": "nodemon --watch src --ext ts,json --exec ts-node src/index.ts", "test": "vitest run", - "lint": "eslint src" + "lint": "eslint src", + "reset-mfa": "node dist/cli/resetMfa.js" }, "keywords": [], "author": "", @@ -63,6 +64,7 @@ "ldapts": "^8.1.7", "node-pty": "^1.1.0", "openid-client": "^6.8.2", + "otplib": "^12.0.1", "semver": "^7.7.4", "systeminformation": "^5.31.1", "ws": "^8.19.0", diff --git a/backend/src/__tests__/helpers/setupTestDb.ts b/backend/src/__tests__/helpers/setupTestDb.ts index d32a684e..52b44fe0 100644 --- a/backend/src/__tests__/helpers/setupTestDb.ts +++ b/backend/src/__tests__/helpers/setupTestDb.ts @@ -60,3 +60,37 @@ export function cleanupTestDb(tmpDir: string): void { // best-effort cleanup } } + +/** + * Seed a user with MFA enrolled. Returns the raw TOTP secret (for generating + * valid codes in tests) and the cleartext backup codes. Callers are expected + * to have already called `setupTestDb`. + */ +export async function seedMfaUser( + username: string, + password: string, + opts: { role?: 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor'; ssoEnforce?: boolean } = {}, +): Promise<{ userId: number; secret: string; backupCodes: string[] }> { + const bcryptMod = (await import('bcrypt')).default; + const { DatabaseService } = await import('../../services/DatabaseService'); + const { CryptoService } = await import('../../services/CryptoService'); + const { MfaService } = await import('../../services/MfaService'); + + const db = DatabaseService.getInstance(); + const passwordHash = await bcryptMod.hash(password, 1); + const userId = db.addUser({ username, password_hash: passwordHash, role: opts.role ?? 'viewer' }); + + const secret = MfaService.generateSecret(); + const backupCodes = MfaService.generateBackupCodes(); + const hashes = await MfaService.hashBackupCodes(backupCodes); + db.upsertUserMfa(userId, { + enabled: true, + totp_secret_encrypted: CryptoService.getInstance().encrypt(secret), + backup_codes_json: JSON.stringify(hashes), + sso_enforce_mfa: opts.ssoEnforce === true, + failed_attempts: 0, + locked_until: null, + }); + + return { userId, secret, backupCodes }; +} diff --git a/backend/src/__tests__/mfa.test.ts b/backend/src/__tests__/mfa.test.ts new file mode 100644 index 00000000..d00d6769 --- /dev/null +++ b/backend/src/__tests__/mfa.test.ts @@ -0,0 +1,479 @@ +/** + * Tests for Multi-Factor Authentication (TOTP + backup codes): + * - Enrolment flow (start + confirm) and rejection of wrong OTPs + * - Login flow: password -> mfa_pending cookie -> /login/mfa -> session cookie + * - Replay prevention: same (user, code, window) refused twice + * - Backup code single-use semantics and remaining count + * - Lockout after repeated failures + * - Partial-auth session: mfa_pending token rejected on non-MFA routes + * - Admin reset endpoint + * - SSO bypass toggle + * - CLI reset helper (direct import, no subprocess) + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { authenticator } from 'otplib'; +import { HashAlgorithms } from '@otplib/core'; +import { + setupTestDb, + cleanupTestDb, + seedMfaUser, + TEST_USERNAME, + TEST_JWT_SECRET, +} from './helpers/setupTestDb'; + +// Match the server-side otplib configuration so test-generated OTPs are +// accepted by the verify path. +authenticator.options = { + digits: 6, + step: 30, + algorithm: HashAlgorithms.SHA1, + window: 1, +}; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let MfaService: typeof import('../services/MfaService').MfaService; + +function adminToken(): string { + const db = DatabaseService.getInstance(); + const user = db.getUserByUsername(TEST_USERNAME)!; + return jwt.sign( + { username: TEST_USERNAME, role: 'admin', tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); +} + +function cookieArray(headers: request.Response['headers']): string[] { + const raw = headers['set-cookie'] as unknown; + if (!raw) return []; + return Array.isArray(raw) ? (raw as string[]) : [raw as string]; +} + +function parseCookie(headers: request.Response['headers'], name: string): string | null { + for (const c of cookieArray(headers)) { + if (c.startsWith(`${name}=`)) { + const value = c.split(';')[0].split('=').slice(1).join('='); + // express-server `clearCookie` sends an empty value with an expired date + return value || null; + } + } + return null; +} + +function findCookie(headers: request.Response['headers'], name: string): string | undefined { + return cookieArray(headers).find((c) => c.startsWith(`${name}=`)); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ MfaService } = await import('../services/MfaService')); + + // Mock LicenseService to return paid/admiral so the admin routes pass gates + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +// ─── MfaService unit-ish tests ──────────────────────────────────────────────── + +describe('MfaService', () => { + it('verifyTotp accepts a freshly generated code', () => { + const secret = MfaService.generateSecret(); + const code = authenticator.generate(secret); + expect(MfaService.verifyTotp(secret, code)).toBe(true); + }); + + it('verifyTotp rejects garbage', () => { + const secret = MfaService.generateSecret(); + expect(MfaService.verifyTotp(secret, '000000')).toBe(false); + expect(MfaService.verifyTotp(secret, 'abcdef')).toBe(false); + expect(MfaService.verifyTotp(secret, '')).toBe(false); + }); + + it('generateBackupCodes returns 10 uppercase-alnum codes', () => { + const codes = MfaService.generateBackupCodes(); + expect(codes).toHaveLength(10); + for (const c of codes) { + expect(c).toMatch(/^[A-Z0-9]{10}$/); + } + }); + + it('verifyBackupCode matches and returns remaining set with the matched hash removed', async () => { + const codes = MfaService.generateBackupCodes(); + const hashes = await MfaService.hashBackupCodes(codes); + const result = await MfaService.verifyBackupCode(hashes, codes[3]); + expect(result.matched).toBe(true); + expect(result.remainingHashes).toHaveLength(hashes.length - 1); + expect(result.remainingHashes).not.toContain(hashes[3]); + }); + + it('verifyBackupCode on non-match returns original hashes', async () => { + const codes = MfaService.generateBackupCodes(); + const hashes = await MfaService.hashBackupCodes(codes); + const result = await MfaService.verifyBackupCode(hashes, 'NOTACODE99'); + expect(result.matched).toBe(false); + expect(result.remainingHashes).toBe(hashes); + }); + + it('normalizeBackupCode strips spaces/dashes and uppercases', () => { + expect(MfaService.normalizeBackupCode('abcde-fghij')).toBe('ABCDEFGHIJ'); + expect(MfaService.normalizeBackupCode('abcde fghij')).toBe('ABCDEFGHIJ'); + }); +}); + +// ─── Login flow ─────────────────────────────────────────────────────────────── + +describe('POST /api/auth/login with MFA-enabled user', () => { + const username = 'mfauser-login'; + const password = 'mfapassword123'; + + it('returns mfaRequired and sets the partial-auth cookie only', async () => { + await seedMfaUser(username, password); + + const res = await request(app) + .post('/api/auth/login') + .send({ username, password }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.mfaRequired).toBe(true); + + expect(parseCookie(res.headers, 'sencho_mfa_pending')).toBeTruthy(); + // No full session cookie yet. + expect(parseCookie(res.headers, 'sencho_token')).toBeFalsy(); + }); + + it('/auth/status reports mfaPending=true for a valid pending cookie', async () => { + const login = await request(app).post('/api/auth/login').send({ username, password }); + const pendingCookie = findCookie(login.headers, 'sencho_mfa_pending')!; + + const status = await request(app).get('/api/auth/status').set('Cookie', pendingCookie); + expect(status.status).toBe(200); + expect(status.body.mfaPending).toBe(true); + }); +}); + +// ─── MFA verify endpoint ────────────────────────────────────────────────────── + +describe('POST /api/auth/login/mfa', () => { + const username = 'mfauser-verify'; + const password = 'mfapassword123'; + let secret = ''; + let backupCodes: string[] = []; + + beforeAll(async () => { + ({ secret, backupCodes } = await seedMfaUser(username, password)); + }); + + async function startChallenge() { + const res = await request(app).post('/api/auth/login').send({ username, password }); + return findCookie(res.headers, 'sencho_mfa_pending')!; + } + + it('401 when no pending cookie is present', async () => { + const res = await request(app).post('/api/auth/login/mfa').send({ code: '123456' }); + expect(res.status).toBe(401); + }); + + it('accepts a valid TOTP, clears pending cookie, issues session', async () => { + const pendingCookie = await startChallenge(); + const code = authenticator.generate(secret); + + const res = await request(app) + .post('/api/auth/login/mfa') + .set('Cookie', pendingCookie) + .send({ code }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + // Session cookie is issued. + expect(findCookie(res.headers, 'sencho_token')).toBeDefined(); + // Pending cookie is cleared (empty value or Expires in the past). + const cleared = findCookie(res.headers, 'sencho_mfa_pending'); + expect(cleared).toBeDefined(); + expect(cleared!).toMatch(/sencho_mfa_pending=;/); + }); + + it('rejects a replayed TOTP within the same window', async () => { + // Fresh user so previous test state does not pollute the replay table. + const u = 'mfauser-replay'; + const p = 'mfapassword123'; + const { secret: s } = await seedMfaUser(u, p); + + const login = await request(app).post('/api/auth/login').send({ username: u, password: p }); + const pending = findCookie(login.headers, 'sencho_mfa_pending')!; + const code = authenticator.generate(s); + + const ok = await request(app).post('/api/auth/login/mfa').set('Cookie', pending).send({ code }); + expect(ok.status).toBe(200); + + // Second login, same code, still within this 30s window + const login2 = await request(app).post('/api/auth/login').send({ username: u, password: p }); + const pending2 = findCookie(login2.headers, 'sencho_mfa_pending')!; + const replay = await request(app).post('/api/auth/login/mfa').set('Cookie', pending2).send({ code }); + expect(replay.status).toBe(401); + expect(replay.body.code).toBe('OTP_REPLAY'); + }); + + it('rejects an obviously wrong TOTP', async () => { + const pending = await startChallenge(); + const res = await request(app).post('/api/auth/login/mfa').set('Cookie', pending).send({ code: '000000' }); + expect(res.status).toBe(401); + }); + + it('accepts a backup code and invalidates it on a second submission', async () => { + const u = 'mfauser-backup'; + const p = 'mfapassword123'; + const { backupCodes: codes } = await seedMfaUser(u, p); + const chosen = codes[0]; + + // First use: ok + const login1 = await request(app).post('/api/auth/login').send({ username: u, password: p }); + const pending1 = findCookie(login1.headers, 'sencho_mfa_pending')!; + const first = await request(app) + .post('/api/auth/login/mfa') + .set('Cookie', pending1) + .send({ code: chosen, isBackupCode: true }); + expect(first.status).toBe(200); + + // Second use of the same code: rejected + const login2 = await request(app).post('/api/auth/login').send({ username: u, password: p }); + const pending2 = findCookie(login2.headers, 'sencho_mfa_pending')!; + const second = await request(app) + .post('/api/auth/login/mfa') + .set('Cookie', pending2) + .send({ code: chosen, isBackupCode: true }); + expect(second.status).toBe(401); + + // Remaining backup count decreased by exactly 1 + const remaining = backupCodes.length; + const db = DatabaseService.getInstance(); + const user = db.getUserByUsername(u)!; + const mfa = db.getUserMfa(user.id)!; + const hashes = mfa.backup_codes_json ? (JSON.parse(mfa.backup_codes_json) as string[]) : []; + expect(hashes.length).toBe(remaining - 1); + }); + + it('locks the user after MFA_MAX_FAILED (5) wrong codes and returns 423', async () => { + const u = 'mfauser-lock'; + const p = 'mfapassword123'; + await seedMfaUser(u, p); + + const login = await request(app).post('/api/auth/login').send({ username: u, password: p }); + const pending = findCookie(login.headers, 'sencho_mfa_pending')!; + + let lastStatus = 0; + for (let i = 0; i < 5; i++) { + const r = await request(app) + .post('/api/auth/login/mfa') + .set('Cookie', pending) + .send({ code: '000000' }); + lastStatus = r.status; + } + expect(lastStatus).toBe(423); + + // Any further attempt still 423 + const blocked = await request(app) + .post('/api/auth/login/mfa') + .set('Cookie', pending) + .send({ code: '111111' }); + expect(blocked.status).toBe(423); + }); +}); + +// ─── Partial-auth session guard ─────────────────────────────────────────────── + +describe('authMiddleware partial-auth guard', () => { + it('rejects mfa_pending token on a non-MFA route with 403 MFA_PENDING', async () => { + const pendingToken = jwt.sign( + { scope: 'mfa_pending', user_id: 42, username: 'whoever' }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + const res = await request(app) + .get('/api/stacks') + .set('Authorization', `Bearer ${pendingToken}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('MFA_PENDING'); + }); +}); + +// ─── Enrol / confirm / disable ──────────────────────────────────────────────── + +describe('MFA enrol + confirm', () => { + it('full enrol -> confirm activates MFA and returns 10 backup codes', async () => { + // Create a dedicated user so we do not toggle MFA on the admin. + const start = await request(app) + .post('/api/users') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ username: 'enroller', password: 'enrolpass123', role: 'viewer' }); + expect(start.status).toBe(201); + + const userId = start.body.id as number; + const db = DatabaseService.getInstance(); + const user = db.getUserByUsername('enroller')!; + const userToken = jwt.sign( + { username: 'enroller', role: 'viewer', tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + + const startRes = await request(app) + .post('/api/auth/mfa/enroll/start') + .set('Authorization', `Bearer ${userToken}`); + expect(startRes.status).toBe(200); + expect(typeof startRes.body.otpauthUri).toBe('string'); + expect(typeof startRes.body.secret).toBe('string'); + + // Reject wrong OTP + const wrong = await request(app) + .post('/api/auth/mfa/enroll/confirm') + .set('Authorization', `Bearer ${userToken}`) + .send({ code: '000000' }); + expect(wrong.status).toBe(401); + + const code = authenticator.generate(startRes.body.secret as string); + const confirm = await request(app) + .post('/api/auth/mfa/enroll/confirm') + .set('Authorization', `Bearer ${userToken}`) + .send({ code }); + expect(confirm.status).toBe(200); + expect(Array.isArray(confirm.body.backupCodes)).toBe(true); + expect(confirm.body.backupCodes).toHaveLength(10); + + const mfa = db.getUserMfa(userId); + expect(mfa?.enabled).toBe(1); + }); + + it('rejects enroll/start when already enrolled', async () => { + const db = DatabaseService.getInstance(); + const user = db.getUserByUsername('enroller')!; + const token = jwt.sign( + { username: 'enroller', role: 'viewer', tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const res = await request(app) + .post('/api/auth/mfa/enroll/start') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(409); + }); + + it('disable without a valid code returns 401 and MFA stays enabled', async () => { + const db = DatabaseService.getInstance(); + const user = db.getUserByUsername('enroller')!; + const token = jwt.sign( + { username: 'enroller', role: 'viewer', tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const res = await request(app) + .post('/api/auth/mfa/disable') + .set('Authorization', `Bearer ${token}`) + .send({ code: '000000' }); + expect(res.status).toBe(401); + expect(db.getUserMfa(user.id)?.enabled).toBe(1); + }); +}); + +// ─── Admin reset ────────────────────────────────────────────────────────────── + +describe('POST /api/users/:id/mfa/reset', () => { + it('non-admin caller gets 403', async () => { + // Create a viewer and seed MFA for someone else + const db = DatabaseService.getInstance(); + const { userId: victimId } = await seedMfaUser('victim', 'victimpass123'); + await request(app) + .post('/api/users') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ username: 'nonadmin', password: 'nonadminpass123', role: 'viewer' }); + const nonAdmin = db.getUserByUsername('nonadmin')!; + const nonAdminToken = jwt.sign( + { username: 'nonadmin', role: 'viewer', tv: nonAdmin.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const res = await request(app) + .post(`/api/users/${victimId}/mfa/reset`) + .set('Authorization', `Bearer ${nonAdminToken}`); + expect(res.status).toBe(403); + expect(db.getUserMfa(victimId)?.enabled).toBe(1); + }); + + it('admin clears the target MFA and bumps their token_version', async () => { + const db = DatabaseService.getInstance(); + const { userId } = await seedMfaUser('victim2', 'victim2pass123'); + const before = db.getUser(userId)!.token_version; + const res = await request(app) + .post(`/api/users/${userId}/mfa/reset`) + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + expect(db.getUserMfa(userId)).toBeUndefined(); + expect(db.getUser(userId)!.token_version).toBeGreaterThan(before); + }); +}); + +// ─── SSO bypass toggle ──────────────────────────────────────────────────────── + +describe('PUT /api/auth/mfa/sso-bypass', () => { + it('persists the toggle on an enrolled user', async () => { + const db = DatabaseService.getInstance(); + const { userId } = await seedMfaUser('ssouser', 'ssouserpass123'); + const user = db.getUserById(userId)!; + const token = jwt.sign( + { username: user.username, role: user.role, tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + + const enable = await request(app) + .put('/api/auth/mfa/sso-bypass') + .set('Authorization', `Bearer ${token}`) + .send({ enforce: true }); + expect(enable.status).toBe(200); + expect(db.getUserMfa(userId)?.sso_enforce_mfa).toBe(1); + + const disable = await request(app) + .put('/api/auth/mfa/sso-bypass') + .set('Authorization', `Bearer ${token}`) + .send({ enforce: false }); + expect(disable.status).toBe(200); + expect(db.getUserMfa(userId)?.sso_enforce_mfa).toBe(0); + }); +}); + +// ─── CLI reset helper ───────────────────────────────────────────────────────── + +describe('resetMfaForUser CLI helper', () => { + it('clears MFA and bumps token_version for the target user', async () => { + const { resetMfaForUser } = await import('../cli/resetMfa'); + const db = DatabaseService.getInstance(); + const { userId } = await seedMfaUser('cliuser', 'cliuserpass123'); + const before = db.getUser(userId)!.token_version; + + const result = await resetMfaForUser('cliuser'); + expect(result.ok).toBe(true); + expect(db.getUserMfa(userId)).toBeUndefined(); + expect(db.getUser(userId)!.token_version).toBeGreaterThan(before); + }); + + it('returns ok:false for an unknown username', async () => { + const { resetMfaForUser } = await import('../cli/resetMfa'); + const result = await resetMfaForUser('definitely-not-a-user'); + expect(result.ok).toBe(false); + }); +}); diff --git a/backend/src/cli/resetMfa.ts b/backend/src/cli/resetMfa.ts new file mode 100644 index 00000000..4373e5d0 --- /dev/null +++ b/backend/src/cli/resetMfa.ts @@ -0,0 +1,72 @@ +/** + * Emergency CLI: clear a user's MFA enrolment from a shell inside the + * container, used when the UI is unreachable (e.g. sole admin locked out + * after losing their authenticator AND backup codes). + * + * Run via: + * docker compose exec sencho node dist/cli/resetMfa.js + * + * The target's active sessions are invalidated by bumping `token_version`, + * and the reset is written to the audit log with `actor: 'cli'`. + */ +import { DatabaseService } from '../services/DatabaseService'; + +export interface ResetMfaResult { + ok: boolean; + message: string; +} + +/** + * Import-friendly entry point for tests: resets MFA for `username` and + * returns a structured result. The binary `main()` is a thin wrapper + * around this. + */ +export function resetMfaForUser(username: string): ResetMfaResult { + if (!username || typeof username !== 'string') { + return { ok: false, message: 'Username is required' }; + } + const db = DatabaseService.getInstance(); + const user = db.getUserByUsername(username); + if (!user) { + return { ok: false, message: `User not found: ${username}` }; + } + db.deleteUserMfa(user.id); + db.bumpTokenVersion(user.id); + try { + db.insertAuditLog({ + timestamp: Date.now(), + username: 'cli', + method: 'POST', + path: `/cli/reset-mfa/${username}`, + status_code: 200, + node_id: null, + ip_address: 'cli', + summary: `CLI reset two-factor authentication for ${username}`, + }); + } catch (err) { + // Audit failure should not block the reset itself. + console.warn('[reset-mfa] audit log write failed:', (err as Error).message); + } + return { ok: true, message: `Two-factor authentication cleared for ${username}` }; +} + +/** Binary entry: parse argv, run, exit with the right status code. */ +function main(): void { + const username = process.argv[2]; + if (!username) { + console.error('Usage: node dist/cli/resetMfa.js '); + process.exit(2); + } + const result = resetMfaForUser(username); + if (result.ok) { + console.log(result.message); + process.exit(0); + } else { + console.error(result.message); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 38d19826..82d38cd0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -31,6 +31,8 @@ import { NodeRegistry } from './services/NodeRegistry'; import { LicenseService, type LicenseTier, type LicenseVariant, isLicenseTier, isLicenseVariant, normalizeTier, normalizeVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService'; import { WebhookService } from './services/WebhookService'; import { SSOService } from './services/SSOService'; +import { MfaService } from './services/MfaService'; +import { CryptoService } from './services/CryptoService'; import { SchedulerService } from './services/SchedulerService'; import { RegistryService } from './services/RegistryService'; import { CacheService } from './services/CacheService'; @@ -95,6 +97,9 @@ const PORT = 3000; // Cookie settings const COOKIE_NAME = 'sencho_token'; +const MFA_PENDING_COOKIE_NAME = 'sencho_mfa_pending'; +const MFA_PENDING_SCOPE = 'mfa_pending'; +const MFA_PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes to complete the challenge // Helper to determine if request is secure (HTTPS or behind a proxy that terminates SSL) const isSecureRequest = (req: Request): boolean => { @@ -405,6 +410,10 @@ declare global { proxyTier?: LicenseTier; /** License variant asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */ proxyVariant?: LicenseVariant; + /** User ID carried by a scoped `mfa_pending` token. Only set while the user is completing the MFA challenge. */ + mfaPendingUserId?: number; + /** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */ + mfaPendingSso?: boolean; } } } @@ -436,7 +445,7 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('No JWT secret'); - const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number }; + const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean }; if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session'); @@ -468,6 +477,23 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): return; } + // Partial-auth session: a password/SSO credential has verified, but the + // TOTP second factor is still required. Such a token can only be used to + // complete the MFA challenge or to abort the flow by logging out. Every + // other route must reject it so no privileged action is reachable before + // the second factor clears. + if (decoded.scope === MFA_PENDING_SCOPE) { + const allowedPath = req.path === '/api/auth/login/mfa' || req.path === '/api/auth/logout'; + if (!allowedPath) { + res.status(403).json({ error: 'Two-factor authentication required', code: 'MFA_PENDING' }); + return; + } + req.mfaPendingUserId = typeof decoded.user_id === 'number' ? decoded.user_id : undefined; + req.mfaPendingSso = decoded.sso === true; + next(); + return; + } + // Node proxy tokens: Sencho-to-Sencho communication, not user sessions. // Handle before user resolution since proxy tokens have no username. if (decoded.scope === 'node_proxy') { @@ -536,6 +562,36 @@ function issueSessionCookie( res.cookie(COOKIE_NAME, token, getCookieOptions(req)); } +/** + * Sign a short-lived `mfa_pending` JWT and set it as an httpOnly cookie. This + * represents the partial-auth session that exists between password (or SSO) + * success and TOTP verification. The scope is enforced in `authMiddleware`, so + * this cookie cannot be used to reach any route other than + * `/api/auth/login/mfa` or `/api/auth/logout`. + */ +function issueMfaPendingCookie( + res: Response, + req: Request, + user: { id: number; username: string }, + jwtSecret: string, + opts: { sso?: boolean } = {}, +): void { + const token = jwt.sign( + { scope: MFA_PENDING_SCOPE, user_id: user.id, username: user.username, sso: opts.sso === true }, + jwtSecret, + { expiresIn: Math.floor(MFA_PENDING_TTL_MS / 1000) }, + ); + res.cookie(MFA_PENDING_COOKIE_NAME, token, { + ...getCookieOptions(req), + maxAge: MFA_PENDING_TTL_MS, + }); +} + +/** Clear the partial-auth cookie. Called on successful MFA verification and on logout. */ +function clearMfaPendingCookie(res: Response, req: Request): void { + res.clearCookie(MFA_PENDING_COOKIE_NAME, getCookieOptions(req)); +} + // Rate limiter for auth endpoints - prevents brute-force attacks. // Production: 5 attempts per 15-minute window per IP. // Development: 100 attempts (so E2E tests and local tooling are not blocked). @@ -570,15 +626,30 @@ app.get('/api/meta', (_req: Request, res: Response): void => { // Auth Routes (no authentication required) -// Check if setup is needed +// Check if setup is needed, and whether the caller currently holds a valid +// `mfa_pending` partial-auth cookie (so the frontend can route to the +// challenge screen on a page reload mid-flow, for example after an OIDC +// redirect). app.get('/api/auth/status', async (req: Request, res: Response): Promise => { try { const settings = DatabaseService.getInstance().getGlobalSettings(); const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret; - res.json({ needsSetup }); + + let mfaPending = false; + const mfaCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME]; + if (mfaCookie && settings.auth_jwt_secret) { + try { + const decoded = jwt.verify(mfaCookie, settings.auth_jwt_secret) as { scope?: string }; + mfaPending = decoded.scope === MFA_PENDING_SCOPE; + } catch { + // Expired or invalid cookie; treat as no pending challenge. + } + } + + res.json({ needsSetup, mfaPending }); } catch (error) { console.error('Error checking setup status:', error); - res.json({ needsSetup: true }); + res.json({ needsSetup: true, mfaPending: false }); } }); @@ -654,6 +725,18 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response) const settings = db.getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('JWT secret missing from DB'); + + // If MFA is enabled for this user, issue only the partial-auth cookie + // and signal the client to complete the TOTP challenge. No session + // cookie is set until the second factor is verified. + const mfa = db.getUserMfa(user.id); + if (mfa?.enabled) { + issueMfaPendingCookie(res, req, user, jwtSecret); + console.log('[Auth] Login password OK, MFA challenge pending:', user.username); + res.json({ success: true, mfaRequired: true }); + return; + } + issueSessionCookie(res, req, user, jwtSecret); console.log('[Auth] Login successful:', user.username); res.json({ success: true, message: 'Login successful' }); @@ -726,6 +809,9 @@ app.post('/api/auth/logout', (req: Request, res: Response): void => { secure: isSecureRequest(req), sameSite: 'strict', }); + // Also clear any partial-auth cookie so a user aborting the MFA challenge + // is returned to a fully unauthenticated state. + clearMfaPendingCookie(res, req); res.json({ success: true, message: 'Logged out successfully' }); }); @@ -806,6 +892,17 @@ app.post('/api/auth/sso/ldap', authRateLimiter, async (req: Request, res: Respon // Issue JWT (same as local login) const settings = DatabaseService.getInstance().getGlobalSettings(); + + // If MFA is enabled AND the user has opted into SSO enforcement, route + // through the TOTP challenge. Otherwise SSO bypasses MFA (default). + const mfa = DatabaseService.getInstance().getUserMfa(user.id); + if (mfa?.enabled && mfa.sso_enforce_mfa) { + issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true }); + console.log(`[SSO] LDAP login password OK, MFA challenge pending: ${user.username}`); + res.json({ success: true, mfaRequired: true }); + return; + } + issueSessionCookie(res, req, user, settings.auth_jwt_secret); console.log(`[SSO] LDAP login successful: ${user.username}`); res.json({ success: true, message: 'Login successful' }); @@ -918,6 +1015,18 @@ app.get('/api/auth/sso/oidc/:provider/callback', ssoRateLimiter, async (req: Req // Issue JWT + cookie (same as local login) const settings = DatabaseService.getInstance().getGlobalSettings(); + + // If MFA is enabled AND the user has opted into SSO enforcement, set only + // the partial-auth cookie. The frontend surfaces the challenge screen + // based on `/api/auth/status` after the redirect lands. + const mfa = DatabaseService.getInstance().getUserMfa(user.id); + if (mfa?.enabled && mfa.sso_enforce_mfa) { + issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true }); + console.log(`[SSO] OIDC login password OK, MFA challenge pending: ${user.username} via ${provider}`); + res.redirect('/'); + return; + } + issueSessionCookie(res, req, user, settings.auth_jwt_secret); console.log(`[SSO] OIDC login successful: ${user.username} via ${provider}`); @@ -929,6 +1038,382 @@ app.get('/api/auth/sso/oidc/:provider/callback', ssoRateLimiter, async (req: Req } }); +// --- MFA (TOTP) Routes --- + +const MFA_MAX_FAILED = 5; +const MFA_LOCKOUT_MS = 15 * 60 * 1000; +const MFA_REPLAY_TTL_MS = 120 * 1000; +const MFA_REPLAY_PURGE_INTERVAL_MS = 60 * 1000; + +/** + * Complete the second factor of login. Consumes the short-lived + * `sencho_mfa_pending` cookie and, on success, clears it and issues a full + * session cookie. Accepts either a 6-digit TOTP or one of the user's backup + * codes (single-use). Enforces per-user failure counter and lockout. + */ +app.post('/api/auth/login/mfa', authRateLimiter, async (req: Request, res: Response): Promise => { + try { + const db = DatabaseService.getInstance(); + const settings = db.getGlobalSettings(); + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) { + res.status(500).json({ error: 'Server is not configured' }); + return; + } + + const pendingCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME]; + if (!pendingCookie) { + res.status(401).json({ error: 'No pending two-factor challenge. Please sign in again.' }); + return; + } + + let decoded: { scope?: string; user_id?: number; username?: string; sso?: boolean }; + try { + decoded = jwt.verify(pendingCookie, jwtSecret) as typeof decoded; + } catch { + clearMfaPendingCookie(res, req); + res.status(401).json({ error: 'Two-factor challenge expired. Please sign in again.' }); + return; + } + + if (decoded.scope !== MFA_PENDING_SCOPE || typeof decoded.user_id !== 'number') { + clearMfaPendingCookie(res, req); + res.status(401).json({ error: 'Invalid two-factor challenge' }); + return; + } + + const user = db.getUserById(decoded.user_id); + const mfa = db.getUserMfa(decoded.user_id); + if (!user || !mfa?.enabled || !mfa.totp_secret_encrypted) { + clearMfaPendingCookie(res, req); + res.status(401).json({ error: 'Two-factor authentication is not configured' }); + return; + } + + // Lockout check + if (mfa.locked_until && mfa.locked_until > Date.now()) { + const retryAfter = Math.ceil((mfa.locked_until - Date.now()) / 1000); + res.setHeader('Retry-After', String(retryAfter)); + res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter }); + return; + } + + const rawCode = typeof req.body?.code === 'string' ? req.body.code : ''; + const isBackup = req.body?.isBackupCode === true; + if (!rawCode) { + res.status(400).json({ error: 'A verification code is required' }); + return; + } + + const cryptoSvc = CryptoService.getInstance(); + const secret = cryptoSvc.decrypt(mfa.totp_secret_encrypted); + let verified = false; + + if (isBackup) { + const hashes: string[] = mfa.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : []; + const result = await MfaService.verifyBackupCode(hashes, rawCode); + if (result.matched) { + db.upsertUserMfa(decoded.user_id, { backup_codes_json: JSON.stringify(result.remainingHashes) }); + verified = true; + } + } else { + const trimmed = rawCode.trim().replace(/\s+/g, ''); + if (MfaService.verifyTotp(secret, trimmed)) { + const window = MfaService.currentWindow(); + if (db.isMfaCodeUsed(decoded.user_id, trimmed, window)) { + db.recordMfaFailure(decoded.user_id); + res.status(401).json({ error: 'This code was already used. Please wait for the next one.', code: 'OTP_REPLAY' }); + return; + } + db.markMfaCodeUsed(decoded.user_id, trimmed, window); + verified = true; + } + } + + if (!verified) { + const failedCount = db.recordMfaFailure(decoded.user_id); + if (failedCount >= MFA_MAX_FAILED) { + db.lockMfa(decoded.user_id, Date.now() + MFA_LOCKOUT_MS); + res.setHeader('Retry-After', String(Math.ceil(MFA_LOCKOUT_MS / 1000))); + res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter: Math.ceil(MFA_LOCKOUT_MS / 1000) }); + return; + } + res.status(401).json({ error: 'Invalid verification code' }); + return; + } + + db.clearMfaFailures(decoded.user_id); + clearMfaPendingCookie(res, req); + issueSessionCookie(res, req, user, jwtSecret); + console.log('[Auth] MFA challenge cleared:', user.username); + res.json({ success: true }); + } catch (error: unknown) { + console.error('[Auth] MFA verification error:', (error as Error).message); + res.status(500).json({ error: 'Two-factor verification failed' }); + } +}); + +/** Report the current user's MFA state. Used by the Account settings UI. */ +app.get('/api/auth/mfa/status', authMiddleware, (req: Request, res: Response): void => { + try { + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + const db = DatabaseService.getInstance(); + const mfa = db.getUserMfa(req.user.userId); + const hashes: string[] = mfa?.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : []; + res.json({ + enabled: mfa?.enabled === 1, + backupCodesRemaining: hashes.length, + sso_enforce_mfa: mfa?.sso_enforce_mfa === 1, + }); + } catch (error: unknown) { + console.error('[MFA] status error:', (error as Error).message); + res.status(500).json({ error: 'Failed to load MFA status' }); + } +}); + +/** + * Begin enrolment: generate a fresh TOTP secret, store it encrypted with + * `enabled=0`, and return the otpauth URI plus the raw base32 secret so the + * frontend can render a QR code and the manual-entry fallback. + */ +app.post('/api/auth/mfa/enroll/start', authMiddleware, (req: Request, res: Response): void => { + try { + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' }); + return; + } + const db = DatabaseService.getInstance(); + const existing = db.getUserMfa(req.user.userId); + if (existing?.enabled) { + res.status(409).json({ error: 'Two-factor authentication is already enabled' }); + return; + } + + const secret = MfaService.generateSecret(); + const cryptoSvc = CryptoService.getInstance(); + db.upsertUserMfa(req.user.userId, { + enabled: false, + totp_secret_encrypted: cryptoSvc.encrypt(secret), + backup_codes_json: null, + failed_attempts: 0, + locked_until: null, + }); + + const otpauthUri = MfaService.buildOtpauthUri(secret, req.user.username); + res.json({ otpauthUri, secret }); + } catch (error: unknown) { + console.error('[MFA] enroll start error:', (error as Error).message); + res.status(500).json({ error: 'Failed to start enrolment' }); + } +}); + +/** + * Finalise enrolment: verify the user's first TOTP against the pending + * secret, flip `enabled=1`, generate + hash + return the backup codes ONCE, + * and bump `token_version` so any other sessions re-authenticate. + */ +app.post('/api/auth/mfa/enroll/confirm', authMiddleware, async (req: Request, res: Response): Promise => { + try { + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' }); + return; + } + const code = typeof req.body?.code === 'string' ? req.body.code : ''; + if (!code) { + res.status(400).json({ error: 'A verification code is required' }); + return; + } + + const db = DatabaseService.getInstance(); + const mfa = db.getUserMfa(req.user.userId); + if (!mfa?.totp_secret_encrypted) { + res.status(400).json({ error: 'No enrolment in progress. Start enrolment first.' }); + return; + } + if (mfa.enabled) { + res.status(409).json({ error: 'Two-factor authentication is already enabled' }); + return; + } + + const cryptoSvc = CryptoService.getInstance(); + const secret = cryptoSvc.decrypt(mfa.totp_secret_encrypted); + if (!MfaService.verifyTotp(secret, code)) { + res.status(401).json({ error: 'Invalid verification code' }); + return; + } + + const backupCodes = MfaService.generateBackupCodes(); + const hashes = await MfaService.hashBackupCodes(backupCodes); + db.upsertUserMfa(req.user.userId, { + enabled: true, + backup_codes_json: JSON.stringify(hashes), + failed_attempts: 0, + locked_until: null, + }); + db.bumpTokenVersion(req.user.userId); + + // The token_version bump invalidates the caller's current session cookie. + // Since the user has just proven possession of the TOTP secret, re-issue a + // session cookie that carries the new token_version so they stay signed in + // long enough to see and save the backup codes. + const refreshed = db.getUserById(req.user.userId); + const settings = db.getGlobalSettings(); + if (refreshed && settings.auth_jwt_secret) { + issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret); + } + + res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) }); + } catch (error: unknown) { + console.error('[MFA] enroll confirm error:', (error as Error).message); + res.status(500).json({ error: 'Failed to confirm enrolment' }); + } +}); + +/** + * Disable MFA for the current user. Requires a valid TOTP or backup code to + * prove possession, so a stolen session cookie alone cannot turn off the + * second factor. Bumps `token_version` on success. + */ +app.post('/api/auth/mfa/disable', authMiddleware, async (req: Request, res: Response): Promise => { + try { + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' }); + return; + } + const code = typeof req.body?.code === 'string' ? req.body.code : ''; + const isBackup = req.body?.isBackupCode === true; + if (!code) { + res.status(400).json({ error: 'A verification code is required to disable two-factor authentication' }); + return; + } + + const db = DatabaseService.getInstance(); + const mfa = db.getUserMfa(req.user.userId); + if (!mfa?.enabled || !mfa.totp_secret_encrypted) { + res.status(400).json({ error: 'Two-factor authentication is not enabled' }); + return; + } + + let ok = false; + if (isBackup) { + const hashes: string[] = mfa.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : []; + ok = (await MfaService.verifyBackupCode(hashes, code)).matched; + } else { + const cryptoSvc = CryptoService.getInstance(); + ok = MfaService.verifyTotp(cryptoSvc.decrypt(mfa.totp_secret_encrypted), code); + } + + if (!ok) { + res.status(401).json({ error: 'Invalid verification code' }); + return; + } + + db.deleteUserMfa(req.user.userId); + db.bumpTokenVersion(req.user.userId); + + // Re-issue the session cookie so the user stays signed in after the bump. + // They just proved possession of a current factor, so granting them the + // new token_version is safe and avoids a surprising forced re-login. + const refreshed = db.getUserById(req.user.userId); + const settings = db.getGlobalSettings(); + if (refreshed && settings.auth_jwt_secret) { + issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret); + } + + res.json({ success: true }); + } catch (error: unknown) { + console.error('[MFA] disable error:', (error as Error).message); + res.status(500).json({ error: 'Failed to disable two-factor authentication' }); + } +}); + +/** + * Regenerate backup codes. Requires a valid TOTP so a stolen session alone + * cannot print new codes. The old set is invalidated immediately; the new + * set is returned in cleartext ONCE. + */ +app.post('/api/auth/mfa/backup-codes/regenerate', authMiddleware, async (req: Request, res: Response): Promise => { + try { + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' }); + return; + } + const code = typeof req.body?.code === 'string' ? req.body.code : ''; + if (!code) { + res.status(400).json({ error: 'A verification code is required' }); + return; + } + + const db = DatabaseService.getInstance(); + const mfa = db.getUserMfa(req.user.userId); + if (!mfa?.enabled || !mfa.totp_secret_encrypted) { + res.status(400).json({ error: 'Two-factor authentication is not enabled' }); + return; + } + + const cryptoSvc = CryptoService.getInstance(); + if (!MfaService.verifyTotp(cryptoSvc.decrypt(mfa.totp_secret_encrypted), code)) { + res.status(401).json({ error: 'Invalid verification code' }); + return; + } + + const backupCodes = MfaService.generateBackupCodes(); + const hashes = await MfaService.hashBackupCodes(backupCodes); + db.upsertUserMfa(req.user.userId, { backup_codes_json: JSON.stringify(hashes) }); + res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) }); + } catch (error: unknown) { + console.error('[MFA] regenerate backup codes error:', (error as Error).message); + res.status(500).json({ error: 'Failed to regenerate backup codes' }); + } +}); + +/** Toggle whether SSO logins must also complete the TOTP challenge. */ +app.put('/api/auth/mfa/sso-bypass', authMiddleware, (req: Request, res: Response): void => { + try { + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' }); + return; + } + const enforce = req.body?.enforce === true; + const db = DatabaseService.getInstance(); + const mfa = db.getUserMfa(req.user.userId); + if (!mfa?.enabled) { + res.status(400).json({ error: 'Two-factor authentication is not enabled' }); + return; + } + if ((mfa.sso_enforce_mfa === 1) !== enforce) { + db.upsertUserMfa(req.user.userId, { sso_enforce_mfa: enforce }); + } + res.json({ success: true, sso_enforce_mfa: enforce }); + } catch (error: unknown) { + console.error('[MFA] sso-bypass error:', (error as Error).message); + res.status(500).json({ error: 'Failed to update SSO enforcement' }); + } +}); + // Apply authentication middleware to all /api/* routes except /api/auth/* app.use('/api', (req: Request, res: Response, next: NextFunction): void => { if (req.path.startsWith('/auth/') || /^\/webhooks\/\d+\/trigger$/.test(req.path)) { @@ -2393,8 +2878,14 @@ app.get('/api/users', authMiddleware, async (req: Request, res: Response): Promi } if (!requireAdmin(req, res)) return; try { - const users = DatabaseService.getInstance().getUsers(); - res.json(users); + const db = DatabaseService.getInstance(); + const users = db.getUsers(); + const mfaUserIds = db.getUsersWithMfaEnabled(); + const enriched = users.map((u) => ({ + ...u, + mfaEnabled: mfaUserIds.has(u.id), + })); + res.json(enriched); } catch (error) { console.error('[Users] List error:', error); res.status(500).json({ error: 'Failed to fetch users' }); @@ -2574,6 +3065,54 @@ app.delete('/api/users/:id', authMiddleware, async (req: Request, res: Response) } }); +/** + * Admin reset: clear a target user's MFA enrolment and force re-auth. Used + * when a user has lost their authenticator AND exhausted their backup codes, + * and another admin is available. For total lockout (including sole admin), + * see the CLI `reset-mfa` command. + */ +app.post('/api/users/:id/mfa/reset', authMiddleware, (req: Request, res: Response): void => { + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' }); + return; + } + if (!requireAdmin(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (!Number.isFinite(id)) { + res.status(400).json({ error: 'Invalid user id' }); + return; + } + const db = DatabaseService.getInstance(); + const target = db.getUser(id); + if (!target) { + res.status(404).json({ error: 'User not found' }); + return; + } + db.deleteUserMfa(id); + db.bumpTokenVersion(id); + try { + db.insertAuditLog({ + timestamp: Date.now(), + username: req.user!.username, + method: 'POST', + path: req.originalUrl, + status_code: 200, + node_id: null, + ip_address: req.ip || 'unknown', + summary: `Admin reset two-factor authentication for ${target.username}`, + }); + } catch (err) { + console.warn('[MFA] Admin reset audit log write failed:', (err as Error).message); + } + console.log('[MFA] Admin reset: target=', target.username, 'by=', req.user!.username); + res.json({ success: true }); + } catch (error: unknown) { + console.error('[MFA] Admin reset error:', (error as Error).message); + res.status(500).json({ error: 'Failed to reset two-factor authentication' }); + } +}); + // --- Scoped Role Assignments (Admiral) --- app.get('/api/users/:id/roles', authMiddleware, (req: Request, res: Response): void => { @@ -6798,6 +7337,8 @@ if (process.env.NODE_ENV === 'production') { } // Start server with migration +let mfaReplayPurgeTimer: NodeJS.Timeout | null = null; + async function startServer() { try { // Run migration before starting server @@ -6833,6 +7374,18 @@ async function startServer() { console.warn('[GitSource] Temp dir sweep failed:', (err as Error).message); }); + // Periodic purge of used-MFA-code rows so the replay blacklist stays + // bounded even without verification traffic. The table holds (user, code, + // window) tuples for the last ~2 minutes; older rows are safe to drop. + mfaReplayPurgeTimer = setInterval(() => { + try { + DatabaseService.getInstance().purgeOldMfaCodes(Date.now() - MFA_REPLAY_TTL_MS); + } catch (err) { + console.warn('[MFA] Replay purge failed:', (err as Error).message); + } + }, MFA_REPLAY_PURGE_INTERVAL_MS); + mfaReplayPurgeTimer.unref(); + server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); @@ -6869,6 +7422,10 @@ const gracefulShutdown = (signal: string) => { try { SchedulerService.getInstance().stop(); } catch (e) { console.warn('[Shutdown] SchedulerService cleanup failed:', (e as Error).message); } + if (mfaReplayPurgeTimer) { + clearInterval(mfaReplayPurgeTimer); + mfaReplayPurgeTimer = null; + } try { DatabaseService.getInstance().getDb().close(); } catch (e) { console.warn('[Shutdown] Database close failed:', (e as Error).message); } diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 27dd6173..c7484890 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -112,6 +112,27 @@ export interface User { updated_at: number; } +export interface UserMfa { + user_id: number; + enabled: number; + totp_secret_encrypted: string | null; + backup_codes_json: string | null; + sso_enforce_mfa: number; + failed_attempts: number; + locked_until: number | null; + created_at: number; + updated_at: number; +} + +export type UserMfaUpdate = Partial<{ + enabled: boolean; + totp_secret_encrypted: string | null; + backup_codes_json: string | null; + sso_enforce_mfa: boolean; + failed_attempts: number; + locked_until: number | null; +}>; + export interface RoleAssignment { id: number; user_id: number; @@ -487,6 +508,28 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_label_assignments_stack ON stack_label_assignments(stack_name, node_id); + CREATE TABLE IF NOT EXISTS user_mfa ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT 0, + totp_secret_encrypted TEXT, + backup_codes_json TEXT, + sso_enforce_mfa INTEGER NOT NULL DEFAULT 0, + failed_attempts INTEGER NOT NULL DEFAULT 0, + locked_until INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS mfa_used_tokens ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code TEXT NOT NULL, + window INTEGER NOT NULL, + used_at INTEGER NOT NULL, + PRIMARY KEY (user_id, code, window) + ); + + CREATE INDEX IF NOT EXISTS idx_mfa_used_tokens_used_at ON mfa_used_tokens(used_at); + CREATE TABLE IF NOT EXISTS stack_git_sources ( id INTEGER PRIMARY KEY AUTOINCREMENT, stack_name TEXT NOT NULL UNIQUE, @@ -1250,6 +1293,115 @@ export class DatabaseService { this.db.prepare('UPDATE users SET token_version = token_version + 1, updated_at = ? WHERE id = ?').run(Date.now(), userId); } + // --- User MFA --- + + public getUserMfa(userId: number): UserMfa | undefined { + return this.db.prepare('SELECT * FROM user_mfa WHERE user_id = ?').get(userId) as UserMfa | undefined; + } + + /** + * Create or merge a user_mfa row. Any field left undefined on the update + * object is preserved. Boolean flags are normalized to 0/1. + */ + public upsertUserMfa(userId: number, updates: UserMfaUpdate): void { + const now = Date.now(); + const existing = this.getUserMfa(userId); + + if (!existing) { + this.db.prepare( + `INSERT INTO user_mfa + (user_id, enabled, totp_secret_encrypted, backup_codes_json, sso_enforce_mfa, + failed_attempts, locked_until, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + userId, + updates.enabled ? 1 : 0, + updates.totp_secret_encrypted ?? null, + updates.backup_codes_json ?? null, + updates.sso_enforce_mfa ? 1 : 0, + updates.failed_attempts ?? 0, + updates.locked_until ?? null, + now, + now, + ); + return; + } + + const fields: string[] = []; + const values: (string | number | null)[] = []; + if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); } + if (updates.totp_secret_encrypted !== undefined) { fields.push('totp_secret_encrypted = ?'); values.push(updates.totp_secret_encrypted); } + if (updates.backup_codes_json !== undefined) { fields.push('backup_codes_json = ?'); values.push(updates.backup_codes_json); } + if (updates.sso_enforce_mfa !== undefined) { fields.push('sso_enforce_mfa = ?'); values.push(updates.sso_enforce_mfa ? 1 : 0); } + if (updates.failed_attempts !== undefined) { fields.push('failed_attempts = ?'); values.push(updates.failed_attempts); } + if (updates.locked_until !== undefined) { fields.push('locked_until = ?'); values.push(updates.locked_until); } + + if (fields.length === 0) return; + + fields.push('updated_at = ?'); + values.push(now); + values.push(userId); + this.db.prepare(`UPDATE user_mfa SET ${fields.join(', ')} WHERE user_id = ?`).run(...values); + } + + public deleteUserMfa(userId: number): void { + this.db.prepare('DELETE FROM user_mfa WHERE user_id = ?').run(userId); + this.db.prepare('DELETE FROM mfa_used_tokens WHERE user_id = ?').run(userId); + } + + /** + * Single-query helper to enrich a user list with MFA status without the + * N+1 cost of calling getUserMfa() per row. + */ + public getUsersWithMfaEnabled(): Set { + const rows = this.db.prepare('SELECT user_id FROM user_mfa WHERE enabled = 1').all() as { user_id: number }[]; + return new Set(rows.map((r) => r.user_id)); + } + + public recordMfaFailure(userId: number): number { + const row = this.db.prepare( + `UPDATE user_mfa + SET failed_attempts = failed_attempts + 1, + updated_at = ? + WHERE user_id = ? + RETURNING failed_attempts` + ).get(Date.now(), userId) as { failed_attempts: number } | undefined; + return row?.failed_attempts ?? 0; + } + + public clearMfaFailures(userId: number): void { + this.db.prepare( + `UPDATE user_mfa + SET failed_attempts = 0, + locked_until = NULL, + updated_at = ? + WHERE user_id = ?` + ).run(Date.now(), userId); + } + + public lockMfa(userId: number, untilMs: number): void { + this.db.prepare( + `UPDATE user_mfa SET locked_until = ?, updated_at = ? WHERE user_id = ?` + ).run(untilMs, Date.now(), userId); + } + + public isMfaCodeUsed(userId: number, code: string, window: number): boolean { + const row = this.db.prepare( + 'SELECT 1 FROM mfa_used_tokens WHERE user_id = ? AND code = ? AND window = ?' + ).get(userId, code, window); + return !!row; + } + + public markMfaCodeUsed(userId: number, code: string, window: number): void { + this.db.prepare( + 'INSERT OR IGNORE INTO mfa_used_tokens (user_id, code, window, used_at) VALUES (?, ?, ?, ?)' + ).run(userId, code, window, Date.now()); + } + + public purgeOldMfaCodes(olderThanMs: number): void { + this.db.prepare('DELETE FROM mfa_used_tokens WHERE used_at < ?').run(olderThanMs); + } + // --- Role Assignments --- public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] { diff --git a/backend/src/services/MfaService.ts b/backend/src/services/MfaService.ts new file mode 100644 index 00000000..33a105c3 --- /dev/null +++ b/backend/src/services/MfaService.ts @@ -0,0 +1,140 @@ +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; +import { authenticator } from 'otplib'; +import { HashAlgorithms } from '@otplib/core'; + +// Configure otplib for the default TOTP contract we present to users: +// - 6 digits +// - 30-second step +// - SHA-1 (the universally supported default for authenticator apps) +// - ±1 step tolerance, so the server accepts the previous, current, and next code +// to cover small clock drift between the device and the server. +authenticator.options = { + digits: 6, + step: 30, + algorithm: HashAlgorithms.SHA1, + window: 1, +}; + +const BACKUP_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Crockford-like, no 0/O/1/I/L +const BACKUP_CODE_LENGTH = 10; +const BACKUP_CODE_COUNT = 10; +const BACKUP_HASH_COST = 10; + +export interface BackupVerifyResult { + matched: boolean; + remainingHashes: string[]; +} + +export class MfaService { + /** + * Generate a fresh base32 TOTP secret ready for `buildOtpauthUri` and + * `verifyTotp`. Each user should receive a unique secret. + */ + public static generateSecret(): string { + return authenticator.generateSecret(); + } + + /** + * Build an `otpauth://` URI for QR-code rendering or manual entry. The + * label follows the RFC 6238 format `Issuer:account` so the authenticator + * app can label the entry clearly. + */ + public static buildOtpauthUri(secret: string, username: string, issuer = 'Sencho'): string { + return authenticator.keyuri(username, issuer, secret); + } + + /** + * Verify a TOTP code against the stored secret. Uses the window tolerance + * configured above, so a code is accepted if it matches the previous, + * current, or next 30-second step. + */ + public static verifyTotp(secret: string, code: string): boolean { + if (!secret || !code) return false; + const trimmed = code.trim().replace(/\s+/g, ''); + if (!/^\d{6}$/.test(trimmed)) return false; + try { + return authenticator.check(trimmed, secret); + } catch { + return false; + } + } + + /** + * Return the integer Unix step for the current time. Used to key the + * replay-prevention blacklist so a given (user, code, window) combination + * can only be used once. + */ + public static currentWindow(nowMs: number = Date.now()): number { + return Math.floor(nowMs / 1000 / 30); + } + + /** + * Generate a fresh set of backup codes in cleartext. Callers should pass + * these through `hashBackupCodes` before persistence and show the + * cleartext to the user exactly once. + */ + public static generateBackupCodes(count: number = BACKUP_CODE_COUNT): string[] { + const codes: string[] = []; + for (let i = 0; i < count; i++) { + codes.push(this.randomBackupCode()); + } + return codes; + } + + /** + * Hash each backup code with bcrypt so the stored form cannot be replayed + * even if the database is leaked. + */ + public static async hashBackupCodes(codes: string[]): Promise { + return Promise.all(codes.map((code) => bcrypt.hash(this.normalizeBackupCode(code), BACKUP_HASH_COST))); + } + + /** + * Check a user-supplied backup code against the stored hashes. Returns + * `{ matched, remainingHashes }`; when matched, the matched hash is + * removed so callers can persist the shrunk set and enforce single-use + * semantics. + */ + public static async verifyBackupCode(hashes: string[], code: string): Promise { + const normalized = this.normalizeBackupCode(code); + if (!normalized) return { matched: false, remainingHashes: hashes }; + + for (let i = 0; i < hashes.length; i++) { + // bcrypt.compare is constant-time for a given hash. We still check + // every hash regardless of an early hit to avoid leaking which + // slot matched via timing. + const ok = await bcrypt.compare(normalized, hashes[i]); + if (ok) { + const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1)); + return { matched: true, remainingHashes: remaining }; + } + } + return { matched: false, remainingHashes: hashes }; + } + + /** + * Display helper: group a 10-character backup code as `ABCDE-FGHIJ` so + * it is easier for the user to read and transcribe. + */ + public static formatBackupCodeForDisplay(code: string): string { + const normalized = this.normalizeBackupCode(code); + if (normalized.length !== BACKUP_CODE_LENGTH) return normalized; + return `${normalized.slice(0, 5)}-${normalized.slice(5)}`; + } + + /** Uppercase, strip non-alphanumeric separators (e.g. dashes, spaces). */ + public static normalizeBackupCode(code: string): string { + if (!code) return ''; + return code.toUpperCase().replace(/[^A-Z0-9]/g, ''); + } + + private static randomBackupCode(): string { + const bytes = crypto.randomBytes(BACKUP_CODE_LENGTH); + let out = ''; + for (let i = 0; i < BACKUP_CODE_LENGTH; i++) { + out += BACKUP_CODE_ALPHABET[bytes[i] % BACKUP_CODE_ALPHABET.length]; + } + return out; + } +} diff --git a/docs/docs.json b/docs/docs.json index a4c2668f..63148953 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -118,6 +118,7 @@ "features/auto-update-policies", "features/scheduled-operations", "features/sso", + "features/two-factor-authentication", "features/licensing" ] }, @@ -137,7 +138,8 @@ "operations/troubleshooting", "operations/backup", "operations/upgrade", - "operations/self-hosting" + "operations/self-hosting", + "operations/two-factor-admin" ] } ] diff --git a/docs/features/sso.mdx b/docs/features/sso.mdx index 47e4d35d..9b516052 100644 --- a/docs/features/sso.mdx +++ b/docs/features/sso.mdx @@ -200,3 +200,7 @@ If not set, Sencho auto-detects the URL from the request's `Host` header and pro - **Admin-only configuration** - Only Admiral administrators can enable or configure SSO providers For common SSO issues (LDAP connection errors, OAuth callback mismatches, SSO buttons not appearing), see the [Troubleshooting](/operations/troubleshooting#ldap-connection-refused) page. + +## Combining SSO with two-factor authentication + +SSO and [two-factor authentication](/features/two-factor-authentication) can coexist. By default, users who have 2FA enrolled skip the TOTP challenge when they sign in through SSO, since the identity provider has already authenticated them. Users who want stricter sign-in can opt in to requiring 2FA on SSO from their Account & Security screen. diff --git a/docs/features/two-factor-authentication.mdx b/docs/features/two-factor-authentication.mdx new file mode 100644 index 00000000..989215aa --- /dev/null +++ b/docs/features/two-factor-authentication.mdx @@ -0,0 +1,120 @@ +--- +title: Two-Factor Authentication +description: Protect your Sencho account with a time-based one-time password (TOTP) and single-use backup codes. +--- + +Two-factor authentication (2FA) adds a second step to sign-in. After your password is accepted, Sencho asks for a six-digit code from your authenticator app. Someone who steals your password still cannot sign in without that code. + +2FA is available on all Sencho tiers, including Community. + +## How it works + +1. You enrol once by scanning a QR code with an authenticator app (or typing a secret by hand) +2. The app generates a fresh six-digit code every 30 seconds +3. On every sign-in, Sencho asks for the current code after your password passes +4. You also save ten single-use backup codes for the days when your phone is not available + + + Sign-in challenge screen asking for the six-digit code + + +## Enrol + +Open **Settings → Account & Security** and click **Set up 2FA**. Sencho walks you through three steps. + +### Step 1 – Scan the QR code + +Scan the QR code with an authenticator app such as **1Password**, **Bitwarden**, **Google Authenticator**, **Authy**, or **Microsoft Authenticator**. + + + Enrolment dialog showing the QR code + + +If your app cannot scan or you would rather type the secret into a password manager, click **Can't scan? Show secret key** and copy the base32 string. Paste it into the app's manual-entry field. + +### Step 2 – Confirm + +Enter the six-digit code your app is currently showing. This proves the app is paired correctly before Sencho turns 2FA on. + + + Enrolment confirmation step asking for the six-digit code + + +### Step 3 – Save your backup codes + +Sencho shows ten single-use backup codes. Each code can sign you in once without the authenticator app. **Save them somewhere safe before closing this dialog**, they are not shown again. + + + Ten backup codes shown after enrolment + + +Copy them to a password manager or download them as a text file. Common places users store them: + +- Their primary password manager as a secure note on the Sencho entry +- An encrypted document on a separate device +- Printed and stored with other emergency recovery material + +## Sign in with 2FA + +After entering your password, Sencho shows the 2FA challenge screen. + +1. Open your authenticator app, find the Sencho entry, read the six-digit code +2. Type it into the **Verification code** field +3. Click **Verify and sign in** + +If your phone is unavailable, click **Use a backup code instead**, enter one of the codes you saved during enrolment, and click **Verify and sign in**. That code is now used up. + +## Regenerate backup codes + +If you think your backup codes have been exposed, or you have used most of them, regenerate them from **Settings → Account & Security → Regenerate backup codes**. Sencho asks for a current code, then issues a fresh set of ten and invalidates the old set immediately. + + + Account card showing 2FA enabled with regenerate and disable actions + + +## Disable 2FA + +From the same Account & Security section, click **Disable 2FA**. Sencho asks you to confirm with a current authenticator code (or a backup code) so that no one else can disable protection without your phone. Once disabled, only your password protects the account. + +## Signing in with SSO + +If Sencho is configured with LDAP or an OIDC provider (Google, GitHub, Okta), the SSO flow signs you in without the second factor by default. Your identity provider has already authenticated you. + +If you want Sencho to ask for a TOTP code even after a successful SSO sign-in, flip the **Require 2FA even when signing in via SSO** switch on the Account & Security card. The switch only appears when at least one SSO provider is enabled. + + + Toggle that requires 2FA even when signing in via SSO + + +## Troubleshooting + +### "Code invalid" even though the code is current + +The most common cause is clock drift between your phone and the Sencho server. Authenticator apps compute codes from the current Unix time, and even a 30 second skew breaks verification. + +- On iOS, open **Settings → General → Date & Time** and enable **Set Automatically** +- On Android, open **Settings → System → Date & time** and enable **Automatic date & time** +- In Google Authenticator, open the menu → **Settings → Time correction for codes → Sync now** +- In Authy, open **Settings → My account → Sync device time** + +If your server runs in a container, make sure its host clock is synced with NTP. + +### Wrong account selected in the authenticator + +Authenticator apps let you store many accounts. If you have more than one Sencho instance, or a Sencho entry and a different service that looks similar, pick the correct one. The issuer label Sencho uses is simply `Sencho`. + +### The QR code will not scan + +Click **Can't scan? Show secret key** during enrolment, copy the base32 string, and paste it into the authenticator app's manual-entry screen. The secret is identical, the QR code is just a convenience. + +### Lost phone and no backup codes left + +Contact an administrator. An admin can reset your 2FA from the Users section in **Settings → Users**. See the [admin guide](/operations/two-factor-admin) for the steps. If you are the only admin and have lost access, the administrator can also reset 2FA from the command line on the host running Sencho. + +### SSO sign-in is unexpectedly asking for a code + +The **Require 2FA even when signing in via SSO** toggle is on for your account. Open **Settings → Account & Security** and flip it off if SSO alone is enough for your threat model. + +### Enrolment did not activate after confirmation + +The browser may have dropped the JSON response. Retry the confirmation step and watch for an error toast. If confirmation continues to fail, cancel the dialog, ensure your clock is synced, and start enrolment again. diff --git a/docs/images/two-factor-auth/account-card-enabled.png b/docs/images/two-factor-auth/account-card-enabled.png new file mode 100644 index 0000000000000000000000000000000000000000..c3f420f7d642ab6174d790664e73a6400f254e45 GIT binary patch literal 13240 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 D_M&2< literal 0 HcmV?d00001 diff --git a/docs/images/two-factor-auth/admin-reset.png b/docs/images/two-factor-auth/admin-reset.png new file mode 100644 index 0000000000000000000000000000000000000000..982369c029b0d2a5026e5744f18870f6c0556022 GIT binary patch literal 17595 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 Y=wePFsM#1M8#n@zmsXLgmoN+dKZEW9$p8QV literal 0 HcmV?d00001 diff --git a/docs/images/two-factor-auth/challenge.png b/docs/images/two-factor-auth/challenge.png new file mode 100644 index 0000000000000000000000000000000000000000..a1ddfdc26b6c7311bd868059ac19f79ff6533f0d GIT binary patch literal 41618 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%0DmfxmD zMwYL*U#N|JE((s(jj6`3=$7aY+RJ~On8`HKED6~8YA5#Uu5;M->4`fP9RjB~!NTk~Js!34qf^qNw z2eyMkzO6z}n{sL7k6Yu1dc=z70BoEW@|YF}JFVT}$r zFLpubT$lkPh3)>5yJgEM=%F5S4WfaGIkmSxWsgc_($X2Ov8_9QE{zXtam9JU1rQq? z7N_SKEQes8BBt>D4etS*!wggM;02&LONXLfs3z*+PmeFZsNhFACTS}RO^M4az|JpX znPl)-%yv9sxkl+IYL90W#j*{E7yJ@v?nb>g^Y6FafvjH;JK}4xx`KOu5y zG{6MdXnXYVSYpsl z9tJEs1@7A09mh-$f57bn)f+ zae0XnBNYs*A|icX4MzR`0S+hl(!|@h$~$%T+9+@6a-kz1%|}s*`|UO8FKO$dilvDn&eVaEhTz!E+9of-c^T-Nh_ogrLCCRn@7W z(%~G}P8mw2fD)TJM}JjSUS=t0Il&iB!RtA>ndZ^Hx2e2a36trr8ANU5?nCd3U>h^JIq+$0SWX>cC{B43&P>yr`g*n=%Vt7P1E}A8q<0Hvi<(z{+~Ix%Rl|<*$w~u4t(EWIgdWV5YcHBqm{d@I zPp9LK6!~`XH+tCY&9fH{s!R+_o0+(^d?O;|{U9_LI1idleQm(iUd%mw5c*(5w9DlK zRHF@lLHCP`SDjBrupI&4OFXW2`Ie3(^NZVZ^>W;$gMv5ac9=ZR`qh%!n^ zr8hi)nXZ~;?rw9$5M3QdfqwAgE+!{2n{amRuNz9SRrKxJM^m|d81m$Wq9FC+YIR-m zC&_+U+*&P?mhB8HZi~d_u)2+l6cmV>Y2ooQ6 zjZeuaZ8h7hhG)vVqx@Z#z)fzviW6UWR>L%3Y#6trK{zN3L>YB$kF7%z(_6o9`?B00 ztXS6?=!k`5t&%lWl)=368UTXWX)?X?3tiW0@#AyZiN)m0444*BR(p=%0A3egY7I2X z6aU)9&dAs7!CbD8{vO{Xsji$_LiklhgYjmCMkOi#r^K{LOX}-A!HJc9&vneNhOEQZ z`kTs7c6i=Ub9?v^;>mq5;?W@IiIRRiV-a)L4~h?Nl62&J)(^)gkWri9^%YAX3@>Ba zE4iwHo3jd?1{1;%fnS=aGwp_dH)(=;<{acwcIQ}oaSIDAX<-oP1i}tL?I>)hrE${_tObz=5^70&sUC4*W-@B_*83y#740bKe1dfdfh>O)yQ= zrc%_b43bsB@3_2>9ytIli>ufcd3zb%k2x?7R2d`_S zne^@z*9WgCs6|rmZB|iRNsr?FPJT)F3IWm=F4d_HN zp=E-UAe=Nh&GIdHlH&{U&xNUwUtm*+k~z9t&YC3cZY^a-u1icr5P9`UWZ%+V2(O(t z!DoY%iz6*UjF6RtUWjqLq4T|oTv|Ah+pTDHe;bk-XKXAf)#H}z*9%IjRd|?(hIq#9 zoCN7pYyRl7)X&{VbO;gTR+sb-Q9^KrphXO|=nan@iui47ju>^+OqJ7ilYBx(*lAF; z0&*QM9UlL;K7gh@5l@Eg@iAcQym+8RPk=FwFXV5`c5r;c&qsHD7nY(6P_rPzuTNN4 znpA)q3svxG8og`_LqBJs$i2S}yi$_dZhSxSX@Ah{xk40pv;`cjfIG3-SXVU8MR0W5 zfhPkZ5iKYq0#&9W+-&AzON!JU#gRYtJ}aXFCo9p;O4~i$Afp6;8vqgxjlC&+kD+ya zskRq6p!mm9Pz-j5K_`S0=eH9A7|Cd8F9d&eLI z`4o0O~+7BUB5 zQh@kj8)!#-IUcIe!A@BU*msiOLJxMr!y$m=*IM&(;hU_wf>f#pM1nm5CjM7e7-fr;$*1c)jb806^yjX!9QRvf=WAUm&%U{wZihO=ZVwb-4MO#jk|h|Q z&E@_MvzB z%{>;!!r|R~FceJiXXyhW1A&Jb{f8q<_1j>Ha6l7six}s^)2H@VYyh}4`nxK&jZ%@L zwT?Qto6xz%WJ8D3`8{BNEjs!4bTb2vqp0^e=T)q^K>oLak8ffJXd+r6ndyI3Ys7`ATzTi=#K4A#mSkvWwhO_*)eX8h54cBd?4jYK?W zMs=GELS@U}51_0%iCPu^@9uIK9oW%nNY&)J#cnBwS0hul`t+AQw}|xRWKvQQzHsy!g~zK@zpZTVd4n8Df+rZJ6xW=Hx%U z2`c#dqL`8hGsMjw4chq5`fWFYSapyYS0NJ_#9I@#Pn9=M$5q6-19*Evd@{d0g67@T zqopEPeDjwq1l~SfR$}YWKW`@V)7rkh@u_0qo46FF55T*C{FlJjV}4JXnsti~YLR$^ zz`N`UQLU>pCVgfO8Jc}Ey?#PzUNBUuiO_ZOkZU&}OWIrA%fTP5jXf6;ha<~ww7GOqh1i=sQ)vU^#YR_-v2>&MU+mx8POTFPqC*2cjxll#H>*^`tl|-z9;VZX=#FP46N1EtuO;xYdN(|`X?Um z$2CgrKA+0JUVhE|5_e)}B|0(pYrcW$OES6hAW<%M?c1R2>iP!jO8c`s-U>=y8W|QR zOy?_zeu0y3OL^E3*BhON?kn5FCKqLEV#Vn$aYgYy>i)?iFj(Gi{q$UX%%q7OU4paS zeLp;&n<($B*zUK8@li0=AG;;+EhC95#JF<{Ux_1D-Qx?rAo*l#yM(=gD82}n=P8^Q zAte-WZwWO`2I!n&p6#so$^NFeBCR4et*qm87Z*}tYm4}5Ndhemjm===H_8IvHk-ZBQz; zUmgQbhv{}WH57{1c?XwifSX((U;Nx0+dEaJuq<9xWltSX{f8pq-)7E+hiWnhoC7)ihWd zU-l%nsb z$>Url>X`6Ft|8th9W-@yOl%jV0+|^vv_as+Bu;C+obXxR)F=eVxj%6T%!o;ZqA&J6 zOT9bVu1Mhe!BX6$bf)fwzzBVBfMRfnWNC0V=CV{bad~er!*Yl1`OzJbJkL{}1m)=K z`Cwd6LBW$Yk3TBI+^1k&^$025VcXBKXF`qG!5`%*FOWqk>k%9al%d(Gf;D)Ry~{%c zCSn#yoO4*61&{BcNgRFEeju-Vk!+=zzpwz%Di~sEfvprz)eK$4HNocc6?l?#O|hNg zo+)2lO)SaBUcXD*r5t}W`aTu#(qAn0q9hvq* zNCdoTqqGI_-|dG7igexQ+$3HYkqqr-l#4awvtT}9_S{p6zjtA%7GAl z=Btw1|v?Xt;k0TifuDoA>`c zob`W99lgai_C~3xKcOOEWundr@DB;(3R88RfY>Fa7s7fJ!GK_g*1G{Z`Wv_DI}+zl z)a4N+;K1kt5_B5+;EEjZfPfr(Wzz;AB$EdPNogoe=;eotiHcXUUMQ;pH|T`M$IXU( zN+EPVT+Rw5DM!muHqQTMM98BcDF&zR2_Pq4CR#xgO}=DWgc`?hm(;auUXut0eXgIh zSIr(5UVYF}m5i!ouh*$@Vo>cy=;4kQH6&)#)#aHML>-`>Hen(?!^eP45 zVzA#9fsY=NQcU@fKoVD~>vRYNe#fTnww~bHM61(a9{8C+fVOG%I`_o0y4T9V7$^QY zgRJ_cdFo>gbYPueu6E=$92#R4#!fJ!@tUPvRP%dW(sRC$DUIW|*m85BfGYg`bJOR( z-~|AX{SM30t&V44I~@PQX$DNK35X2v{XO3NKqwOcvI91~1h$P;5ghqsJP&&Vx#eP* zP=MRWTu(iOH6L{NGXoInD&TZLa==OM!=AwFLwB=axDPN9dp#3C;B=kXPsSmMN=H$#04AQnU29-|+p1`rW?Su&%!=DnR*SN#G9Fp> zDs`0OrS%-t`&csnWUdql>?NX+O^B*vX>Wj9x8}i90z7UP7*9yo1EFX(;p(X$@)w}+ zRd6=rT~L@|vH;+Lbvr{(5L}Z`0U#NB1(r5a8EY{B^Y{d~z_Az|%rWq^qL)l%*1!z_ zA%yN!0b@vCKLmq1M{?egL5_?kd|gU>%g$r_Y0{1uF9)siGlN3x&$Q7USpr}Wu%wA(&0Kgrn8O_6E$ynt0o^60pQks zfiBj|6q^MeLOS8*HDnM(Erj4MA~+(i$z#)4*s~MtNWguUZGD_VU^j81PheKv{MU_I zx50gq(+t;1v39_lXYrq#f`W!x22;HyL4eweRd6W}#1HWVg1|TrW~_yZGeF;pc9V>!ip}8My(E(%zyXe zO+n#i3+=^SLRj!87Z8`1RThwVO4U>zA2ZvTyuM=^`UT)2&HiF<$}fP_hoE_G{r3I+ zJ-(sA02ytFSXhKNgQ!}1-FfaD;THBzL45042jZ7@@LVQyzLUMeWzNrWdhsWpPa0TT zvCrQHleo=PWBm%ank*@>0_@pMy-$FpP3K49Dt1<2s9plM=O&P$ETY9M1SKVaJri!6 zaY*O&I%rZzMXzKHZ7nNEa1&A66n73hDs>r=e2uNN|Gb6lTN;JW>|-0eUZ-YccTYU) zb=X)yC1^Il`opvK9>Irw24T%Y8JOm~O)-dfFbtwf&QRYg4a*n9GbUK%cHeyN&DqmEbi`JZ5 zWpQi3$&VFFj~bK7?i8X)mPu~)RJC6W8bSur3#z48gPk6;wuWHkjsFVGQT4=ow%b@` ze6bol#H+avzH99Y^57iAtqP8-{?&BLHuQj#o@PdOgIj;cUNH<|GG5$ z;%^m9by*6$DslllH+#yts-h@TW`Yi2onXrmRBt7>&zNx*SU_<0F^p`^Qq68{C%(5Q zxu3_&py@f-9<-6K;0za|`)OO?f~VE-SA2o*$Jhgw3e5K|c){ABCw2DN+YQo-l{U?N z5p~n_?62OEg+OB*C=*y8sjiV^gz$HInqA%DnW_Ru8OQ!JxRwsv@wrcniLGEf{sjL1 zXYw!`wuco_w(rBBw$1dj{Rif=1BvyQO0q|3Ffn>^HEQ1OoruUchr_7nPD(A*weT_j zI=jMu-#+^k?f|)Nfi@e-H%VD$h@v6J;#1&!kBLxXf^vVPXia!sylVt z`3qVVb-m9_Wy5|LS$ghX<=Bmoc)~$Kx5xN6nvAPf#SpG;lu%Jl^WZ@#rzk|Hs;n?s z%GD)aVzx*rr$Ht+kDNZZBC!6LFLs1$F=52#iWaIi%7Mh0+NwQ#RI*@1f}FiEZt5w$ z`1#-I>VRN+^LX@>KXV%?QD>F2XWvsCDvxooi!IeM1WeN#R6Co1g_I ztUd^od0Ap**L0refu(rQ-5oY3$rX|Ns&t6vB+mM; zhLNm`ycx&F`Mmoae6_*ejM*|TT zDvaFwy3R-YL1G~n*Zbe;Aw&Qwh-vS0H?E^uamdH-&j%!<91-cenP+sODSnQHb|ZRK zBV3o*SJkTGwSBRzI#U1o?Qn+Wum{Wa=XTVUwv*Fu@s2vYo;nDh+^|Z{FWQJdzBRJ- zL@S%52On%PQ~y8>7A(|wbMIyfk9N9Hmh}axde!p}UImRxrr*p!o{1|SjAB}bcP4n0 z>iH*9;C+nP$D9KO@-Ov4O zVo*neEw}+t`(XW>DX2ddg&q7^6m=SK!asz>^3k8)2CA~s#L=!WXQqO;SZ z7}Ckrd1l;050I;Q7nT#}E{6~5?z%Y1GI5gjp4~hMEZ&YZb_XUw>*I2{G6wi`JXkt`kSIKXIq_2ujEg$L3*4`H2 z{L+lfN<4e6<5FWYvMu@<`sm1e5>hmyV>E9BPZS5McXH*PS_%F5jPYhGBG?0aGEMDo zp&*mvmhm|6DQZP&H_bfyzh*y8rRWf4Nonw?r5&dfxP17|_bF&Z@@&J#^?enj-LjPF zUy=UiZDg}c=n2taa|JqE)*+HGTgXb2bkix9ckMZflW60lq%%;_`k|J3Z!{G2IVUj{ z`_ShVlthqrJ>0*e4ee&a7NXB)k;0sRBqi16IVJ`i40 zWob;+yN`o!Tb&V8Ek*Zq+@niJL9a2-%GEBCW#^uyb;SUNPd12=nK&VP!; z>A8H&(-PBU(^S?cK9fOMMDSGlHyq2l`n-O5qi0h{t-hRQxCfQOfo7|}4k$O)CKH{b zi*I)g#7pY%_uy*MC!wxfc~8#QgORVXl#j42ndf`$$}_m3*2mvrr#hJW-Hr`30_X`Q z`}BUaaIpY~iW+nsCFx;yHhD$>h4c=AkyA=t&OiMu%lO9jqbpYhenEjFr5v-7);`U| z!d>fSKQyu96Rpke5LMQ6`uXm#X`7-`mQ_rTYN#1+V|k2#_RH{>xnQXPPJ2EOe6a37 z7@oaVG{`$U6Xd@}>*L|RD=LMtRca%kHdEui5Nm4kzK`c(ehuG>-3E#w=+D{sSs3xY zTW~AgGk)Tb@y78FCj%pAc<0WYo$b(+EWy3P1lYqL?_A2)om8XK;zanHb6V>9$d{_O GLjM=VQPx8M literal 0 HcmV?d00001 diff --git a/docs/images/two-factor-auth/enroll-confirm.png b/docs/images/two-factor-auth/enroll-confirm.png new file mode 100644 index 0000000000000000000000000000000000000000..519d421afb9cac6a69aa275438620742c21c65ba GIT binary patch literal 15477 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 IDYKCO1Fk$1EC2ui literal 0 HcmV?d00001 diff --git a/docs/images/two-factor-auth/enroll-qr.png b/docs/images/two-factor-auth/enroll-qr.png new file mode 100644 index 0000000000000000000000000000000000000000..14dae63003929845eeae63c8446e93511353c4c4 GIT binary patch literal 22241 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 DhRZa? literal 0 HcmV?d00001 diff --git a/docs/operations/two-factor-admin.mdx b/docs/operations/two-factor-admin.mdx new file mode 100644 index 00000000..7b3092e4 --- /dev/null +++ b/docs/operations/two-factor-admin.mdx @@ -0,0 +1,58 @@ +--- +title: Managing Two-Factor Authentication +description: Reset a user's 2FA, configure per-user SSO enforcement, and recover from lockouts. +--- + +This guide is for administrators. For general 2FA usage, see [Two-Factor Authentication](/features/two-factor-authentication). + +## Reset a user's 2FA + +A user who has lost their authenticator app and all backup codes cannot sign in on their own. Any administrator can reset the affected account from the Settings UI. + +1. Open **Settings → Users** +2. Find the user in the list. Users with 2FA enabled show a shield icon in the action column +3. Click the shield icon and confirm the reset + + + Administrator resetting a user's 2FA from the Users settings + + +After the reset: + +- The user can sign in with their password only, no second factor is required +- Their existing sessions are invalidated, so any stale browser tabs have to sign in again +- The action is recorded in the audit log with the administrator's username + +Instruct the user to enrol again from **Settings → Account & Security** as soon as they are back in. The account is unprotected until they do. + +## Emergency recovery from the command line + +If every administrator has lost access to 2FA and no one can sign in through the UI, reset the admin account directly from the host running Sencho. + +From a shell on that host: + +```bash +docker compose exec sencho node dist/cli/resetMfa.js +``` + +Replace `` with the admin's account name. On success the command prints a confirmation line and exits 0. Sign in with the password, then re-enrol immediately. + +The command respects the container's `DATA_DIR`, so it always acts on the same SQLite database the application uses. It writes an audit log entry attributed to `cli` so the action is auditable after the fact. + +## Per-user SSO enforcement + +When SSO (LDAP or OIDC) is configured, users with 2FA enabled sign in through SSO without a second factor by default. SSO is already an authenticated flow, and requiring a TOTP on top is extra friction that most teams do not need. + +Each user can opt into stricter behaviour by flipping **Require 2FA even when signing in via SSO** on their own Account & Security screen. There is no admin-wide override, every user decides for themselves. + +If your organisation needs to force 2FA for every SSO sign-in for every user, raise this with the Sencho team, it is not exposed as a global policy today. + +## What a reset changes in the database + +For completeness, a reset does the following to the target user: + +- Clears the stored TOTP secret and all remaining backup codes +- Rotates the user's session version, which invalidates any outstanding session cookies +- Leaves the account otherwise unchanged, the username, password hash, role, and audit history are preserved + +Nothing about the user's SSO bindings changes. If the user was signing in with SSO plus 2FA, they simply resume SSO-only sign-in after the reset. diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 0e6f95e0..e0839ad0 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -8,10 +8,28 @@ * E2E_USERNAME=admin E2E_PASSWORD=mypassword npx playwright test */ import { Page, expect } from '@playwright/test'; +import { authenticator } from 'otplib'; +import { HashAlgorithms } from '@otplib/core'; + +// Match backend configuration so generated codes are accepted. +authenticator.options = { + digits: 6, + step: 30, + algorithm: HashAlgorithms.SHA1, + window: 1, +}; export const TEST_USERNAME = process.env.E2E_USERNAME ?? 'admin'; export const TEST_PASSWORD = process.env.E2E_PASSWORD ?? 'password123'; +/** + * Generate a current TOTP code for the given base32 secret. Used by the MFA + * E2E tests to drive the login challenge without a real authenticator app. + */ +export function totpNow(secret: string): string { + return authenticator.generate(secret.replace(/\s+/g, '')); +} + /** Selector for the dashboard - only present in EditorLayout, not on login/setup pages */ const DASHBOARD_INDICATOR = 'img[alt="Sencho Logo"]'; diff --git a/e2e/mfa.spec.ts b/e2e/mfa.spec.ts new file mode 100644 index 00000000..7eee8bc1 --- /dev/null +++ b/e2e/mfa.spec.ts @@ -0,0 +1,189 @@ +/** + * Two-factor authentication (TOTP) E2E tests. + * + * These tests run serially and share mutable state (the enrolment secret and + * the freshly issued backup codes). The chain is: + * 1. Enrol via the Account section, capture secret and backup codes from the + * network responses so we do not have to scrape the DOM. + * 2. Log out, log back in, satisfy the TOTP challenge, land on the dashboard. + * 3. Log out, log back in, satisfy the challenge with a backup code, + * re-use the same backup code and confirm the second attempt fails. + * 4. Disable 2FA to leave the dev DB in a clean state for the next run. + * + * If a previous run aborted mid-way, the test user may already have MFA on. + * Run `node backend/dist/cli/resetMfa.js ` or wipe the dev DB first. + */ +import { test, expect, Page } from '@playwright/test'; +import { loginAs, totpNow, TEST_USERNAME, TEST_PASSWORD, isDashboard } from './helpers'; + +async function logout(page: Page) { + await page.getByRole('button', { name: /profile/i }).click(); + await page.getByRole('button', { name: /log out/i }).click(); + // The MfaChallenge / Login screen has no dashboard indicator. + await expect.poll(async () => isDashboard(page), { timeout: 5_000 }).toBe(false); +} + +async function openAccountSettings(page: Page) { + await page.getByRole('button', { name: /profile/i }).click(); + await page.getByRole('button', { name: /settings/i }).click(); + await expect(page.getByRole('heading', { name: /Account & Security/i })).toBeVisible(); +} + +/** Fill a login form (no MFA branch). */ +async function fillLoginForm(page: Page, username: string, password: string) { + await page.locator('#username').fill(username); + await page.locator('#password').fill(password); + await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click(); +} + +test.describe.serial('Two-factor authentication', () => { + let secret = ''; + let backupCodes: string[] = []; + + // Safety net: if any test above fails, Playwright skips the rest of the + // serial block, so the "disable 2FA" test never runs and the shared test + // user stays MFA-enabled in the dev DB. That wrecks every subsequent spec + // (nodes, stacks, screenshots) because their loginAs helper does not know + // about the challenge screen. afterAll always runs, so we clear MFA here + // via the API using whatever enrolment state we captured. + test.afterAll(async ({ request }) => { + if (!secret || backupCodes.length < 2) return; + try { + // Use backup codes for both steps: they are single-use and sidestep + // the TOTP replay blacklist, so we do not need to reason about which + // 30-second window we are currently in. + const loginBackup = backupCodes[backupCodes.length - 2]; + const disableBackup = backupCodes[backupCodes.length - 1]; + await request.post('/api/auth/login', { + data: { username: TEST_USERNAME, password: TEST_PASSWORD }, + }); + const loginRes = await request.post('/api/auth/login/mfa', { + data: { code: loginBackup, isBackupCode: true }, + }); + if (!loginRes.ok()) return; + await request.post('/api/auth/mfa/disable', { + data: { code: disableBackup, isBackupCode: true }, + }); + } catch { + // Best effort; if this fails the next full-suite run will need a + // manual DB wipe or CLI reset. + } + }); + + test('enrol from Account settings captures secret and backup codes', async ({ page }) => { + await loginAs(page, TEST_USERNAME, TEST_PASSWORD); + await openAccountSettings(page); + + // Capture the raw base32 secret from the enroll/start response so we + // do not need to strip formatting spaces off the DOM value. + const startPromise = page.waitForResponse( + (r) => r.url().includes('/api/auth/mfa/enroll/start') && r.status() === 200, + ); + await page.getByRole('button', { name: /Set up 2FA/i }).click(); + const startRes = await startPromise; + const startBody = await startRes.json(); + secret = startBody.secret; + expect(secret).toMatch(/^[A-Z2-7]+$/); // base32 alphabet + + // Step 1 (QR) -> Next + await page.getByRole('button', { name: /^Next$/ }).click(); + + // Step 2 (Confirm): enter a fresh TOTP and capture the backup codes. + const confirmPromise = page.waitForResponse( + (r) => r.url().includes('/api/auth/mfa/enroll/confirm') && r.status() === 200, + ); + await page.locator('#mfa-confirm-code').fill(totpNow(secret)); + await page.getByRole('button', { name: /^Verify$/ }).click(); + const confirmRes = await confirmPromise; + const confirmBody = await confirmRes.json(); + backupCodes = confirmBody.backupCodes; + expect(backupCodes.length).toBe(10); + + // Step 3 (Backup codes) -> acknowledge. + await page.getByRole('button', { name: /saved these/i }).click(); + + // Card now shows the Enabled badge. + await expect(page.getByText(/^Enabled$/)).toBeVisible(); + }); + + test('login with a valid TOTP code reaches the dashboard', async ({ page }) => { + // Fresh page lands on the login screen; password passes but the MFA + // challenge appears because test #1 enrolled the user. + await page.goto('/'); + await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 }); + await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD); + await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible(); + + await page.locator('#mfa-code').fill(totpNow(secret)); + await page.getByRole('button', { name: /Verify and sign in/i }).click(); + + await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true); + }); + + test('backup code works once and cannot be replayed', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 }); + + const code = backupCodes[0]; + expect(code).toBeTruthy(); + + // First use: should succeed. + await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD); + await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible(); + await page.getByRole('button', { name: /Use a backup code instead/i }).click(); + await page.locator('#mfa-code').fill(code); + await page.getByRole('button', { name: /Verify and sign in/i }).click(); + await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true); + + // Log out and try the same backup code again: should fail. + await logout(page); + await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD); + await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible(); + await page.getByRole('button', { name: /Use a backup code instead/i }).click(); + await page.locator('#mfa-code').fill(code); + await page.getByRole('button', { name: /Verify and sign in/i }).click(); + + // Error should be visible and we should still be on the challenge screen. + await expect(page.locator('.text-destructive')).toBeVisible(); + expect(await isDashboard(page)).toBe(false); + + // Recover using a fresh backup code. Using a TOTP here races the + // 30-second window against the one test #2 consumed, which the server + // (correctly) rejects as a replay when the boundary falls the wrong + // way. Backup codes are single-use and sidestep that blacklist. + await page.locator('#mfa-code').clear(); + await page.locator('#mfa-code').fill(backupCodes[1]); + await page.getByRole('button', { name: /Verify and sign in/i }).click(); + await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true); + }); + + test('disable 2FA with a valid code removes the challenge on next login', async ({ page }) => { + // loginAs does not understand the MFA challenge screen, so drive the + // login manually. Use a backup code for both the challenge and the + // disable step so we do not race the TOTP replay blacklist against + // codes consumed by earlier tests in this serial block. + await page.goto('/'); + await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 }); + await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD); + await expect(page.getByRole('heading', { name: /Two-factor authentication/i })).toBeVisible(); + await page.getByRole('button', { name: /Use a backup code instead/i }).click(); + await page.locator('#mfa-code').fill(backupCodes[2]); + await page.getByRole('button', { name: /Verify and sign in/i }).click(); + await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true); + + await openAccountSettings(page); + await page.getByRole('button', { name: /Disable 2FA/i }).click(); + await page.getByRole('button', { name: /Use a backup code instead/i }).click(); + await page.locator('#mfa-disable-code').fill(backupCodes[3]); + await page.getByRole('button', { name: /^Disable$/ }).click(); + + // Card flips back to the "Set up 2FA" call to action. + await expect(page.getByRole('button', { name: /Set up 2FA/i })).toBeVisible(); + + // Close settings, log out, log back in without the MFA challenge. + await page.keyboard.press('Escape').catch(() => {}); + await logout(page); + await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD); + await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a9ca076a..acbfe1b6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,7 @@ "lucide-react": "^1.8.0", "monaco-editor": "^0.55.1", "motion": "^12.38.0", + "qrcode.react": "^4.2.0", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-day-picker": "^9.14.0", @@ -5230,6 +5231,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 791addf2..179910ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "lucide-react": "^1.8.0", "monaco-editor": "^0.55.1", "motion": "^12.38.0", + "qrcode.react": "^4.2.0", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-day-picker": "^9.14.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fbcaa9a2..e207a483 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { LicenseProvider } from './context/LicenseContext'; import { Login } from './components/Login'; import { Setup } from './components/Setup'; import EditorLayout from './components/EditorLayout'; +import { MfaChallenge } from './components/MfaChallenge'; function AppContent() { const { appStatus, isAuthenticated, needsSetup, completeSetup } = useAuth(); @@ -20,6 +21,10 @@ function AppContent() { return ; } + if (appStatus === 'mfaChallenge') { + return ; + } + if (!isAuthenticated) { return ; } diff --git a/frontend/src/components/MfaChallenge.tsx b/frontend/src/components/MfaChallenge.tsx new file mode 100644 index 00000000..267e4863 --- /dev/null +++ b/frontend/src/components/MfaChallenge.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +export function MfaChallenge({ + className, + ...props +}: React.ComponentPropsWithoutRef<'div'>) { + const { submitMfa, cancelMfa } = useAuth(); + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [useBackup, setUseBackup] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + const result = await submitMfa(code, { isBackupCode: useBackup }); + if (!result.success) { + const retryNote = result.retryAfter ? ` (try again in ${Math.ceil(result.retryAfter / 60)} min)` : ''; + setError((result.error || 'Verification failed') + retryNote); + setCode(''); + } + setIsLoading(false); + }; + + const handleToggleBackup = () => { + setUseBackup((v) => !v); + setCode(''); + setError(''); + }; + + return ( +
+ {/* Branding panel (matches Login layout) */} +
+
+
+ Sencho +
+

Sencho

+

Docker Compose Management

+
+
+
+
+ + {/* Form panel */} +
+
+ Sencho + Sencho + Sencho +
+ +
+
+

Two-factor authentication

+

+ {useBackup + ? 'Enter one of your saved backup codes to continue.' + : 'Open your authenticator app and enter the 6-digit code.'} +

+
+ +
+
+
+ + setCode(e.target.value)} + className="font-mono tabular-nums tracking-widest text-center" + placeholder={useBackup ? 'ABCDE-FGHIJ' : '123456'} + /> +
+ {error && ( +
+ {error} +
+ )} + + + +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/mfa/MfaBackupCodesDialog.tsx b/frontend/src/components/mfa/MfaBackupCodesDialog.tsx new file mode 100644 index 00000000..e26333de --- /dev/null +++ b/frontend/src/components/mfa/MfaBackupCodesDialog.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Copy, Download } from 'lucide-react'; +import { toast } from '@/components/ui/toast-store'; +import { apiFetch } from '@/lib/api'; + +interface MfaBackupCodesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onRegenerated: () => void; +} + +type Step = 'confirm' | 'show'; + +export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaBackupCodesDialogProps) { + const [step, setStep] = useState('confirm'); + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [backupCodes, setBackupCodes] = useState([]); + + const resetState = () => { + setStep('confirm'); + setCode(''); + setError(''); + setBackupCodes([]); + }; + + const handleConfirm = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + const res = await apiFetch('/auth/mfa/backup-codes/regenerate', { + method: 'POST', + localOnly: true, + body: JSON.stringify({ code }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error || 'Could not regenerate backup codes'); + return; + } + setBackupCodes(data.backupCodes || []); + setStep('show'); + } catch (err) { + setError((err as Error)?.message || 'Could not regenerate backup codes'); + } finally { + setLoading(false); + } + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(backupCodes.join('\n')); + toast.success('Backup codes copied'); + } catch { + toast.error('Could not copy to clipboard'); + } + }; + + const handleDownload = () => { + const blob = new Blob([ + 'Sencho backup codes\n', + 'Each code can be used once. Keep this file somewhere safe.\n\n', + backupCodes.join('\n'), + '\n', + ], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'sencho-backup-codes.txt'; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleFinish = () => { + resetState(); + onOpenChange(false); + onRegenerated(); + }; + + return ( + { + if (!next) { + if (step === 'show') onRegenerated(); + resetState(); + } + onOpenChange(next); + }} + > + + + + {step === 'confirm' ? 'Regenerate backup codes' : 'New backup codes'} + + + Replace your backup codes with a freshly generated set. The previous + set stops working immediately. + + + + {step === 'confirm' && ( +
+

+ Your current backup codes will stop working immediately. Confirm with a code from your authenticator app to continue. +

+
+ + setCode(e.target.value)} + className="font-mono tabular-nums tracking-widest text-center" + placeholder="123456" + /> +
+ {error &&
{error}
} + + + + +
+ )} + + {step === 'show' && ( +
+

+ Each code can be used once. Store them somewhere safe; they will not be shown again. +

+
+ {backupCodes.map((c) => ( +
{c}
+ ))} +
+
+ + +
+ + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/mfa/MfaDisableDialog.tsx b/frontend/src/components/mfa/MfaDisableDialog.tsx new file mode 100644 index 00000000..80454635 --- /dev/null +++ b/frontend/src/components/mfa/MfaDisableDialog.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { toast } from '@/components/ui/toast-store'; +import { apiFetch } from '@/lib/api'; + +interface MfaDisableDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onDisabled: () => void; +} + +export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableDialogProps) { + const [code, setCode] = useState(''); + const [useBackup, setUseBackup] = useState(false); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open) { + setCode(''); + setError(''); + setUseBackup(false); + } + }, [open]); + + const handleDisable = async () => { + setError(''); + setLoading(true); + try { + const res = await apiFetch('/auth/mfa/disable', { + method: 'POST', + localOnly: true, + body: JSON.stringify({ code, isBackupCode: useBackup }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error || 'Could not disable two-factor authentication'); + return; + } + toast.success('Two-factor authentication disabled'); + setCode(''); + onOpenChange(false); + onDisabled(); + } catch (err) { + setError((err as Error)?.message || 'Could not disable two-factor authentication'); + } finally { + setLoading(false); + } + }; + + return ( + + + + Disable two-factor authentication? + + Your account will only be protected by a password. Anyone who obtains that password can sign in. Confirm with a current code to continue. + + + +
+
+ + setCode(e.target.value)} + className="font-mono tabular-nums tracking-widest text-center" + placeholder={useBackup ? 'ABCDE-FGHIJ' : '123456'} + /> +
+ + {error &&
{error}
} +
+ + + Cancel + + +
+
+ ); +} diff --git a/frontend/src/components/mfa/MfaEnrollDialog.tsx b/frontend/src/components/mfa/MfaEnrollDialog.tsx new file mode 100644 index 00000000..6ca5f6d5 --- /dev/null +++ b/frontend/src/components/mfa/MfaEnrollDialog.tsx @@ -0,0 +1,250 @@ +import { useEffect, useState } from 'react'; +import { QRCodeSVG } from 'qrcode.react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Copy, Download, ChevronDown, ChevronRight } from 'lucide-react'; +import { toast } from '@/components/ui/toast-store'; +import { apiFetch } from '@/lib/api'; + +interface MfaEnrollDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onEnrolled: () => void; +} + +type Step = 'qr' | 'confirm' | 'backup'; + +/** + * Format a raw base32 secret as groups of 4 characters so it is easier for + * users typing it into authenticator apps manually. + */ +function formatSecret(secret: string): string { + return secret.replace(/(.{4})/g, '$1 ').trim(); +} + +export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDialogProps) { + const [step, setStep] = useState('qr'); + const [loading, setLoading] = useState(false); + const [otpauthUri, setOtpauthUri] = useState(''); + const [secret, setSecret] = useState(''); + const [showSecret, setShowSecret] = useState(false); + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + const [backupCodes, setBackupCodes] = useState([]); + + // When the dialog opens, start enrolment so the QR is ready immediately. + useEffect(() => { + if (!open) return; + let cancelled = false; + setStep('qr'); + setCode(''); + setError(''); + setBackupCodes([]); + setShowSecret(false); + setLoading(true); + apiFetch('/auth/mfa/enroll/start', { method: 'POST', localOnly: true }) + .then(async (r) => { + const data = await r.json().catch(() => ({})); + if (cancelled) return; + if (!r.ok) { + toast.error(data?.error || 'Failed to start enrolment'); + onOpenChange(false); + return; + } + setOtpauthUri(data.otpauthUri); + setSecret(data.secret); + }) + .catch((e) => { + if (cancelled) return; + toast.error(e?.message || 'Failed to start enrolment'); + onOpenChange(false); + }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [open, onOpenChange]); + + const handleConfirm = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + const res = await apiFetch('/auth/mfa/enroll/confirm', { + method: 'POST', + localOnly: true, + body: JSON.stringify({ code }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error || 'Verification failed'); + return; + } + setBackupCodes(data.backupCodes || []); + setStep('backup'); + } catch (err) { + setError((err as Error)?.message || 'Verification failed'); + } finally { + setLoading(false); + } + }; + + const handleCopySecret = async () => { + try { + await navigator.clipboard.writeText(secret); + toast.success('Secret copied to clipboard'); + } catch { + toast.error('Could not copy to clipboard'); + } + }; + + const handleCopyBackupCodes = async () => { + try { + await navigator.clipboard.writeText(backupCodes.join('\n')); + toast.success('Backup codes copied'); + } catch { + toast.error('Could not copy to clipboard'); + } + }; + + const handleDownloadBackupCodes = () => { + const blob = new Blob([ + 'Sencho backup codes\n', + 'Each code can be used once. Keep this file somewhere safe.\n\n', + backupCodes.join('\n'), + '\n', + ], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'sencho-backup-codes.txt'; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleFinish = () => { + onOpenChange(false); + onEnrolled(); + }; + + return ( + { + // Once backup codes have been shown, a close is equivalent to + // finishing, so the parent can refresh the status card. + if (!next && step === 'backup') onEnrolled(); + onOpenChange(next); + }} + > + + + + {step === 'qr' && 'Set up two-factor authentication'} + {step === 'confirm' && 'Confirm your authenticator'} + {step === 'backup' && 'Save your backup codes'} + + + Enrol a time-based one-time password (TOTP) authenticator and save + single-use backup codes. + + + + {step === 'qr' && ( +
+

+ Scan the QR code with an authenticator app such as 1Password, Bitwarden, or Google Authenticator. +

+
+ {otpauthUri + ? + :
+ } +
+ + {showSecret && ( +
+ + {formatSecret(secret) || '...'} + + +
+ )} + + + + +
+ )} + + {step === 'confirm' && ( +
+

+ Enter the 6-digit code shown in your authenticator app to confirm enrolment. +

+
+ + setCode(e.target.value)} + className="font-mono tabular-nums tracking-widest text-center" + placeholder="123456" + /> +
+ {error &&
{error}
} + + + + +
+ )} + + {step === 'backup' && ( +
+

+ Each code can be used once if your authenticator is unavailable. Store them somewhere safe; they will not be shown again. +

+
+ {backupCodes.map((c) => ( +
{c}
+ ))} +
+
+ + +
+ + + +
+ )} + +
+ ); +} diff --git a/frontend/src/components/settings/AccountSection.tsx b/frontend/src/components/settings/AccountSection.tsx index e663649e..6366002c 100644 --- a/frontend/src/components/settings/AccountSection.tsx +++ b/frontend/src/components/settings/AccountSection.tsx @@ -1,7 +1,16 @@ +import { useCallback, useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { RefreshCw } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Separator } from '@/components/ui/separator'; +import { RefreshCw, Shield, ShieldCheck } from 'lucide-react'; +import { MfaEnrollDialog } from '@/components/mfa/MfaEnrollDialog'; +import { MfaDisableDialog } from '@/components/mfa/MfaDisableDialog'; +import { MfaBackupCodesDialog } from '@/components/mfa/MfaBackupCodesDialog'; +import { toast } from '@/components/ui/toast-store'; +import { apiFetch } from '@/lib/api'; interface AccountSectionProps { authData: { oldPassword: string; newPassword: string; confirmPassword: string }; @@ -10,7 +19,68 @@ interface AccountSectionProps { isSaving: boolean; } +interface MfaStatus { + enabled: boolean; + backupCodesRemaining: number; + sso_enforce_mfa: boolean; +} + +interface SSOProvider { + provider: string; + type: 'ldap' | 'oidc'; +} + export function AccountSection({ authData, onAuthDataChange, onPasswordChange, isSaving }: AccountSectionProps) { + const [mfa, setMfa] = useState(null); + const [mfaLoading, setMfaLoading] = useState(true); + const [hasSso, setHasSso] = useState(false); + const [enrollOpen, setEnrollOpen] = useState(false); + const [disableOpen, setDisableOpen] = useState(false); + const [regenOpen, setRegenOpen] = useState(false); + const [togglingBypass, setTogglingBypass] = useState(false); + + const refreshMfa = useCallback(async () => { + setMfaLoading(true); + try { + const res = await apiFetch('/auth/mfa/status', { localOnly: true }); + if (res.ok) setMfa(await res.json()); + } catch { + // Non-fatal; surface as disabled card. + } finally { + setMfaLoading(false); + } + }, []); + + useEffect(() => { + refreshMfa(); + apiFetch('/auth/sso/providers', { localOnly: true }) + .then((r) => (r.ok ? r.json() : [])) + .then((providers: SSOProvider[]) => setHasSso(providers.length > 0)) + .catch(() => setHasSso(false)); + }, [refreshMfa]); + + const handleBypassToggle = async (enforce: boolean) => { + setTogglingBypass(true); + try { + const res = await apiFetch('/auth/mfa/sso-bypass', { + method: 'PUT', + localOnly: true, + body: JSON.stringify({ enforce }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + toast.error(data?.error || data?.message || 'Could not update SSO preference'); + return; + } + setMfa((prev) => (prev ? { ...prev, sso_enforce_mfa: enforce } : prev)); + } catch (err) { + const e = err as { message?: string; error?: string } | undefined; + toast.error(e?.message || e?.error || 'Could not update SSO preference'); + } finally { + setTogglingBypass(false); + } + }; + return (
@@ -49,6 +119,79 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i }
+ + + + {/* Two-factor authentication card */} +
+
+ {mfa?.enabled + ? + : + } +
+
+

Two-factor authentication

+ {mfa?.enabled && Enabled} +
+

+ {mfa?.enabled + ? 'Sign-in requires a code from your authenticator app. Back up your codes somewhere safe.' + : 'Add a time-based one-time password to your account for an extra layer of security.'} +

+ + {mfaLoading ? ( +
Loading…
+ ) : mfa?.enabled ? ( +
+
+ {mfa.backupCodesRemaining} backup code{mfa.backupCodesRemaining === 1 ? '' : 's'} remaining +
+ + {hasSso && ( +
+
+
Require 2FA even when signing in via SSO
+
+ SSO logins skip the second factor by default. +
+
+ +
+ )} + +
+ + +
+
+ ) : ( +
+ +
+ )} +
+
+
+ + + +
); } diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index 04d03709..15642494 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -15,7 +15,7 @@ import { useAuth, type UserRole } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { PaidGate } from '@/components/PaidGate'; import { CapabilityGate } from '@/components/CapabilityGate'; -import { RefreshCw, Trash2, Plus, Pencil } from 'lucide-react'; +import { RefreshCw, Trash2, Plus, Pencil, ShieldOff } from 'lucide-react'; interface UserItem { id: number; @@ -23,6 +23,7 @@ interface UserItem { role: UserRole; auth_provider: string; created_at: number; + mfaEnabled?: boolean; } interface RoleAssignmentItem { @@ -129,6 +130,22 @@ export function UsersSection() { } }; + const handleResetMfa = async (userId: number, username: string) => { + try { + const res = await apiFetch(`/users/${userId}/mfa/reset`, { method: 'POST', localOnly: true }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to reset two-factor authentication.'); + return; + } + toast.success(`Two-factor authentication reset for ${username}.`); + fetchUsers(); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Something went wrong.'; + toast.error(msg); + } + }; + const handleDelete = async (userId: number) => { try { const res = await apiFetch(`/users/${userId}`, { method: 'DELETE', localOnly: true }); @@ -427,6 +444,27 @@ export function UsersSection() { + {u.mfaEnabled && ( + + + + + + + Reset two-factor authentication for "{u.username}"? + + This removes the user's authenticator enrolment and backup codes. They will sign in with just their password on their next login and can re-enrol from their account settings. Use this when a user has lost access to their authenticator. + + + + Cancel + handleResetMfa(u.id, u.username)}>Reset 2FA + + + + )}