Add Multi-Factor Authentication (MFA) system - Backend
Implements TOTP (Google Authenticator) and Email-based MFA: Backend Features: - MFA database fields (mfa_enabled, mfa_method, totp_secret, backup_codes) - V010 migration for MFA support - MFAService with TOTP and Email MFA support - QR code generation for Google Authenticator setup - 10 backup codes per user (hashed storage) - Email verification codes (6-digit, 5min expiry) - MFA verification with backup code support API Endpoints: - GET /api/v1/auth/mfa/status - POST /api/v1/auth/mfa/totp/setup - POST /api/v1/auth/mfa/totp/enable - POST /api/v1/auth/mfa/email/setup - POST /api/v1/auth/mfa/email/send-code - POST /api/v1/auth/mfa/verify - DELETE /api/v1/auth/mfa - POST /api/v1/auth/mfa/backup-codes/regenerate Dependencies: otplib, qrcode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
236
maternal-app/maternal-app-backend/package-lock.json
generated
236
maternal-app/maternal-app-backend/package-lock.json
generated
@@ -28,6 +28,7 @@
|
||||
"@sentry/node": "^10.17.0",
|
||||
"@sentry/profiling-node": "^10.17.0",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"axios": "^1.12.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.2",
|
||||
@@ -42,11 +43,13 @@
|
||||
"mailgun.js": "^12.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"openai": "^5.23.2",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"redis": "^5.8.2",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
@@ -4468,6 +4471,53 @@
|
||||
"@opentelemetry/api": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"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==",
|
||||
"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==",
|
||||
"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.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
|
||||
@@ -5982,6 +6032,15 @@
|
||||
"@types/pg": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
||||
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
@@ -7453,7 +7512,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -8155,6 +8213,12 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
@@ -12024,6 +12088,17 @@
|
||||
"node": ">=0.10.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-finally": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
|
||||
@@ -12116,7 +12191,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -12226,7 +12300,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -12516,6 +12589,15 @@
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -12700,6 +12782,127 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
@@ -12902,6 +13105,12 @@
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
@@ -13270,6 +13479,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -14211,6 +14426,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/through": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
@@ -15110,6 +15333,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||
@@ -15152,7 +15381,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@sentry/node": "^10.17.0",
|
||||
"@sentry/profiling-node": "^10.17.0",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"axios": "^1.12.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.2",
|
||||
@@ -54,11 +55,13 @@
|
||||
"mailgun.js": "^12.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"openai": "^5.23.2",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"redis": "^5.8.2",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
@@ -42,6 +42,25 @@ export class User {
|
||||
@Column({ name: 'email_verification_sent_at', type: 'timestamp without time zone', nullable: true })
|
||||
emailVerificationSentAt?: Date | null;
|
||||
|
||||
// MFA fields
|
||||
@Column({ name: 'mfa_enabled', default: false })
|
||||
mfaEnabled: boolean;
|
||||
|
||||
@Column({ name: 'mfa_method', length: 20, nullable: true })
|
||||
mfaMethod?: 'totp' | 'email' | null;
|
||||
|
||||
@Column({ name: 'totp_secret', length: 32, nullable: true })
|
||||
totpSecret?: string | null;
|
||||
|
||||
@Column({ name: 'mfa_backup_codes', type: 'jsonb', nullable: true })
|
||||
mfaBackupCodes?: string[] | null;
|
||||
|
||||
@Column({ name: 'email_mfa_code', length: 6, nullable: true })
|
||||
emailMfaCode?: string | null;
|
||||
|
||||
@Column({ name: 'email_mfa_code_expires_at', type: 'timestamp without time zone', nullable: true })
|
||||
emailMfaCodeExpiresAt?: Date | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
preferences?: {
|
||||
notifications?: boolean;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- V010: Add Multi-Factor Authentication (MFA) fields to users table
|
||||
-- Created: 2025-10-01
|
||||
-- Description: Adds support for TOTP (Google Authenticator) and Email MFA
|
||||
|
||||
-- Add MFA fields to users table
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN DEFAULT false NOT NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS mfa_method VARCHAR(20);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(32);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS mfa_backup_codes JSONB;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_mfa_code VARCHAR(6);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_mfa_code_expires_at TIMESTAMP WITHOUT TIME ZONE;
|
||||
|
||||
-- Add index for MFA queries
|
||||
CREATE INDEX IF NOT EXISTS idx_users_mfa_enabled ON users(mfa_enabled) WHERE mfa_enabled = true;
|
||||
|
||||
-- Comments
|
||||
COMMENT ON COLUMN users.mfa_enabled IS 'Whether MFA is enabled for this user';
|
||||
COMMENT ON COLUMN users.mfa_method IS 'MFA method: totp (Google Authenticator) or email';
|
||||
COMMENT ON COLUMN users.totp_secret IS 'Encrypted TOTP secret for Google Authenticator';
|
||||
COMMENT ON COLUMN users.mfa_backup_codes IS 'Array of backup codes (hashed) for MFA recovery';
|
||||
COMMENT ON COLUMN users.email_mfa_code IS 'Temporary email verification code for email-based MFA';
|
||||
COMMENT ON COLUMN users.email_mfa_code_expires_at IS 'Expiration time for email MFA code (5 minutes)';
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Post,
|
||||
Get,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -13,11 +14,13 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PasswordResetService } from './password-reset.service';
|
||||
import { MFAService } from './mfa.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { LogoutDto } from './dto/logout.dto';
|
||||
import { RequestPasswordResetDto, ResetPasswordDto, VerifyEmailDto } from './dto/password-reset.dto';
|
||||
import { VerifyMFACodeDto, EnableTOTPDto } from './dto/mfa.dto';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
@@ -27,6 +30,7 @@ export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly passwordResetService: PasswordResetService,
|
||||
private readonly mfaService: MFAService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@@ -120,4 +124,70 @@ export class AuthController {
|
||||
async resendEmailVerification(@CurrentUser() user: any) {
|
||||
return await this.passwordResetService.resendEmailVerification(user.userId);
|
||||
}
|
||||
|
||||
// MFA Endpoints
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('mfa/status')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getMFAStatus(@CurrentUser() user: any) {
|
||||
return await this.mfaService.getMFAStatus(user.userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('mfa/totp/setup')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async setupTOTP(@CurrentUser() user: any) {
|
||||
return await this.mfaService.setupTOTP(user.userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('mfa/totp/enable')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async enableTOTP(
|
||||
@CurrentUser() user: any,
|
||||
@Body(ValidationPipe) dto: EnableTOTPDto,
|
||||
) {
|
||||
return await this.mfaService.verifyAndEnableTOTP(user.userId, dto.code);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('mfa/email/setup')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async setupEmailMFA(@CurrentUser() user: any) {
|
||||
return await this.mfaService.setupEmailMFA(user.userId);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('mfa/email/send-code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async sendEmailMFACode(@Body() body: { userId: string }) {
|
||||
return await this.mfaService.sendEmailMFACode(body.userId);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('mfa/verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyMFACode(
|
||||
@Body() body: { userId: string; code: string },
|
||||
) {
|
||||
return await this.mfaService.verifyMFACode(body.userId, body.code);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('mfa')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async disableMFA(@CurrentUser() user: any) {
|
||||
return await this.mfaService.disableMFA(user.userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('mfa/backup-codes/regenerate')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async regenerateBackupCodes(@CurrentUser() user: any) {
|
||||
const codes = await this.mfaService.regenerateBackupCodes(user.userId);
|
||||
return {
|
||||
success: true,
|
||||
backupCodes: codes,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PasswordResetService } from './password-reset.service';
|
||||
import { MFAService } from './mfa.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { CommonModule } from '../../common/common.module';
|
||||
import {
|
||||
User,
|
||||
DeviceRegistry,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, PasswordResetToken, Family, FamilyMember]),
|
||||
PassportModule,
|
||||
CommonModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
@@ -33,7 +36,7 @@ import {
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, PasswordResetService, JwtStrategy, LocalStrategy],
|
||||
exports: [AuthService, PasswordResetService],
|
||||
providers: [AuthService, PasswordResetService, MFAService, JwtStrategy, LocalStrategy],
|
||||
exports: [AuthService, PasswordResetService, MFAService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IsString, IsNotEmpty, IsIn, Length, Matches } from 'class-validator';
|
||||
|
||||
export class VerifyMFACodeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(6, 8)
|
||||
@Matches(/^[0-9A-F]+$/i, { message: 'Code must contain only numbers or hexadecimal characters' })
|
||||
code: string;
|
||||
}
|
||||
|
||||
export class SetupMFAMethodDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['totp', 'email'])
|
||||
method: 'totp' | 'email';
|
||||
}
|
||||
|
||||
export class EnableTOTPDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(6, 6)
|
||||
@Matches(/^[0-9]+$/, { message: 'Code must be 6 digits' })
|
||||
code: string;
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { authenticator } from 'otplib';
|
||||
import * as QRCode from 'qrcode';
|
||||
import * as crypto from 'crypto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from '../../database/entities';
|
||||
import { EmailService } from '../../common/services/email.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface MFASetupResult {
|
||||
secret: string;
|
||||
qrCodeUrl: string;
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export interface MFAStatus {
|
||||
enabled: boolean;
|
||||
method?: 'totp' | 'email';
|
||||
hasBackupCodes: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MFAService {
|
||||
private readonly logger = new Logger(MFAService.name);
|
||||
private readonly appName: string;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
private emailService: EmailService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.appName = this.configService.get<string>('APP_NAME', 'Maternal App');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MFA status for a user
|
||||
*/
|
||||
async getMFAStatus(userId: string): Promise<MFAStatus> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
select: ['id', 'mfaEnabled', 'mfaMethod', 'mfaBackupCodes'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: user.mfaEnabled,
|
||||
method: user.mfaMethod as 'totp' | 'email' | undefined,
|
||||
hasBackupCodes: !!user.mfaBackupCodes && user.mfaBackupCodes.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup TOTP (Google Authenticator) MFA
|
||||
*/
|
||||
async setupTOTP(userId: string): Promise<MFASetupResult> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
select: ['id', 'email', 'name'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Generate secret for TOTP
|
||||
const secret = authenticator.generateSecret();
|
||||
|
||||
// Generate QR code
|
||||
const otpauthUrl = authenticator.keyuri(
|
||||
user.email,
|
||||
this.appName,
|
||||
secret,
|
||||
);
|
||||
const qrCodeUrl = await QRCode.toDataURL(otpauthUrl);
|
||||
|
||||
// Generate backup codes
|
||||
const backupCodes = this.generateBackupCodes();
|
||||
const hashedBackupCodes = await Promise.all(
|
||||
backupCodes.map((code) => bcrypt.hash(code, 10)),
|
||||
);
|
||||
|
||||
// Save secret and backup codes (but don't enable MFA yet)
|
||||
await this.userRepository.update(userId, {
|
||||
totpSecret: secret,
|
||||
mfaBackupCodes: hashedBackupCodes,
|
||||
});
|
||||
|
||||
this.logger.log(`TOTP setup initiated for user ${userId}`);
|
||||
|
||||
return {
|
||||
secret,
|
||||
qrCodeUrl,
|
||||
backupCodes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify TOTP code and enable TOTP MFA
|
||||
*/
|
||||
async verifyAndEnableTOTP(
|
||||
userId: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
select: ['id', 'totpSecret', 'mfaEnabled'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (!user.totpSecret) {
|
||||
throw new BadRequestException('TOTP is not set up. Please set up TOTP first.');
|
||||
}
|
||||
|
||||
// Verify the TOTP code
|
||||
const isValid = authenticator.verify({
|
||||
token: code,
|
||||
secret: user.totpSecret,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
throw new BadRequestException('Invalid verification code');
|
||||
}
|
||||
|
||||
// Enable MFA with TOTP method
|
||||
await this.userRepository.update(userId, {
|
||||
mfaEnabled: true,
|
||||
mfaMethod: 'totp',
|
||||
});
|
||||
|
||||
this.logger.log(`TOTP MFA enabled for user ${userId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Two-factor authentication enabled successfully',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Email MFA
|
||||
*/
|
||||
async setupEmailMFA(userId: string): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
select: ['id', 'email', 'name'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Generate backup codes
|
||||
const backupCodes = this.generateBackupCodes();
|
||||
const hashedBackupCodes = await Promise.all(
|
||||
backupCodes.map((code) => bcrypt.hash(code, 10)),
|
||||
);
|
||||
|
||||
// Enable Email MFA
|
||||
await this.userRepository.update(userId, {
|
||||
mfaEnabled: true,
|
||||
mfaMethod: 'email',
|
||||
mfaBackupCodes: hashedBackupCodes,
|
||||
});
|
||||
|
||||
// Send confirmation email
|
||||
try {
|
||||
await this.emailService.sendEmail({
|
||||
to: user.email,
|
||||
subject: 'Email Two-Factor Authentication Enabled',
|
||||
html: `
|
||||
<h2>Two-Factor Authentication Enabled</h2>
|
||||
<p>Hi ${user.name},</p>
|
||||
<p>Email-based two-factor authentication has been enabled for your account.</p>
|
||||
<p>You will now receive a verification code via email each time you log in.</p>
|
||||
<h3>Backup Codes</h3>
|
||||
<p>Save these backup codes in a safe place. You can use them to access your account if you don't have access to your email:</p>
|
||||
<ul>
|
||||
${backupCodes.map((code) => `<li><code>${code}</code></li>`).join('')}
|
||||
</ul>
|
||||
<p>Each backup code can only be used once.</p>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send MFA setup confirmation email: ${error.message}`);
|
||||
}
|
||||
|
||||
this.logger.log(`Email MFA enabled for user ${userId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email-based two-factor authentication enabled successfully. Check your email for backup codes.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email MFA code
|
||||
*/
|
||||
async sendEmailMFACode(userId: string): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
select: ['id', 'email', 'name', 'mfaEnabled', 'mfaMethod'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (!user.mfaEnabled || user.mfaMethod !== 'email') {
|
||||
throw new BadRequestException('Email MFA is not enabled for this user');
|
||||
}
|
||||
|
||||
// Generate 6-digit code
|
||||
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setMinutes(expiresAt.getMinutes() + 5); // Code expires in 5 minutes
|
||||
|
||||
// Save code
|
||||
await this.userRepository.update(userId, {
|
||||
emailMfaCode: code,
|
||||
emailMfaCodeExpiresAt: expiresAt,
|
||||
});
|
||||
|
||||
// Send email with code
|
||||
try {
|
||||
await this.emailService.sendEmail({
|
||||
to: user.email,
|
||||
subject: `Your verification code is ${code}`,
|
||||
html: `
|
||||
<h2>Verification Code</h2>
|
||||
<p>Hi ${user.name},</p>
|
||||
<p>Your verification code is:</p>
|
||||
<h1 style="font-size: 36px; letter-spacing: 8px; font-family: monospace;">${code}</h1>
|
||||
<p>This code will expire in 5 minutes.</p>
|
||||
<p>If you didn't request this code, please ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
|
||||
this.logger.log(`Email MFA code sent to user ${userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send MFA email code: ${error.message}`);
|
||||
throw new BadRequestException('Failed to send verification code. Please try again.');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Verification code sent to your email',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MFA code (TOTP or Email or Backup code)
|
||||
*/
|
||||
async verifyMFACode(
|
||||
userId: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
select: [
|
||||
'id',
|
||||
'mfaEnabled',
|
||||
'mfaMethod',
|
||||
'totpSecret',
|
||||
'emailMfaCode',
|
||||
'emailMfaCodeExpiresAt',
|
||||
'mfaBackupCodes',
|
||||
],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (!user.mfaEnabled) {
|
||||
throw new BadRequestException('MFA is not enabled for this user');
|
||||
}
|
||||
|
||||
// Try TOTP verification
|
||||
if (user.mfaMethod === 'totp' && user.totpSecret) {
|
||||
const isValid = authenticator.verify({
|
||||
token: code,
|
||||
secret: user.totpSecret,
|
||||
});
|
||||
|
||||
if (isValid) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Code verified successfully',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try Email code verification
|
||||
if (user.mfaMethod === 'email' && user.emailMfaCode) {
|
||||
if (!user.emailMfaCodeExpiresAt || new Date() > user.emailMfaCodeExpiresAt) {
|
||||
throw new BadRequestException('Verification code has expired. Please request a new one.');
|
||||
}
|
||||
|
||||
if (code === user.emailMfaCode) {
|
||||
// Clear the used code
|
||||
await this.userRepository.update(userId, {
|
||||
emailMfaCode: null,
|
||||
emailMfaCodeExpiresAt: null,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Code verified successfully',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try backup code verification
|
||||
if (user.mfaBackupCodes && user.mfaBackupCodes.length > 0) {
|
||||
for (let i = 0; i < user.mfaBackupCodes.length; i++) {
|
||||
const isValid = await bcrypt.compare(code, user.mfaBackupCodes[i]);
|
||||
if (isValid) {
|
||||
// Remove used backup code
|
||||
const updatedBackupCodes = [...user.mfaBackupCodes];
|
||||
updatedBackupCodes.splice(i, 1);
|
||||
await this.userRepository.update(userId, {
|
||||
mfaBackupCodes: updatedBackupCodes,
|
||||
});
|
||||
|
||||
this.logger.log(`Backup code used for user ${userId}. ${updatedBackupCodes.length} codes remaining.`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Backup code verified. You have ${updatedBackupCodes.length} backup codes remaining.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException('Invalid verification code');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable MFA
|
||||
*/
|
||||
async disableMFA(userId: string): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
select: ['id', 'mfaEnabled'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (!user.mfaEnabled) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'MFA is already disabled',
|
||||
};
|
||||
}
|
||||
|
||||
// Disable MFA and clear all MFA data
|
||||
await this.userRepository.update(userId, {
|
||||
mfaEnabled: false,
|
||||
mfaMethod: null,
|
||||
totpSecret: null,
|
||||
mfaBackupCodes: null,
|
||||
emailMfaCode: null,
|
||||
emailMfaCodeExpiresAt: null,
|
||||
});
|
||||
|
||||
this.logger.log(`MFA disabled for user ${userId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Two-factor authentication disabled successfully',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate backup codes
|
||||
*/
|
||||
async regenerateBackupCodes(userId: string): Promise<string[]> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
select: ['id', 'mfaEnabled'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (!user.mfaEnabled) {
|
||||
throw new BadRequestException('MFA is not enabled. Please enable MFA first.');
|
||||
}
|
||||
|
||||
// Generate new backup codes
|
||||
const backupCodes = this.generateBackupCodes();
|
||||
const hashedBackupCodes = await Promise.all(
|
||||
backupCodes.map((code) => bcrypt.hash(code, 10)),
|
||||
);
|
||||
|
||||
await this.userRepository.update(userId, {
|
||||
mfaBackupCodes: hashedBackupCodes,
|
||||
});
|
||||
|
||||
this.logger.log(`Backup codes regenerated for user ${userId}`);
|
||||
|
||||
return backupCodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate backup codes (10 codes, 8 characters each)
|
||||
*/
|
||||
private generateBackupCodes(): string[] {
|
||||
const codes: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
|
||||
codes.push(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user