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:
Paul Nothaft
2026-07-03 11:33:38 +02:00
parent 5b26dbd935
commit 72e2ef6721
8 changed files with 771 additions and 41 deletions
+4 -3
View File
@@ -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",