From 72e2ef6721b0572ed34455de901aa357eacd8c76 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 3 Jul 2026 11:33:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20admin=20TOTP=20MFA=20=E2=80=94=20?= =?UTF-8?q?enrollment,=20login=20challenge,=20recovery,=20CLI=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend for #738. Real TOTP 2FA for admin accounts, all roles incl. super_admin (closes #735). - mfaService: otplib TOTP; AES-256-GCM encryption of the secret at rest (key derived from MFA_ENCRYPTION_KEY or JWT_SECRET); bcrypt-hashed, single-use recovery codes; otpauth URI + QR. - Migration 151: adds two_factor_recovery_codes + two_factor_enrolled_at (secret/enabled columns already existed from legacy 016). - Enrollment endpoints (behind adminAuth, per-user): GET /mfa/status, POST /mfa/{setup,enable,disable,recovery-codes}. Disable/regenerate require a current code so a hijacked session can't strip 2FA. - Login challenge: /admin/login returns {mfaRequired, mfaToken} (no session) when 2FA is on; /admin/login/mfa exchanges a TOTP or recovery code for the session. Lockout counter is NOT reset until the second factor passes, so MFA brute-force is rate-limited too. - CLI break-glass: scripts/reset-admin-mfa.js --email | --all --yes, audit-logged, matches reset-admin-password.js convention. - Docs + optional MFA_ENCRYPTION_KEY env. Verified end-to-end on a live backend: enroll (super_admin), challenge, TOTP + single-use recovery login, disable, and CLI reset. --- backend/.env.example | 10 + .../core/151_add_admin_mfa_recovery.js | 58 +++++ backend/package-lock.json | 74 ++++++- backend/package.json | 7 +- backend/scripts/reset-admin-mfa.js | 109 ++++++++++ backend/src/routes/adminAuth.js | 172 +++++++++++++++ backend/src/routes/auth.js | 199 ++++++++++++++---- backend/src/services/mfaService.js | 183 ++++++++++++++++ 8 files changed, 771 insertions(+), 41 deletions(-) create mode 100644 backend/migrations/core/151_add_admin_mfa_recovery.js create mode 100644 backend/scripts/reset-admin-mfa.js create mode 100644 backend/src/services/mfaService.js 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/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 e99b5861..245bc27e 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, @@ -32,6 +33,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(), @@ -94,49 +138,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, +};