Merge pull request #739 from PicPeak/feat/admin-mfa

feat: admin two-factor authentication (TOTP) with recovery codes + CLI reset
This commit is contained in:
Paul Nothaft
2026-07-03 11:49:07 +02:00
committed by GitHub
21 changed files with 2070 additions and 65 deletions
+172
View File
@@ -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;
+163 -36
View File
@@ -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 {
+183
View File
@@ -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,
};