feat(auth): admin TOTP MFA — enrollment, login challenge, recovery, CLI reset
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 <e> | --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.
This commit is contained in:
@@ -9,6 +9,16 @@ PORT=3001
|
|||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
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
|
# Auth cookie Secure flag
|
||||||
# unset - default: 'auto' in production, false in dev (#427)
|
# unset - default: 'auto' in production, false in dev (#427)
|
||||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Generated
+72
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.74.0-beta.0",
|
"version": "3.80.0-beta.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.74.0-beta.0",
|
"version": "3.80.0-beta.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
@@ -2703,6 +2704,56 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/@paralleldrive/cuid2": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||||
@@ -9371,6 +9422,17 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
@@ -11668,6 +11730,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/thread-stream": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||||
|
|||||||
@@ -46,9 +46,11 @@
|
|||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"postcss": "8.5.10",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
@@ -57,11 +59,10 @@
|
|||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"swissqrbill": "^4.3.0",
|
"swissqrbill": "^4.3.0",
|
||||||
|
"tar": ">=7.5.16",
|
||||||
"uuid": "^11.1.1",
|
"uuid": "^11.1.1",
|
||||||
"winston": "^3.8.2",
|
"winston": "^3.8.2",
|
||||||
"zxcvbn": "^4.4.2",
|
"zxcvbn": "^4.4.2"
|
||||||
"postcss": "8.5.10",
|
|
||||||
"tar": ">=7.5.16"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
|
|||||||
@@ -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 [email protected]
|
||||||
|
* docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||||
|
*
|
||||||
|
* Flags:
|
||||||
|
* --email <addr> target a single admin by email (or --username <name>)
|
||||||
|
* --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 <addr>, --username <name>, or --all');
|
||||||
|
console.log(' e.g. node scripts/reset-admin-mfa.js --email [email protected]');
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
|
|||||||
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||||
|
const mfaService = require('../services/mfaService');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get admin profile
|
// Get admin profile
|
||||||
@@ -184,4 +185,175 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => {
|
|||||||
successResponse(res, { message: 'Logged out successfully' });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
+163
-36
@@ -2,9 +2,10 @@ const express = require('express');
|
|||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||||
|
const mfaService = require('../services/mfaService');
|
||||||
const {
|
const {
|
||||||
trackFailedAttempt,
|
trackFailedAttempt,
|
||||||
trackSuccessfulLogin,
|
trackSuccessfulLogin,
|
||||||
@@ -32,6 +33,49 @@ const {
|
|||||||
} = require('../utils/passwordValidation');
|
} = require('../utils/passwordValidation');
|
||||||
const router = express.Router();
|
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
|
// Admin login with enhanced security
|
||||||
router.post('/admin/login', [
|
router.post('/admin/login', [
|
||||||
body('username').notEmpty().trim(),
|
body('username').notEmpty().trim(),
|
||||||
@@ -94,49 +138,132 @@ router.post('/admin/login', [
|
|||||||
return res.status(401).json({ error: getGenericAuthError() });
|
return res.status(401).json({ error: getGenericAuthError() });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful login
|
// Second factor: if this admin has TOTP enabled, do NOT complete the login
|
||||||
await trackSuccessfulLogin(username, ipAddress, userAgent);
|
// 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
|
||||||
// Update last login and login metadata
|
// (trackSuccessfulLogin) or stamp last_login until the second factor passes,
|
||||||
await db('admin_users').where('id', admin.id).update({
|
// so MFA brute-force is still gated by the account lockout. `loginId` carries
|
||||||
last_login: new Date(),
|
// the typed identifier so the verify step tracks the same lockout bucket.
|
||||||
last_login_ip: ipAddress
|
if (mfaService.isEnrolled(admin)) {
|
||||||
});
|
const mfaToken = jwt.sign({
|
||||||
|
|
||||||
// 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: {
|
|
||||||
id: admin.id,
|
id: admin.id,
|
||||||
username: admin.username,
|
username: admin.username,
|
||||||
email: admin.email,
|
type: 'mfa_pending',
|
||||||
mustChangePassword: admin.must_change_password || false,
|
loginId: username
|
||||||
role: admin.role_name ? {
|
}, process.env.JWT_SECRET, {
|
||||||
name: admin.role_name,
|
expiresIn: '5m',
|
||||||
displayName: admin.role_display_name
|
issuer: 'picpeak-auth'
|
||||||
} : null
|
});
|
||||||
}
|
return res.json({ mfaRequired: true, mfaToken });
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return await completeAdminLogin(req, res, admin, ipAddress, userAgent, username);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Login error:', error);
|
logger.error('Login error:', error);
|
||||||
res.status(500).json({ error: 'Login failed' });
|
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
|
// Logout endpoint
|
||||||
router.post('/logout', async (req, res) => {
|
router.post('/logout', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user