diff --git a/backend/.env.example b/backend/.env.example index 15ab3ddd..300858e0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,6 +9,16 @@ PORT=3001 # Generate with: openssl rand -base64 32 JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456 +# Admin 2FA (TOTP) secret encryption key — OPTIONAL. +# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default +# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it +# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so +# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set +# it, changing/losing it makes existing 2FA secrets undecryptable — recover +# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes +# Generate with: openssl rand -base64 32 +#MFA_ENCRYPTION_KEY= + # Auth cookie Secure flag # unset - default: 'auto' in production, false in dev (#427) # true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access — diff --git a/backend/__tests__/integration/resetAdminMfaCli.test.js b/backend/__tests__/integration/resetAdminMfaCli.test.js new file mode 100644 index 00000000..3aa3f2bb --- /dev/null +++ b/backend/__tests__/integration/resetAdminMfaCli.test.js @@ -0,0 +1,82 @@ +/** + * CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738). + * + * Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs + * the script in a child process (--email --yes) pointed at the same + * DB file, and asserts the four MFA columns are zeroed. The script runs in + * its own process with its own knex connection; the parent connection is + * idle during the spawn so the SQLite write lock isn't contended. + */ + +const path = require('path'); +const { execFileSync } = require('child_process'); + +const { bootCrmDb } = require('./helpers/crmDb'); + +jest.setTimeout(60000); + +let db; +let cleanup; + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); +}, 60000); + +afterAll(async () => { + if (cleanup) await cleanup(); +}); + +const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js'); + +async function seedEnrolledAdmin(email) { + const inserted = await db('admin_users').insert({ + username: email.split('@')[0], + email, + password_hash: 'x', + is_active: true, + two_factor_enabled: true, + two_factor_secret: 'iv.tag.ct', + two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']), + two_factor_enrolled_at: new Date(), + created_at: new Date(), + }).returning('id'); + return inserted[0]?.id ?? inserted[0]; +} + +it('zeroes the four MFA columns for the targeted admin', async () => { + const email = 'reset-me@example.com'; + const id = await seedEnrolledAdmin(email); + + execFileSync('node', [SCRIPT, '--email', email, '--yes'], { + env: { + ...process.env, + NODE_ENV: 'test', + TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH, + }, + stdio: 'pipe', + }); + + const row = await db('admin_users').where({ id }).first(); + expect(Number(row.two_factor_enabled)).toBe(0); + expect(row.two_factor_secret).toBeNull(); + expect(row.two_factor_recovery_codes).toBeNull(); + expect(row.two_factor_enrolled_at).toBeNull(); +}); + +it('leaves a different admin untouched', async () => { + const targetEmail = 'target@example.com'; + const bystanderEmail = 'bystander@example.com'; + const targetId = await seedEnrolledAdmin(targetEmail); + const bystanderId = await seedEnrolledAdmin(bystanderEmail); + + execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], { + env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH }, + stdio: 'pipe', + }); + + const target = await db('admin_users').where({ id: targetId }).first(); + const bystander = await db('admin_users').where({ id: bystanderId }).first(); + expect(Number(target.two_factor_enabled)).toBe(0); + expect(Number(bystander.two_factor_enabled)).toBe(1); + expect(bystander.two_factor_secret).toBe('iv.tag.ct'); +}); diff --git a/backend/__tests__/routes/adminMfa.test.js b/backend/__tests__/routes/adminMfa.test.js new file mode 100644 index 00000000..352f2a6e --- /dev/null +++ b/backend/__tests__/routes/adminMfa.test.js @@ -0,0 +1,345 @@ +/** + * HTTP-level tests for the admin TOTP MFA feature (#738). + * + * Two surfaces: + * 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable, + * GET /mfa/status, POST /mfa/disable — mounted like server.js at + * /api/admin/auth (src/routes/adminAuth.js). + * 2. Login challenge — POST /admin/login + POST /admin/login/mfa + * (src/routes/auth.js, mounted /api/auth). + * + * Uses the same real-SQLite harness as the CRM route tests + * (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are + * generated in-test via otplib's authenticator against the secret the + * /setup endpoint returns in plaintext. + * + * NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the + * first require of db.js — mirror adminCrmAuth.test.js exactly. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-')); +process.env.NODE_ENV = 'test'; +process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite'); +process.env.STORAGE_PATH = path.join(tmpDir, 'storage'); +fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true }); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret'; +// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login +// tests don't need a token. Be explicit so a leaked env can't flip it on. +delete process.env.RECAPTCHA_SECRET_KEY; + +const request = require('supertest'); +const bcrypt = require('bcrypt'); +const { authenticator } = require('otplib'); + +const { + bootCrmDb, mintAdminToken, buildRouteApp, +} = require('../integration/helpers/crmDb'); + +jest.setTimeout(60000); + +let db; +let cleanup; +let adminApp; // /api/admin/auth (enrollment) +let authApp; // /api/auth (login challenge) + +/** + * Seed a bare admin (password known) and return its id + login creds. + * seedMinimal always creates username 'tester'; we need distinct rows per + * scenario, so insert directly with a unique username/email. + */ +async function seedAdmin({ username, superAdmin = false } = {}) { + const password = 'correct-horse'; + const passwordHash = await bcrypt.hash(password, 4); + const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`; + const row = { + username: uname, + email: `${uname}@example.com`, + password_hash: passwordHash, + must_change_password: false, + is_active: true, + created_at: new Date(), + }; + if (superAdmin) { + const role = await db('roles').where({ name: 'super_admin' }).first(); + if (!role) throw new Error('super_admin role not seeded'); + row.role_id = role.id; + } + const inserted = await db('admin_users').insert(row).returning('id'); + const id = inserted[0]?.id ?? inserted[0]; + return { id, username: uname, password }; +} + +/** Run the full setup→enable enrollment against the live app. Returns + * the plaintext TOTP secret (for later login codes) and recovery codes. */ +async function enroll(adminId) { + const token = mintAdminToken(adminId); + const setup = await request(adminApp) + .post('/api/admin/auth/mfa/setup') + .set('Authorization', `Bearer ${token}`); + expect(setup.status).toBe(200); + const secret = setup.body.secret; + + const enable = await request(adminApp) + .post('/api/admin/auth/mfa/enable') + .set('Authorization', `Bearer ${token}`) + .send({ code: authenticator.generate(secret) }); + expect(enable.status).toBe(200); + return { secret, recoveryCodes: enable.body.recoveryCodes, token }; +} + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth')); + authApp = buildRouteApp('/api/auth', require('../../src/routes/auth')); +}, 60000); + +afterAll(async () => { + if (cleanup) await cleanup(); +}); + +describe('MFA enrollment — /api/admin/auth/mfa/*', () => { + it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => { + const admin = await seedAdmin(); + const token = mintAdminToken(admin.id); + + const res = await request(adminApp) + .post('/api/admin/auth/mfa/setup') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.secret).toEqual(expect.any(String)); + expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//); + expect(res.body.qr).toMatch(/^data:image\/png;base64,/); + + // Not yet enabled: status must still report disabled. + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(status.body.enabled).toBe(false); + + // And the row stores an encrypted secret (not the plaintext one). + const row = await db('admin_users').where({ id: admin.id }).first(); + expect(row.two_factor_secret).toBeTruthy(); + expect(row.two_factor_secret).not.toBe(res.body.secret); + expect(Number(row.two_factor_enabled)).toBe(0); + }); + + it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => { + const admin = await seedAdmin(); + const { recoveryCodes, token } = await enroll(admin.id); + + expect(Array.isArray(recoveryCodes)).toBe(true); + expect(recoveryCodes).toHaveLength(10); + + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(status.status).toBe(200); + expect(status.body.enabled).toBe(true); + expect(status.body.recoveryCodesRemaining).toBe(10); + expect(status.body.enrolledAt).toBeTruthy(); + }); + + it('enable with a WRONG code is rejected (400) and MFA stays off', async () => { + const admin = await seedAdmin(); + const token = mintAdminToken(admin.id); + const setup = await request(adminApp) + .post('/api/admin/auth/mfa/setup') + .set('Authorization', `Bearer ${token}`); + const valid = authenticator.generate(setup.body.secret); + const wrong = valid === '000000' ? '111111' : '000000'; + + const res = await request(adminApp) + .post('/api/admin/auth/mfa/enable') + .set('Authorization', `Bearer ${token}`) + .send({ code: wrong }); + expect(res.status).toBe(400); + + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(status.body.enabled).toBe(false); + }); + + it('enable before setup is rejected', async () => { + const admin = await seedAdmin(); + const token = mintAdminToken(admin.id); + const res = await request(adminApp) + .post('/api/admin/auth/mfa/enable') + .set('Authorization', `Bearer ${token}`) + .send({ code: '123456' }); + // No provisional secret → ValidationError (400). + expect(res.status).toBe(400); + }); + + it('all enrollment endpoints require a valid admin token (401 without one)', async () => { + const noToken = await request(adminApp).get('/api/admin/auth/mfa/status'); + expect(noToken.status).toBe(401); + const setup = await request(adminApp).post('/api/admin/auth/mfa/setup'); + expect(setup.status).toBe(401); + }); + + // Regression guard for #735: super_admin used to be blocked from enrolling. + // Enrollment operates on req.admin.id and is role-agnostic — assert a + // super_admin can complete the full setup→enable flow. + it('#735 regression — a super_admin can enroll in MFA', async () => { + const admin = await seedAdmin({ superAdmin: true }); + const { recoveryCodes, token } = await enroll(admin.id); + expect(recoveryCodes).toHaveLength(10); + + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(status.body.enabled).toBe(true); + }); +}); + +describe('MFA disable — /api/admin/auth/mfa/disable', () => { + it('requires a valid code; a wrong code is rejected and state persists', async () => { + const admin = await seedAdmin(); + const { token } = await enroll(admin.id); + + const bad = await request(adminApp) + .post('/api/admin/auth/mfa/disable') + .set('Authorization', `Bearer ${token}`) + .send({ code: '000000' }); + expect(bad.status).toBe(400); + + const stillOn = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(stillOn.body.enabled).toBe(true); + }); + + it('a valid TOTP disables MFA and clears the stored secret', async () => { + const admin = await seedAdmin(); + const { secret, token } = await enroll(admin.id); + + const res = await request(adminApp) + .post('/api/admin/auth/mfa/disable') + .set('Authorization', `Bearer ${token}`) + .send({ code: authenticator.generate(secret) }); + expect(res.status).toBe(200); + + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(status.body.enabled).toBe(false); + expect(status.body.recoveryCodesRemaining).toBe(0); + + const row = await db('admin_users').where({ id: admin.id }).first(); + expect(row.two_factor_secret).toBeNull(); + expect(row.two_factor_recovery_codes).toBeNull(); + }); +}); + +describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => { + it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => { + const admin = await seedAdmin(); + await enroll(admin.id); + + const res = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + + expect(res.status).toBe(200); + expect(res.body.mfaRequired).toBe(true); + expect(res.body.mfaToken).toEqual(expect.any(String)); + expect(res.body.user).toBeUndefined(); // no completed session + // No admin auth cookie should have been set on the challenge response. + const cookies = res.headers['set-cookie'] || []; + expect(cookies.join(';')).not.toMatch(/adminToken/i); + }); + + it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => { + const admin = await seedAdmin(); + const res = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + expect(res.status).toBe(200); + expect(res.body.mfaRequired).toBeUndefined(); + expect(res.body.user).toBeDefined(); + expect(res.body.user.username).toBe(admin.username); + }); + + it('login/mfa with a valid TOTP completes the session', async () => { + const admin = await seedAdmin(); + const { secret } = await enroll(admin.id); + + const challenge = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + const { mfaToken } = challenge.body; + + const res = await request(authApp) + .post('/api/auth/admin/login/mfa') + .send({ mfaToken, code: authenticator.generate(secret) }); + + expect(res.status).toBe(200); + expect(res.body.user).toBeDefined(); + expect(res.body.user.id).toBe(admin.id); + }); + + it('login/mfa with a wrong code is 401 MFA_INVALID', async () => { + const admin = await seedAdmin(); + const { secret } = await enroll(admin.id); + const challenge = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + + const valid = authenticator.generate(secret); + const wrong = valid === '000000' ? '111111' : '000000'; + const res = await request(authApp) + .post('/api/auth/admin/login/mfa') + .send({ mfaToken: challenge.body.mfaToken, code: wrong }); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('MFA_INVALID'); + expect(res.body.user).toBeUndefined(); + }); + + it('a recovery code logs in and is then single-use (second use fails)', async () => { + const admin = await seedAdmin(); + const { recoveryCodes } = await enroll(admin.id); + const recovery = recoveryCodes[0]; + + // First challenge + recovery-code exchange succeeds. + const c1 = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + const first = await request(authApp) + .post('/api/auth/admin/login/mfa') + .send({ mfaToken: c1.body.mfaToken, code: recovery }); + expect(first.status).toBe(200); + expect(first.body.user).toBeDefined(); + + // recoveryCodesRemaining dropped by one. + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${mintAdminToken(admin.id)}`); + expect(status.body.recoveryCodesRemaining).toBe(9); + + // Second use of the SAME recovery code must fail. + const c2 = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + const second = await request(authApp) + .post('/api/auth/admin/login/mfa') + .send({ mfaToken: c2.body.mfaToken, code: recovery }); + expect(second.status).toBe(401); + expect(second.body.code).toBe('MFA_INVALID'); + }); + + it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => { + const admin = await seedAdmin(); + await enroll(admin.id); + const res = await request(authApp) + .post('/api/auth/admin/login/mfa') + .send({ mfaToken: mintAdminToken(admin.id), code: '123456' }); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/__tests__/services/mfaService.test.js b/backend/__tests__/services/mfaService.test.js new file mode 100644 index 00000000..a6e5b36b --- /dev/null +++ b/backend/__tests__/services/mfaService.test.js @@ -0,0 +1,193 @@ +/** + * Unit tests for mfaService — admin TOTP MFA (#738). + * + * Pure unit: no DB, no Express. Exercises the crypto/verification surface + * directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt + * derivation has key material (the service derives the AES key from + * MFA_ENCRYPTION_KEY, falling back to JWT_SECRET). + */ + +// Must be set BEFORE the service is required — the key is derived lazily per +// call, but keep it explicit and stable so encrypt/decrypt round-trips. +process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret'; +delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET + +const { authenticator } = require('otplib'); +const mfaService = require('../../src/services/mfaService'); + +describe('mfaService — secret encryption (AES-256-GCM)', () => { + it('round-trips encrypt → decrypt to the original secret', () => { + const secret = mfaService.generateSecret(); + const blob = mfaService.encryptSecret(secret); + expect(blob).toEqual(expect.any(String)); + expect(blob).not.toContain(secret); // stored form is not plaintext + expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext + expect(mfaService.decryptSecret(blob)).toBe(secret); + }); + + it('produces a different ciphertext each time (random IV) but decrypts identically', () => { + const secret = mfaService.generateSecret(); + const a = mfaService.encryptSecret(secret); + const b = mfaService.encryptSecret(secret); + expect(a).not.toBe(b); + expect(mfaService.decryptSecret(a)).toBe(secret); + expect(mfaService.decryptSecret(b)).toBe(secret); + }); + + it('throws when decrypting a malformed blob (wrong segment count)', () => { + expect(() => mfaService.decryptSecret('garbage')).toThrow(); + expect(() => mfaService.decryptSecret('only.two')).toThrow(); + }); + + it('throws when the auth tag / ciphertext is tampered with', () => { + const secret = mfaService.generateSecret(); + const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.'); + // Flip a character in the ciphertext → GCM auth check must fail. + const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA'); + expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow(); + }); +}); + +describe('mfaService — TOTP verification', () => { + it('accepts a freshly generated code for the plaintext secret', () => { + const secret = mfaService.generateSecret(); + const code = authenticator.generate(secret); + expect(mfaService.verifyTotp(code, secret)).toBe(true); + }); + + it('tolerates whitespace in the submitted code', () => { + const secret = mfaService.generateSecret(); + const code = authenticator.generate(secret); + expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true); + }); + + it('rejects a wrong code', () => { + const secret = mfaService.generateSecret(); + const code = authenticator.generate(secret); + const wrong = code === '000000' ? '111111' : '000000'; + expect(mfaService.verifyTotp(wrong, secret)).toBe(false); + }); + + it('returns false for empty inputs rather than throwing', () => { + const secret = mfaService.generateSecret(); + expect(mfaService.verifyTotp('', secret)).toBe(false); + expect(mfaService.verifyTotp('123456', '')).toBe(false); + expect(mfaService.verifyTotp(null, secret)).toBe(false); + }); + + it('verifies through the encrypted blob (verifyTotpEncrypted)', () => { + const secret = mfaService.generateSecret(); + const stored = mfaService.encryptSecret(secret); + const code = authenticator.generate(secret); + expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true); + + const wrong = code === '000000' ? '111111' : '000000'; + expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false); + }); + + it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => { + const secret = mfaService.generateSecret(); + const code = authenticator.generate(secret); + expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false); + }); +}); + +describe('mfaService — otpauth URI / QR', () => { + it('builds an otpauth:// URI containing issuer, account and secret', () => { + const secret = mfaService.generateSecret(); + const uri = mfaService.buildOtpauthUri('admin@example.com', secret); + expect(uri).toMatch(/^otpauth:\/\/totp\//); + expect(uri).toContain(encodeURIComponent(mfaService.ISSUER)); + expect(uri).toContain(`secret=${secret}`); + }); + + it('builds a PNG data-URL QR for the URI', async () => { + const secret = mfaService.generateSecret(); + const uri = mfaService.buildOtpauthUri('admin@example.com', secret); + const qr = await mfaService.buildQrDataUrl(uri); + expect(qr).toMatch(/^data:image\/png;base64,/); + }); +}); + +describe('mfaService — recovery codes', () => { + it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => { + const { plain, hashed } = await mfaService.generateRecoveryCodes(); + expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT); + expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT); + expect(new Set(plain).size).toBe(10); + expect(new Set(hashed).size).toBe(10); + // Hashes are bcrypt, not the plaintext. + hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/)); + plain.forEach((p) => expect(hashed).not.toContain(p)); + }); + + it('formats a raw code into 4-char groups', () => { + expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij'); + }); + + it('consumes a valid recovery code once and removes it (single-use)', async () => { + const { plain, hashed } = await mfaService.generateRecoveryCodes(); + const target = plain[3]; + + const first = await mfaService.consumeRecoveryCode(target, hashed); + expect(first.matched).toBe(true); + expect(first.remainingHashes).toHaveLength(9); + + // Reusing the same code against the reduced set must now fail. + const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes); + expect(reuse.matched).toBe(false); + expect(reuse.remainingHashes).toHaveLength(9); + }); + + it('matches case-insensitively and trims whitespace', async () => { + const { plain, hashed } = await mfaService.generateRecoveryCodes(); + const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed); + expect(res.matched).toBe(true); + }); + + it('rejects a wrong code and leaves the hash set unchanged', async () => { + const { hashed } = await mfaService.generateRecoveryCodes(); + const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed); + expect(res.matched).toBe(false); + expect(res.remainingHashes).toHaveLength(10); + }); + + it('handles empty / missing input safely', async () => { + const { hashed } = await mfaService.generateRecoveryCodes(); + const res = await mfaService.consumeRecoveryCode('', hashed); + expect(res.matched).toBe(false); + expect(res.remainingHashes).toBe(hashed); + const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null); + expect(noHashes.matched).toBe(false); + expect(noHashes.remainingHashes).toEqual([]); + }); +}); + +describe('mfaService — parseRecoveryCodes', () => { + it('parses a JSON string array', () => { + expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']); + }); + it('passes an already-array through', () => { + expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']); + }); + it('returns [] for null / garbage / non-array JSON', () => { + expect(mfaService.parseRecoveryCodes(null)).toEqual([]); + expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]); + expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]); + }); +}); + +describe('mfaService — isEnrolled coercion', () => { + it('treats true / 1 / "1" as enrolled', () => { + expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true); + expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true); + expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true); + }); + it('treats false / 0 / null / missing as not enrolled', () => { + expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false); + expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false); + expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false); + expect(mfaService.isEnrolled({})).toBe(false); + expect(mfaService.isEnrolled(null)).toBe(false); + }); +}); diff --git a/backend/migrations/core/151_add_admin_mfa_recovery.js b/backend/migrations/core/151_add_admin_mfa_recovery.js new file mode 100644 index 00000000..fed7b651 --- /dev/null +++ b/backend/migrations/core/151_add_admin_mfa_recovery.js @@ -0,0 +1,58 @@ +/** + * Migration 151: admin MFA (TOTP) enrollment support — issue #738. + * + * The `admin_users.two_factor_enabled` / `two_factor_secret` columns already + * exist from the legacy migration 016 but were never wired to any code. This + * migration adds the two columns the real TOTP flow needs on top of them: + * + * - two_factor_recovery_codes: JSON array of one-time backup codes, stored + * HASHED (never plaintext), so a locked-out admin can log in without the + * authenticator. Consumed on use. + * - two_factor_enrolled_at: when the admin completed enrollment (audit / + * display only). + * + * The TOTP secret itself continues to live in the existing `two_factor_secret` + * column, but is now stored ENCRYPTED at rest (AES-256-GCM) by mfaService — + * the column type is unchanged (the encrypted blob is short). + * + * Additive and idempotent: only adds columns, guarded by hasColumn, so it is + * safe to re-run and touches no existing data. + */ +exports.up = async function (knex) { + const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes'); + const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at'); + const hasEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled'); + const hasSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret'); + + await knex.schema.alterTable('admin_users', (t) => { + // Backfill the legacy columns too, in case an install somehow lacks them + // (016 is a legacy migration; guard defensively). + if (!hasEnabled) { + t.boolean('two_factor_enabled').defaultTo(false); + } + if (!hasSecret) { + t.string('two_factor_secret').nullable(); + } + if (!hasRecovery) { + t.text('two_factor_recovery_codes').nullable(); + } + if (!hasEnrolledAt) { + t.timestamp('two_factor_enrolled_at').nullable(); + } + }); +}; + +exports.down = async function (knex) { + const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes'); + const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at'); + + await knex.schema.alterTable('admin_users', (t) => { + // Only drop what THIS migration added; leave the legacy 016 columns. + if (hasRecovery) { + t.dropColumn('two_factor_recovery_codes'); + } + if (hasEnrolledAt) { + t.dropColumn('two_factor_enrolled_at'); + } + }); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 60ecdaa0..87fbe8ea 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-backend", - "version": "3.74.0-beta.0", + "version": "3.80.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "3.74.0-beta.0", + "version": "3.80.0-beta.0", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", @@ -40,6 +40,7 @@ "node-cron": "^3.0.2", "node-stream-zip": "^1.15.0", "nodemailer": "^9.0.1", + "otplib": "^12.0.1", "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", "pg": "^8.16.3", @@ -2703,6 +2704,56 @@ "node": ">=10" } }, + "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/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -9371,6 +9422,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", @@ -11668,6 +11730,14 @@ "dev": true, "license": "MIT" }, + "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/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 3d97a11e..702e7cf1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -46,9 +46,11 @@ "node-cron": "^3.0.2", "node-stream-zip": "^1.15.0", "nodemailer": "^9.0.1", + "otplib": "^12.0.1", "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", "pg": "^8.16.3", + "postcss": "8.5.10", "qrcode": "^1.5.4", "react-i18next": "^15.6.0", "sanitize-html": "^2.17.0", @@ -57,11 +59,10 @@ "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "swissqrbill": "^4.3.0", + "tar": ">=7.5.16", "uuid": "^11.1.1", "winston": "^3.8.2", - "zxcvbn": "^4.4.2", - "postcss": "8.5.10", - "tar": ">=7.5.16" + "zxcvbn": "^4.4.2" }, "devDependencies": { "eslint": "^8.40.0", diff --git a/backend/scripts/reset-admin-mfa.js b/backend/scripts/reset-admin-mfa.js new file mode 100644 index 00000000..ce4985cb --- /dev/null +++ b/backend/scripts/reset-admin-mfa.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * reset-admin-mfa.js — disable two-factor auth for a locked-out admin (#738). + * + * Break-glass recovery for when an admin loses their authenticator AND their + * recovery codes. Clears the MFA state so the admin can log in with just their + * password and re-enroll from Settings. + * + * Usage (inside the running backend container): + * docker compose exec backend node scripts/reset-admin-mfa.js --email admin@example.com + * docker compose exec backend node scripts/reset-admin-mfa.js --all --yes + * + * Flags: + * --email target a single admin by email (or --username ) + * --all reset MFA for EVERY admin (full lockout / break-glass) + * --yes non-interactive (skip the confirmation prompt) + */ + +const readline = require('readline'); +const { db, logActivity } = require('../src/database/db'); + +const args = process.argv.slice(2); +const hasFlag = (f) => args.includes(f); +const getOption = (name) => { + const i = args.indexOf(`--${name}`); + return i !== -1 && i + 1 < args.length ? args[i + 1] : null; +}; + +const force = hasFlag('--yes') || hasFlag('--force') || hasFlag('--non-interactive'); +const all = hasFlag('--all'); +const email = getOption('email'); +const username = getOption('username'); + +const MFA_CLEAR = { + two_factor_enabled: false, + two_factor_secret: null, + two_factor_recovery_codes: null, + two_factor_enrolled_at: null, + updated_at: new Date(), +}; + +function ask(prompt) { + if (force) return Promise.resolve('yes'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => rl.question(prompt, (a) => { rl.close(); resolve(a); })); +} + +async function main() { + console.log('\n========================================'); + console.log('PicPeak Admin MFA Reset Tool'); + console.log('========================================\n'); + + if (!all && !email && !username) { + console.error('❌ Specify a target: --email , --username , or --all'); + console.log(' e.g. node scripts/reset-admin-mfa.js --email admin@example.com'); + process.exit(1); + } + + // Resolve target admins. + let targets; + if (all) { + targets = await db('admin_users').select('id', 'username', 'email', 'two_factor_enabled'); + } else { + const q = db('admin_users'); + if (email) q.where({ email }); + if (username) q.where({ username }); + targets = await q.select('id', 'username', 'email', 'two_factor_enabled'); + } + + if (targets.length === 0) { + console.error('❌ No matching admin user found.'); + process.exit(1); + } + + const enrolled = targets.filter((t) => t.two_factor_enabled === true || t.two_factor_enabled === 1); + console.log(`Matched ${targets.length} admin(s); ${enrolled.length} currently have MFA enabled:`); + for (const t of targets) { + const flag = (t.two_factor_enabled === true || t.two_factor_enabled === 1) ? 'MFA ON' : 'mfa off'; + console.log(` - ${t.username} <${t.email}> [${flag}]`); + } + + const confirm = await ask('\nDisable MFA for the above? (yes/no): '); + const normalized = String(confirm).trim().toLowerCase(); + if (normalized !== 'yes' && normalized !== 'y') { + console.log('\n❌ Cancelled. No changes made.'); + process.exit(0); + } + + const ids = targets.map((t) => t.id); + const updated = await db('admin_users').whereIn('id', ids).update(MFA_CLEAR); + + for (const t of targets) { + try { + await logActivity('admin_mfa_reset_cli', + { admin_id: t.id, via: 'cli' }, + null, + { type: 'system', id: 0, name: 'reset-admin-mfa.js' } + ); + } catch (_) { /* activity log is best-effort */ } + } + + console.log(`\n✅ MFA disabled for ${updated} admin(s). They can now log in with just their password and re-enroll from Settings → Security.`); + process.exit(0); +} + +main().catch((err) => { + console.error('❌ Failed to reset MFA:', err.message); + process.exit(1); +}); diff --git a/backend/src/routes/adminAuth.js b/backend/src/routes/adminAuth.js index b2224825..898f029c 100644 --- a/backend/src/routes/adminAuth.js +++ b/backend/src/routes/adminAuth.js @@ -10,6 +10,7 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors'); const { setAdminAuthCookie } = require('../utils/tokenUtils'); const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization'); +const mfaService = require('../services/mfaService'); const router = express.Router(); // Get admin profile @@ -184,4 +185,175 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => { successResponse(res, { message: 'Logged out successfully' }); })); +// --------------------------------------------------------------------------- +// Multi-factor authentication (TOTP) — issue #738. +// +// All endpoints operate on the AUTHENTICATED admin's own account +// (req.admin.id) — enrollment is per-user and works for every role, +// super_admin included (closes #735). The TOTP secret is stored encrypted +// at rest and recovery codes are hashed; see services/mfaService.js. +// --------------------------------------------------------------------------- + +const isMfaEnabled = mfaService.isEnrolled; + +// Current MFA state for the logged-in admin. +router.get('/mfa/status', adminAuth, handleAsync(async (req, res) => { + const admin = await db('admin_users').where('id', req.admin.id).first(); + if (!admin) throw new NotFoundError('Admin user'); + const enabled = isMfaEnabled(admin); + res.json({ + enabled, + enrolledAt: enabled ? admin.two_factor_enrolled_at || null : null, + recoveryCodesRemaining: enabled + ? mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes).length + : 0 + }); +})); + +// Begin enrollment: mint a provisional secret, store it encrypted (NOT yet +// enabled), and return the otpauth URI + QR for the authenticator app. Calling +// this again before /enable simply regenerates the provisional secret. +router.post('/mfa/setup', adminAuth, handleAsync(async (req, res) => { + const admin = await db('admin_users').where('id', req.admin.id).first(); + if (!admin) throw new NotFoundError('Admin user'); + if (isMfaEnabled(admin)) { + throw new ConflictError('Two-factor authentication is already enabled'); + } + + const secret = mfaService.generateSecret(); + await db('admin_users').where('id', admin.id).update({ + two_factor_secret: mfaService.encryptSecret(secret), + two_factor_enabled: false, + two_factor_recovery_codes: null, + two_factor_enrolled_at: null, + updated_at: new Date() + }); + + const accountName = admin.email || admin.username; + const otpauthUri = mfaService.buildOtpauthUri(accountName, secret); + const qr = await mfaService.buildQrDataUrl(otpauthUri); + + res.json({ + // `secret` is returned for manual entry when a QR can't be scanned. + secret, + otpauthUri, + qr, + issuer: mfaService.ISSUER, + account: accountName + }); +})); + +// Complete enrollment: verify a code against the provisional secret, enable +// MFA, and return one-time recovery codes (shown exactly once). +router.post('/mfa/enable', [ + adminAuth, + body('code').notEmpty().withMessage('Verification code is required') +], handleAsync(async (req, res) => { + validateRequest(req); + const admin = await db('admin_users').where('id', req.admin.id).first(); + if (!admin) throw new NotFoundError('Admin user'); + if (isMfaEnabled(admin)) { + throw new ConflictError('Two-factor authentication is already enabled'); + } + if (!admin.two_factor_secret) { + throw new ValidationError('Start setup before enabling two-factor authentication'); + } + if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) { + throw new ValidationError('Invalid verification code'); + } + + const { plain, hashed } = await mfaService.generateRecoveryCodes(); + await db('admin_users').where('id', admin.id).update({ + two_factor_enabled: true, + two_factor_enrolled_at: new Date(), + two_factor_recovery_codes: JSON.stringify(hashed), + updated_at: new Date() + }); + + await logActivity('admin_mfa_enabled', + { admin_id: admin.id }, + null, + { type: 'admin', id: admin.id, name: admin.username } + ); + + successResponse(res, { + message: 'Two-factor authentication enabled', + recoveryCodes: plain + }); +})); + +// Disable MFA. Requires a fresh TOTP or recovery code so a hijacked session +// can't silently strip the second factor. +router.post('/mfa/disable', [ + adminAuth, + body('code').notEmpty().withMessage('A current code is required to disable 2FA') +], handleAsync(async (req, res) => { + validateRequest(req); + const admin = await db('admin_users').where('id', req.admin.id).first(); + if (!admin) throw new NotFoundError('Admin user'); + if (!isMfaEnabled(admin)) { + throw new ValidationError('Two-factor authentication is not enabled'); + } + + const totpOk = mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret); + let recoveryOk = false; + if (!totpOk) { + const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes); + recoveryOk = (await mfaService.consumeRecoveryCode(req.body.code, stored)).matched; + } + if (!totpOk && !recoveryOk) { + throw new ValidationError('Invalid verification code'); + } + + await db('admin_users').where('id', admin.id).update({ + two_factor_enabled: false, + two_factor_secret: null, + two_factor_recovery_codes: null, + two_factor_enrolled_at: null, + updated_at: new Date() + }); + + await logActivity('admin_mfa_disabled', + { admin_id: admin.id }, + null, + { type: 'admin', id: admin.id, name: admin.username } + ); + + successResponse(res, { message: 'Two-factor authentication disabled' }); +})); + +// Regenerate recovery codes (invalidates the old set). Requires a fresh TOTP +// code. Returns the new codes once. +router.post('/mfa/recovery-codes', [ + adminAuth, + body('code').notEmpty().withMessage('A current authenticator code is required') +], handleAsync(async (req, res) => { + validateRequest(req); + const admin = await db('admin_users').where('id', req.admin.id).first(); + if (!admin) throw new NotFoundError('Admin user'); + if (!isMfaEnabled(admin)) { + throw new ValidationError('Two-factor authentication is not enabled'); + } + if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) { + throw new ValidationError('Invalid verification code'); + } + + const { plain, hashed } = await mfaService.generateRecoveryCodes(); + await db('admin_users').where('id', admin.id).update({ + two_factor_recovery_codes: JSON.stringify(hashed), + updated_at: new Date() + }); + + await logActivity('admin_mfa_recovery_regenerated', + { admin_id: admin.id }, + null, + { type: 'admin', id: admin.id, name: admin.username } + ); + + successResponse(res, { + message: 'Recovery codes regenerated', + recoveryCodes: plain + }); +})); + module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 3ad12e8c..ff496d9a 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -2,9 +2,10 @@ const express = require('express'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const { body, validationResult } = require('express-validator'); -const { db } = require('../database/db'); +const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { verifyRecaptcha } = require('../services/recaptcha'); +const mfaService = require('../services/mfaService'); const { trackFailedAttempt, trackSuccessfulLogin, @@ -33,6 +34,49 @@ const { } = require('../utils/passwordValidation'); const router = express.Router(); +/** + * Finish a successful admin login: reset the lockout counter, stamp + * last_login, mint the 24h admin JWT, set the HttpOnly cookie, and return the + * user payload. Shared by the direct (no-MFA) path and the MFA-verify path so + * both produce an identical session. `lockoutKey` is the identifier the user + * typed (username or email) so success/failure tracking stays in one bucket. + */ +async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) { + await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent); + + await db('admin_users').where('id', admin.id).update({ + last_login: new Date(), + last_login_ip: ipAddress + }); + + const token = jwt.sign({ + id: admin.id, + username: admin.username, + type: 'admin', + role: admin.role_name, + ip: ipAddress, + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '24h', + issuer: 'picpeak-auth' + }); + + setAdminAuthCookie(res, token); + + return res.json({ + user: { + id: admin.id, + username: admin.username, + email: admin.email, + mustChangePassword: admin.must_change_password || false, + role: admin.role_name ? { + name: admin.role_name, + displayName: admin.role_display_name + } : null + } + }); +} + // Admin login with enhanced security router.post('/admin/login', [ body('username').notEmpty().trim(), @@ -95,49 +139,132 @@ router.post('/admin/login', [ return res.status(401).json({ error: getGenericAuthError() }); } - // Successful login - await trackSuccessfulLogin(username, ipAddress, userAgent); - - // Update last login and login metadata - await db('admin_users').where('id', admin.id).update({ - last_login: new Date(), - last_login_ip: ipAddress - }); - - // Generate token with additional claims including role - const token = jwt.sign({ - id: admin.id, - username: admin.username, - type: 'admin', - role: admin.role_name, // Add role to JWT - ip: ipAddress, - loginTime: Date.now() - }, process.env.JWT_SECRET, { - expiresIn: '24h', - issuer: 'picpeak-auth' - }); - - setAdminAuthCookie(res, token); - - // Token is delivered via HttpOnly cookie only (not in response body) - res.json({ - user: { + // Second factor: if this admin has TOTP enabled, do NOT complete the login + // yet. Issue a short-lived, single-purpose mfa_pending token and require the + // code via /admin/login/mfa. We deliberately don't reset the lockout counter + // (trackSuccessfulLogin) or stamp last_login until the second factor passes, + // so MFA brute-force is still gated by the account lockout. `loginId` carries + // the typed identifier so the verify step tracks the same lockout bucket. + if (mfaService.isEnrolled(admin)) { + const mfaToken = jwt.sign({ id: admin.id, username: admin.username, - email: admin.email, - mustChangePassword: admin.must_change_password || false, - role: admin.role_name ? { - name: admin.role_name, - displayName: admin.role_display_name - } : null - } - }); + type: 'mfa_pending', + loginId: username + }, process.env.JWT_SECRET, { + expiresIn: '5m', + issuer: 'picpeak-auth' + }); + return res.json({ mfaRequired: true, mfaToken }); + } + + return await completeAdminLogin(req, res, admin, ipAddress, userAgent, username); } catch (error) { logger.error('Login error:', error); res.status(500).json({ error: 'Login failed' }); } }); +// Second-factor verification. Exchanges the short-lived mfa_pending token +// (from /admin/login) plus a TOTP or recovery code for a full admin session. +router.post('/admin/login/mfa', [ + body('mfaToken').notEmpty(), + body('code').notEmpty().trim() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { mfaToken, code } = req.body; + const ipAddress = getClientIp(req); + const userAgent = req.headers['user-agent'] || ''; + + let decoded; + try { + decoded = jwt.verify(mfaToken, process.env.JWT_SECRET, { + algorithms: ['HS256'], + issuer: 'picpeak-auth' + }); + } catch (err) { + return res.status(401).json({ + error: 'Your verification session expired. Please sign in again.', + code: 'MFA_SESSION_EXPIRED' + }); + } + + if (decoded.type !== 'mfa_pending') { + return res.status(401).json({ error: getGenericAuthError() }); + } + + const lockoutKey = decoded.loginId || decoded.username; + const lockoutStatus = await checkAccountLockout(lockoutKey); + if (lockoutStatus.isLocked) { + return res.status(423).json({ + error: 'Account temporarily locked due to too many failed attempts', + retryAfter: lockoutStatus.remainingTime + }); + } + + const admin = await db('admin_users') + .leftJoin('roles', 'roles.id', 'admin_users.role_id') + .where('admin_users.id', decoded.id) + .select( + 'admin_users.*', + 'roles.name as role_name', + 'roles.display_name as role_display_name' + ) + .first(); + + if (!admin || !admin.is_active || !mfaService.isEnrolled(admin)) { + return res.status(401).json({ error: getGenericAuthError() }); + } + + // TOTP first, then a one-time recovery code. + let ok = mfaService.verifyTotpEncrypted(code, admin.two_factor_secret); + let usedRecovery = false; + let remainingHashes = null; + if (!ok) { + const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes); + const result = await mfaService.consumeRecoveryCode(code, stored); + if (result.matched) { + ok = true; + usedRecovery = true; + remainingHashes = result.remainingHashes; + } + } + + if (!ok) { + await trackFailedAttempt(lockoutKey, ipAddress, userAgent); + return res.status(401).json({ error: 'Invalid verification code', code: 'MFA_INVALID' }); + } + + if (usedRecovery) { + await db('admin_users').where('id', admin.id).update({ + two_factor_recovery_codes: JSON.stringify(remainingHashes), + updated_at: new Date() + }); + await logActivity('admin_mfa_recovery_used', + { admin_id: admin.id, remaining: remainingHashes.length }, + null, + { type: 'admin', id: admin.id, name: admin.username } + ); + } + + await logActivity('admin_mfa_login', + { admin_id: admin.id, method: usedRecovery ? 'recovery_code' : 'totp' }, + null, + { type: 'admin', id: admin.id, name: admin.username } + ); + + return await completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey); + } catch (error) { + logger.error('MFA verification error:', error); + res.status(500).json({ error: 'Verification failed' }); + } +}); + // Logout endpoint router.post('/logout', async (req, res) => { try { diff --git a/backend/src/services/mfaService.js b/backend/src/services/mfaService.js new file mode 100644 index 00000000..422a91a5 --- /dev/null +++ b/backend/src/services/mfaService.js @@ -0,0 +1,183 @@ +/** + * mfaService — TOTP (RFC 6238) multi-factor auth for admin accounts (#738). + * + * Responsibilities: + * - generate/verify TOTP secrets (otplib, standard SHA1/6-digit/30s so + * Google Authenticator / Authy / 1Password all work); + * - encrypt the secret at rest (AES-256-GCM) so a DB leak alone doesn't + * yield working authenticator seeds; + * - generate/verify one-time recovery codes, hashed (bcrypt) and single-use; + * - build the otpauth:// URI + QR data-URL for enrollment. + * + * The encryption key is derived (scrypt) from MFA_ENCRYPTION_KEY when set, + * otherwise from JWT_SECRET. Rotating either invalidates stored secrets — + * the same blast radius as rotating JWT_SECRET already has for sessions, and + * `reset-admin-mfa.js` is the recovery path. + */ + +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const { authenticator } = require('otplib'); +const QRCode = require('qrcode'); + +// Standard TOTP params; window:1 tolerates ±1 step (30s) of clock drift. +authenticator.options = { window: 1 }; + +const ISSUER = 'PicPeak'; +const RECOVERY_CODE_COUNT = 10; +const RECOVERY_CODE_BYTES = 10; // ~80 bits of entropy per code +const RECOVERY_BCRYPT_ROUNDS = 10; + +const ENC_ALGO = 'aes-256-gcm'; +const ENC_SALT = 'picpeak-mfa-secret-v1'; // fixed: derivation must be stable + +function getEncryptionKey() { + const material = process.env.MFA_ENCRYPTION_KEY || process.env.JWT_SECRET; + if (!material) { + throw new Error('mfaService: MFA_ENCRYPTION_KEY or JWT_SECRET must be set'); + } + return crypto.scryptSync(material, ENC_SALT, 32); +} + +/** Generate a fresh base32 TOTP secret. */ +function generateSecret() { + return authenticator.generateSecret(); +} + +/** AES-256-GCM encrypt a secret → "iv.tag.ciphertext" (all base64url). */ +function encryptSecret(plainSecret) { + const key = getEncryptionKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ENC_ALGO, key, iv); + const ct = Buffer.concat([cipher.update(plainSecret, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return [iv, tag, ct].map((b) => b.toString('base64url')).join('.'); +} + +/** Reverse of encryptSecret. Throws on tamper/wrong key. */ +function decryptSecret(stored) { + const key = getEncryptionKey(); + const [ivB64, tagB64, ctB64] = String(stored).split('.'); + if (!ivB64 || !tagB64 || !ctB64) { + throw new Error('mfaService: malformed encrypted secret'); + } + const decipher = crypto.createDecipheriv(ENC_ALGO, key, Buffer.from(ivB64, 'base64url')); + decipher.setAuthTag(Buffer.from(tagB64, 'base64url')); + const pt = Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]); + return pt.toString('utf8'); +} + +/** Verify a 6-digit TOTP code against the (plaintext) secret. */ +function verifyTotp(code, plainSecret) { + if (!code || !plainSecret) return false; + try { + return authenticator.verify({ token: String(code).replace(/\s+/g, ''), secret: plainSecret }); + } catch { + return false; + } +} + +/** Verify a code against a STORED (encrypted) secret. */ +function verifyTotpEncrypted(code, storedSecret) { + try { + return verifyTotp(code, decryptSecret(storedSecret)); + } catch { + return false; + } +} + +/** otpauth:// URI for an authenticator app. */ +function buildOtpauthUri(accountName, plainSecret) { + return authenticator.keyuri(accountName, ISSUER, plainSecret); +} + +/** QR code (PNG data URL) for the otpauth URI. */ +async function buildQrDataUrl(otpauthUri) { + return QRCode.toDataURL(otpauthUri, { errorCorrectionLevel: 'M', margin: 1, width: 240 }); +} + +/** Format a raw code as human-friendly groups, e.g. "abcd-efgh-jk". */ +function formatRecoveryCode(raw) { + return raw.match(/.{1,4}/g).join('-'); +} + +/** + * Generate RECOVERY_CODE_COUNT one-time codes. Returns the plaintext codes + * (shown to the admin ONCE) and their bcrypt hashes (persisted). + */ +async function generateRecoveryCodes() { + const plain = []; + const hashed = []; + for (let i = 0; i < RECOVERY_CODE_COUNT; i++) { + // base32-ish, lowercase, no ambiguous chars + const raw = crypto.randomBytes(RECOVERY_CODE_BYTES) + .toString('base64') + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase() + .slice(0, 10); + const code = formatRecoveryCode(raw); + plain.push(code); + hashed.push(await bcrypt.hash(code, RECOVERY_BCRYPT_ROUNDS)); + } + return { plain, hashed }; +} + +function normalizeRecoveryInput(code) { + return String(code || '').trim().toLowerCase(); +} + +/** + * Check a submitted recovery code against the stored hash array. On match, + * returns the remaining hashes (matched one removed — single use). On miss, + * matched:false and the array unchanged. + * + * @param {string[]} storedHashes + * @returns {Promise<{matched: boolean, remainingHashes: string[]}>} + */ +async function consumeRecoveryCode(code, storedHashes) { + const input = normalizeRecoveryInput(code); + const hashes = Array.isArray(storedHashes) ? storedHashes : []; + if (!input) return { matched: false, remainingHashes: hashes }; + for (let i = 0; i < hashes.length; i++) { + // eslint-disable-next-line no-await-in-loop + if (await bcrypt.compare(input, hashes[i])) { + const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1)); + return { matched: true, remainingHashes: remaining }; + } + } + return { matched: false, remainingHashes: hashes }; +} + +/** True when an admin row has MFA enabled (coerces SQLite/PG boolean shapes). */ +function isEnrolled(admin) { + const v = admin && admin.two_factor_enabled; + return v === true || v === 1 || v === '1'; +} + +/** Parse the DB column (JSON text) into an array of hashes. */ +function parseRecoveryCodes(raw) { + if (!raw) return []; + try { + const arr = typeof raw === 'string' ? JSON.parse(raw) : raw; + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } +} + +module.exports = { + generateSecret, + encryptSecret, + decryptSecret, + verifyTotp, + verifyTotpEncrypted, + buildOtpauthUri, + buildQrDataUrl, + generateRecoveryCodes, + consumeRecoveryCode, + parseRecoveryCodes, + isEnrolled, + formatRecoveryCode, + ISSUER, + RECOVERY_CODE_COUNT, +}; diff --git a/frontend/src/features/settings/components/MfaSettingsCard.tsx b/frontend/src/features/settings/components/MfaSettingsCard.tsx new file mode 100644 index 00000000..7bf0a58d --- /dev/null +++ b/frontend/src/features/settings/components/MfaSettingsCard.tsx @@ -0,0 +1,320 @@ +import React, { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { ShieldCheck, ShieldOff, Copy, Download, Check, KeyRound, AlertTriangle } from 'lucide-react'; + +import { Button, Card, Input, Loading, useConfirm } from '../../../components/common'; +import { mfaService } from '../../../services/mfa.service'; + +// Per-user admin TOTP MFA management (issue #738). Lives on the admin's own +// account surface (Settings → General → Admin Account). Self-service: acts on +// the currently authenticated admin only. + +interface RecoveryCodesPanelProps { + codes: string[]; + onConfirm: () => void; +} + +const RecoveryCodesPanel: React.FC = ({ codes, onConfirm }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const [acknowledged, setAcknowledged] = useState(false); + + const asText = codes.join('\n'); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(asText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error(t('settings.mfa.copyFailed')); + } + }; + + const handleDownload = () => { + const blob = new Blob([`${asText}\n`], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'picpeak-recovery-codes.txt'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( +
+
+ +

{t('settings.mfa.recoveryCodesWarning')}

+
+ +
+ {codes.map((code) => ( + {code} + ))} +
+ +
+ + +
+ + + + +
+ ); +}; + +export const MfaSettingsCard: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const confirm = useConfirm(); + + const { data: status, isLoading } = useQuery({ + queryKey: ['admin-mfa-status'], + queryFn: () => mfaService.getStatus(), + }); + + // Enrollment flow state + const [setupData, setSetupData] = useState> | null>(null); + const [enableCode, setEnableCode] = useState(''); + const [enableError, setEnableError] = useState(null); + + // Recovery codes to display once (after enable or regenerate) + const [recoveryCodes, setRecoveryCodes] = useState(null); + + // Regenerate flow state + const [showRegenerate, setShowRegenerate] = useState(false); + const [regenerateCode, setRegenerateCode] = useState(''); + const [regenerateError, setRegenerateError] = useState(null); + + const invalidateStatus = () => queryClient.invalidateQueries({ queryKey: ['admin-mfa-status'] }); + + const errorMessage = (error: any, fallbackKey: string): string => + error?.response?.data?.error || t(fallbackKey); + + const setupMutation = useMutation({ + mutationFn: () => mfaService.setup(), + onSuccess: (data) => { + setSetupData(data); + setEnableCode(''); + setEnableError(null); + }, + onError: (error) => toast.error(errorMessage(error, 'settings.mfa.setupFailed')), + }); + + const enableMutation = useMutation({ + mutationFn: (code: string) => mfaService.enable(code), + onSuccess: (data) => { + setRecoveryCodes(data.recoveryCodes); + setSetupData(null); + setEnableCode(''); + setEnableError(null); + invalidateStatus(); + }, + onError: (error) => setEnableError(errorMessage(error, 'settings.mfa.enableFailed')), + }); + + const disableMutation = useMutation({ + mutationFn: (code: string) => mfaService.disable(code), + onSuccess: () => { + toast.success(t('settings.mfa.disabledToast')); + invalidateStatus(); + }, + onError: (error) => toast.error(errorMessage(error, 'settings.mfa.disableFailed')), + }); + + const regenerateMutation = useMutation({ + mutationFn: (code: string) => mfaService.regenerateRecoveryCodes(code), + onSuccess: (data) => { + setRecoveryCodes(data.recoveryCodes); + setShowRegenerate(false); + setRegenerateCode(''); + setRegenerateError(null); + invalidateStatus(); + }, + onError: (error) => setRegenerateError(errorMessage(error, 'settings.mfa.regenerateFailed')), + }); + + const handleDisable = async () => { + const code = window.prompt(t('settings.mfa.disablePrompt')); + if (code === null) return; + const trimmed = code.trim(); + if (!trimmed) { + toast.error(t('settings.mfa.codeRequired')); + return; + } + const ok = await confirm({ + title: t('settings.mfa.disableConfirmTitle'), + message: t('settings.mfa.disableConfirmMessage'), + variant: 'danger', + confirmLabel: t('settings.mfa.disableConfirmButton'), + }); + if (ok) disableMutation.mutate(trimmed); + }; + + return ( + +
+ +

{t('settings.mfa.title')}

+
+

{t('settings.mfa.description')}

+ + {isLoading ? ( +
+ +
+ ) : recoveryCodes ? ( + setRecoveryCodes(null)} /> + ) : status?.enabled ? ( + /* ---------------- Enrolled ---------------- */ +
+
+ + {t('settings.mfa.enabledBadge')} +
+ +

+ {t('settings.mfa.recoveryCodesRemaining', { count: status.recoveryCodesRemaining })} +

+ + {showRegenerate ? ( +
+

{t('settings.mfa.regenerateHelp')}

+ { + setRegenerateCode(e.target.value); + if (regenerateError) setRegenerateError(null); + }} + placeholder={t('settings.mfa.codePlaceholder')} + leftIcon={} + error={regenerateError || undefined} + autoComplete="one-time-code" + /> +
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) : setupData ? ( + /* ---------------- Setup in progress ---------------- */ +
+

{t('settings.mfa.setupScanInstruction')}

+
+ {t('settings.mfa.qrAlt')} +
+

{t('settings.mfa.manualEntry')}

+ + {setupData.secret} + +
+
+ +
+ + { + setEnableCode(e.target.value); + if (enableError) setEnableError(null); + }} + placeholder={t('settings.mfa.codePlaceholder')} + leftIcon={} + error={enableError || undefined} + inputMode="numeric" + autoComplete="one-time-code" + /> +
+ +
+ + +
+
+ ) : ( + /* ---------------- Not enrolled ---------------- */ +
+

{t('settings.mfa.notEnrolled')}

+ +
+ )} +
+ ); +}; diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts index 7344907e..b8977d55 100644 --- a/frontend/src/features/settings/hooks/useSettingsState.ts +++ b/frontend/src/features/settings/hooks/useSettingsState.ts @@ -35,7 +35,6 @@ export interface GeneralSettings { export interface SecuritySettings { password_min_length: number; password_complexity: string; - enable_2fa: boolean; session_timeout_minutes: number; max_login_attempts: number; attempt_window_minutes: number; @@ -134,7 +133,6 @@ export function useSettingsState() { const [securitySettings, setSecuritySettings] = useState({ password_min_length: 8, password_complexity: 'moderate', - enable_2fa: false, session_timeout_minutes: 60, max_login_attempts: 5, attempt_window_minutes: 15, @@ -231,7 +229,6 @@ export function useSettingsState() { setSecuritySettings({ password_min_length: toNumber(settings.security_password_min_length, 8), password_complexity: settings.security_password_complexity ?? 'moderate', - enable_2fa: toBoolean(settings.security_enable_2fa, false), session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60), max_login_attempts: toNumber(settings.security_max_login_attempts, 5), attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15), diff --git a/frontend/src/features/settings/tabs/GeneralTab.tsx b/frontend/src/features/settings/tabs/GeneralTab.tsx index 85e63de3..d250570e 100644 --- a/frontend/src/features/settings/tabs/GeneralTab.tsx +++ b/frontend/src/features/settings/tabs/GeneralTab.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import type { GeneralSettings } from '../hooks/useSettingsState'; import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState'; import { SUPPORTED_LANGUAGES } from "../../../components/common/LanguageSelector.tsx"; +import { MfaSettingsCard } from '../components/MfaSettingsCard'; interface GeneralTabProps { generalSettings: GeneralSettings; @@ -94,6 +95,10 @@ export const GeneralTab: React.FC = ({ )} + {/* Per-user two-factor authentication (issue #738) — lives beside the + admin's own account details rather than the admin-wide Security tab. */} + +

{t('settings.general.siteConfiguration')}

diff --git a/frontend/src/features/settings/tabs/SecurityTab.tsx b/frontend/src/features/settings/tabs/SecurityTab.tsx index 79675bf0..cb338f13 100644 --- a/frontend/src/features/settings/tabs/SecurityTab.tsx +++ b/frontend/src/features/settings/tabs/SecurityTab.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Save, Key, AlertCircle } from 'lucide-react'; +import { Save, Key, AlertCircle, ShieldCheck } from 'lucide-react'; import { Button, Card, Input } from '../../../components/common'; import { useTranslation } from 'react-i18next'; import type { SecuritySettings } from '../hooks/useSettingsState'; @@ -124,15 +124,15 @@ export const SecurityTab: React.FC = ({ - +
+
+ +
+

{t('settings.security.twoFactorTitle')}

+

{t('settings.security.twoFactorNote')}

+
+
+
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 3b761a57..633af451 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1422,13 +1422,14 @@ "attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden", "lockoutDurationMinutes": "Sperrdauer (Minuten)", "lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben", - "enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren", "recaptchaSettings": "reCAPTCHA-Einstellungen", "enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren", "siteKey": "Site-Schlüssel", "secretKey": "Geheimer Schlüssel", "recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von", - "saveSecuritySettings": "Sicherheitseinstellungen speichern" + "saveSecuritySettings": "Sicherheitseinstellungen speichern", + "twoFactorTitle": "Zwei-Faktor-Authentifizierung", + "twoFactorNote": "Die Zwei-Faktor-Authentifizierung wird jetzt pro Admin unter Einstellungen → Allgemein → Admin-Konto verwaltet. Jeder Admin aktiviert sie für seine eigene Anmeldung." }, "events": { "title": "Veranstaltungserstellung", @@ -2040,6 +2041,42 @@ "testSend": "Test senden", "testSending": "Senden…", "testSentToast": "Testnachricht gesendet (ID: {{id}})." + }, + "mfa": { + "title": "Zwei-Faktor-Authentifizierung", + "description": "Sichere deine Admin-Anmeldung mit einem zweiten Schritt über eine Authenticator-App (TOTP).", + "notEnrolled": "Die Zwei-Faktor-Authentifizierung ist für dein Konto nicht aktiviert.", + "setUp": "Einrichten", + "setupScanInstruction": "Scanne diesen QR-Code mit deiner Authenticator-App (z. B. Google Authenticator, 1Password, Authy).", + "manualEntry": "Oder gib diesen Schlüssel manuell ein:", + "qrAlt": "QR-Code zur Zwei-Faktor-Einrichtung", + "enterCodeLabel": "Gib den 6-stelligen Code aus deiner App ein", + "codePlaceholder": "123456", + "enable": "Aktivieren", + "enabledBadge": "Die Zwei-Faktor-Authentifizierung ist aktiviert.", + "recoveryCodesRemaining": "Noch {{count}} Wiederherstellungscode übrig.", + "recoveryCodesRemaining_other": "Noch {{count}} Wiederherstellungscodes übrig.", + "regenerate": "Wiederherstellungscodes neu erzeugen", + "regenerateHelp": "Gib einen aktuellen Authentifizierungscode ein, um neue Wiederherstellungscodes zu erzeugen. Deine alten Codes werden ungültig.", + "regenerateConfirm": "Neu erzeugen", + "disable": "Deaktivieren", + "disablePrompt": "Gib einen aktuellen Authentifizierungs- oder Wiederherstellungscode ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren:", + "disableConfirmTitle": "Zwei-Faktor-Authentifizierung deaktivieren?", + "disableConfirmMessage": "Für dein Konto ist bei der Anmeldung dann kein zweiter Schritt mehr erforderlich. Du kannst sie jederzeit wieder aktivieren.", + "disableConfirmButton": "Deaktivieren", + "disabledToast": "Zwei-Faktor-Authentifizierung deaktiviert.", + "codeRequired": "Ein Code ist erforderlich.", + "recoveryCodesWarning": "Speichere diese Wiederherstellungscodes jetzt. Jeder kann einmal verwendet werden, falls du den Zugriff auf deine Authenticator-App verlierst. Sie werden nicht erneut angezeigt.", + "recoveryCodesAck": "Ich habe meine Wiederherstellungscodes an einem sicheren Ort gespeichert.", + "copy": "Kopieren", + "copied": "Kopiert", + "copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen.", + "download": ".txt herunterladen", + "done": "Fertig", + "setupFailed": "Zwei-Faktor-Einrichtung konnte nicht gestartet werden. Bitte versuche es erneut.", + "enableFailed": "Zwei-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfe den Code und versuche es erneut.", + "disableFailed": "Zwei-Faktor-Authentifizierung konnte nicht deaktiviert werden. Prüfe den Code und versuche es erneut.", + "regenerateFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden. Prüfe den Code und versuche es erneut." } }, "branding": { @@ -3585,7 +3622,25 @@ "generalError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", "needHelp": "Hilfe benötigt? Kontakt", "poweredBy": "Bereitgestellt von PicPeak", - "devModeHint": "Entwicklungsmodus: E-Mail: admin@example.com, Passwort: admin123" + "devModeHint": "Entwicklungsmodus: E-Mail: admin@example.com, Passwort: admin123", + "mfa": { + "title": "Zwei-Faktor-Authentifizierung", + "subtitle": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.", + "recoverySubtitle": "Gib einen deiner Wiederherstellungscodes ein.", + "codeLabel": "Authentifizierungscode", + "codePlaceholder": "123456", + "recoveryCodeLabel": "Wiederherstellungscode", + "recoveryCodePlaceholder": "awzq-jca3-va", + "verify": "Bestätigen", + "back": "Zurück", + "useRecoveryCode": "Stattdessen Wiederherstellungscode verwenden", + "useAuthenticator": "Stattdessen Authenticator-App verwenden", + "codeRequired": "Gib deinen Authentifizierungscode ein", + "invalidCode": "Ungültiger Code. Bitte versuche es erneut.", + "sessionExpired": "Deine Bestätigungssitzung ist abgelaufen. Bitte melde dich erneut an.", + "locked": "Konto wegen zu vieler Versuche vorübergehend gesperrt. Versuche es später erneut.", + "lockedRetry": "Konto vorübergehend gesperrt. Versuche es in {{seconds}} Sekunden erneut." + } }, "cssTemplates": { "title": "Benutzerdefinierte CSS-Vorlagen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3ba9cb3c..fbb30759 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -969,13 +969,14 @@ "attemptWindowMinutesHelp": "How long to look back when counting failed login attempts", "lockoutDurationMinutes": "Lockout Duration (minutes)", "lockoutDurationMinutesHelp": "How long the gallery or account stays locked after too many failures", - "enable2FA": "Enable two-factor authentication for admins", "recaptchaSettings": "reCAPTCHA Settings", "enableRecaptcha": "Enable reCAPTCHA for login forms", "siteKey": "Site Key", "secretKey": "Secret Key", "recaptchaHelp": "Get your reCAPTCHA keys from", - "saveSecuritySettings": "Save Security Settings" + "saveSecuritySettings": "Save Security Settings", + "twoFactorTitle": "Two-factor authentication", + "twoFactorNote": "Two-factor authentication is now managed per admin from Settings → General → Admin Account. Each admin enables it for their own login." }, "categories": { "title": "Categories", @@ -1587,6 +1588,42 @@ "testSend": "Send test", "testSending": "Sending…", "testSentToast": "Test message sent (id: {{id}})." + }, + "mfa": { + "title": "Two-factor authentication", + "description": "Add a second step to your admin sign-in using an authenticator app (TOTP).", + "notEnrolled": "Two-factor authentication is not enabled for your account.", + "setUp": "Set up", + "setupScanInstruction": "Scan this QR code with your authenticator app (e.g. Google Authenticator, 1Password, Authy).", + "manualEntry": "Or enter this secret manually:", + "qrAlt": "Two-factor setup QR code", + "enterCodeLabel": "Enter the 6-digit code from your app", + "codePlaceholder": "123456", + "enable": "Enable", + "enabledBadge": "Two-factor authentication is enabled.", + "recoveryCodesRemaining": "{{count}} recovery code remaining.", + "recoveryCodesRemaining_other": "{{count}} recovery codes remaining.", + "regenerate": "Regenerate recovery codes", + "regenerateHelp": "Enter a current authentication code to generate a new set of recovery codes. Your old codes will stop working.", + "regenerateConfirm": "Regenerate", + "disable": "Disable", + "disablePrompt": "Enter a current authentication or recovery code to disable two-factor authentication:", + "disableConfirmTitle": "Disable two-factor authentication?", + "disableConfirmMessage": "Your account will no longer require a second step at sign-in. You can re-enable it at any time.", + "disableConfirmButton": "Disable", + "disabledToast": "Two-factor authentication disabled.", + "codeRequired": "A code is required.", + "recoveryCodesWarning": "Save these recovery codes now. Each can be used once if you lose access to your authenticator app. They will not be shown again.", + "recoveryCodesAck": "I have saved my recovery codes in a safe place.", + "copy": "Copy", + "copied": "Copied", + "copyFailed": "Failed to copy to clipboard.", + "download": "Download .txt", + "done": "Done", + "setupFailed": "Could not start two-factor setup. Please try again.", + "enableFailed": "Could not enable two-factor authentication. Check the code and try again.", + "disableFailed": "Could not disable two-factor authentication. Check the code and try again.", + "regenerateFailed": "Could not regenerate recovery codes. Check the code and try again." } }, "analytics": { @@ -3481,7 +3518,25 @@ "generalError": "An error occurred. Please try again.", "needHelp": "Need help? Contact", "poweredBy": "Powered by PicPeak", - "devModeHint": "Development Mode: Use email: admin@example.com, password: admin123" + "devModeHint": "Development Mode: Use email: admin@example.com, password: admin123", + "mfa": { + "title": "Two-factor authentication", + "subtitle": "Enter the 6-digit code from your authenticator app.", + "recoverySubtitle": "Enter one of your recovery codes.", + "codeLabel": "Authentication code", + "codePlaceholder": "123456", + "recoveryCodeLabel": "Recovery code", + "recoveryCodePlaceholder": "awzq-jca3-va", + "verify": "Verify", + "back": "Back", + "useRecoveryCode": "Use a recovery code instead", + "useAuthenticator": "Use your authenticator app instead", + "codeRequired": "Enter your authentication code", + "invalidCode": "Invalid code. Please try again.", + "sessionExpired": "Your verification session expired. Please sign in again.", + "locked": "Account temporarily locked due to too many attempts. Try again later.", + "lockedRetry": "Account temporarily locked. Try again in {{seconds}} seconds." + } }, "slideshow": { "adminTitle": "Live Slideshow", diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index 5a482708..52173d69 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -1,13 +1,14 @@ import React, { useState, useEffect } from 'react'; import { Navigate, useSearchParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; -import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react'; +import { Lock, Mail, Eye, EyeOff, AlertCircle, ShieldCheck, KeyRound, ArrowLeft } from 'lucide-react'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; import { Button, Input, Card, ReCaptcha } from '../../components/common'; import { useAdminAuth } from '../../contexts'; import { authService } from '../../services/auth.service'; +import { isMfaChallenge } from '../../types'; import { setupService } from '../../services/setup.service'; import { usePublicSettings } from '../../hooks/usePublicSettings'; import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext'; @@ -29,6 +30,14 @@ export const AdminLoginPage: React.FC = () => { const [loginSuccess, setLoginSuccess] = useState(false); const [recaptchaToken, setRecaptchaToken] = useState(null); + // Two-step MFA challenge state (issue #738). When the first step returns + // { mfaRequired, mfaToken } we swap the form to a code entry step. + const [step, setStep] = useState<'credentials' | 'mfa'>('credentials'); + const [mfaToken, setMfaToken] = useState(null); + const [mfaCode, setMfaCode] = useState(''); + const [useRecoveryCode, setUseRecoveryCode] = useState(false); + const [mfaError, setMfaError] = useState(null); + const { data: settingsData } = usePublicSettings(); const { isDark } = useAdminDarkMode(); @@ -103,6 +112,15 @@ export const AdminLoginPage: React.FC = () => { ...formData, recaptchaToken }); + // MFA enabled → move to the second step instead of logging in. + if (isMfaChallenge(response)) { + setMfaToken(response.mfaToken); + setMfaCode(''); + setUseRecoveryCode(false); + setMfaError(null); + setStep('mfa'); + return; + } login(response.token, response.user); toast.success(t('adminLogin.loginSuccess')); setLoginSuccess(true); @@ -142,6 +160,62 @@ export const AdminLoginPage: React.FC = () => { } }; + const backToCredentials = () => { + setStep('credentials'); + setMfaToken(null); + setMfaCode(''); + setMfaError(null); + setUseRecoveryCode(false); + }; + + const handleMfaSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + toast.dismiss(); + + const code = mfaCode.trim(); + if (!code) { + setMfaError(t('adminLogin.mfa.codeRequired')); + return; + } + if (!mfaToken) { + // Token lost somehow — restart the flow. + toast.info(t('adminLogin.mfa.sessionExpired')); + backToCredentials(); + return; + } + + setIsLoading(true); + setMfaError(null); + + try { + const response = await authService.adminLoginMfa({ mfaToken, code }); + login(response.token, response.user); + toast.success(t('adminLogin.loginSuccess')); + setLoginSuccess(true); + } catch (error: any) { + const data = error.response?.data; + const code = data?.code; + if (error.response?.status === 423) { + const retryAfter = data?.retryAfter; + toast.error( + retryAfter + ? t('adminLogin.mfa.lockedRetry', { seconds: retryAfter }) + : t('adminLogin.mfa.locked') + ); + backToCredentials(); + } else if (code === 'MFA_SESSION_EXPIRED') { + toast.info(t('adminLogin.mfa.sessionExpired')); + backToCredentials(); + } else if (code === 'MFA_INVALID') { + setMfaError(t('adminLogin.mfa.invalidCode')); + } else { + setMfaError(data?.error || t('adminLogin.generalError')); + } + } finally { + setIsLoading(false); + } + }; + return (
@@ -177,6 +251,7 @@ export const AdminLoginPage: React.FC = () => { {/* Login Form */} + {step === 'credentials' ? (
{/* Form Error */} {errors.form && ( @@ -263,6 +338,81 @@ export const AdminLoginPage: React.FC = () => { {t('adminLogin.signIn')}
+ ) : ( +
+
+
+ +
+

+ {t('adminLogin.mfa.title')} +

+

+ {useRecoveryCode ? t('adminLogin.mfa.recoverySubtitle') : t('adminLogin.mfa.subtitle')} +

+
+ + {mfaError && ( +
+ +

{mfaError}

+
+ )} + +
+ + { + setMfaCode(e.target.value); + if (mfaError) setMfaError(null); + }} + placeholder={useRecoveryCode ? t('adminLogin.mfa.recoveryCodePlaceholder') : t('adminLogin.mfa.codePlaceholder')} + leftIcon={} + inputMode={useRecoveryCode ? 'text' : 'numeric'} + autoComplete="one-time-code" + autoFocus + /> +
+ + + +
+ + +
+
+ )}
{/* Footer */} diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 46d8add5..ff081149 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -1,5 +1,5 @@ import { api } from '../config/api'; -import type { LoginResponse, GalleryAuthResponse, AdminUser } from '../types'; +import type { LoginResponse, AdminLoginResponse, GalleryAuthResponse, AdminUser } from '../types'; import { normalizeRequirePassword } from '../utils/accessControl'; const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({ @@ -14,9 +14,10 @@ const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthRes export const authService = { // Admin authentication - async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise { - // Backend expects 'username' field, but we accept email - const response = await api.post('/auth/admin/login', { + async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise { + // Backend expects 'username' field, but we accept email. + // Returns either { user } (session set) or an MFA challenge { mfaRequired, mfaToken }. + const response = await api.post('/auth/admin/login', { username: credentials.email, password: credentials.password, recaptchaToken: credentials.recaptchaToken @@ -24,6 +25,14 @@ export const authService = { return response.data; }, + // Second step of the two-step admin login. `code` accepts a 6-digit TOTP + // or a recovery code (e.g. "awzq-jca3-va"). On success the session cookie + // is set server-side and the user object is returned. + async adminLoginMfa(payload: { mfaToken: string; code: string }): Promise { + const response = await api.post('/auth/admin/login/mfa', payload); + return response.data; + }, + async adminLogout() { try { await api.post('/auth/logout'); diff --git a/frontend/src/services/mfa.service.ts b/frontend/src/services/mfa.service.ts new file mode 100644 index 00000000..0324d619 --- /dev/null +++ b/frontend/src/services/mfa.service.ts @@ -0,0 +1,50 @@ +import { api } from '../config/api'; + +// Per-user admin TOTP MFA (issue #738). All endpoints operate on the +// currently authenticated admin's own account. + +export interface MfaStatus { + enabled: boolean; + enrolledAt: string | null; + recoveryCodesRemaining: number; +} + +export interface MfaSetupResponse { + secret: string; + otpauthUri: string; + qr: string; // PNG data URL + issuer: string; + account: string; +} + +export interface MfaRecoveryCodesResponse { + message: string; + recoveryCodes: string[]; +} + +export const mfaService = { + async getStatus(): Promise { + const response = await api.get('/admin/auth/mfa/status'); + return response.data; + }, + + async setup(): Promise { + const response = await api.post('/admin/auth/mfa/setup'); + return response.data; + }, + + async enable(code: string): Promise { + const response = await api.post('/admin/auth/mfa/enable', { code }); + return response.data; + }, + + async disable(code: string): Promise<{ message: string }> { + const response = await api.post<{ message: string }>('/admin/auth/mfa/disable', { code }); + return response.data; + }, + + async regenerateRecoveryCodes(code: string): Promise { + const response = await api.post('/admin/auth/mfa/recovery-codes', { code }); + return response.data; + }, +}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b84491bf..a33955a3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -252,6 +252,20 @@ export interface LoginResponse { user: AdminUser; } +// Two-step admin login: when MFA is enabled, POST /auth/admin/login returns +// this challenge instead of a session (no cookie yet). The mfaToken is a +// short-lived (5 min) JWT exchanged at POST /auth/admin/login/mfa. +export interface MfaChallengeResponse { + mfaRequired: true; + mfaToken: string; +} + +export type AdminLoginResponse = LoginResponse | MfaChallengeResponse; + +export function isMfaChallenge(res: AdminLoginResponse): res is MfaChallengeResponse { + return (res as MfaChallengeResponse).mfaRequired === true; +} + export interface GalleryAuthResponse { token: string; event: {