feat: Implement two-factor authentication (2FA) with dedicated endpoints for setup, enabling, disabling, verification, and backup code management.
This commit is contained in:
251
package-lock.json
generated
251
package-lock.json
generated
@@ -64,12 +64,14 @@
|
||||
"pg": "^8.16.3",
|
||||
"pino-http": "^11.0.0",
|
||||
"prisma": "^7.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"redis": "^5.10.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"slugify": "^1.6.6",
|
||||
"socket.io": "^4.8.1",
|
||||
"speakeasy": "^2.0.0",
|
||||
"stripe": "^20.1.0",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.19.0",
|
||||
@@ -91,6 +93,8 @@
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"autocannon": "^8.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
@@ -2711,16 +2715,6 @@
|
||||
"@electric-sql/pglite": "0.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
|
||||
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
@@ -6817,6 +6811,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"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",
|
||||
@@ -6904,6 +6908,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/speakeasy": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz",
|
||||
"integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||
@@ -8016,6 +8030,12 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base32.js": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
|
||||
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -9333,6 +9353,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@@ -9498,6 +9527,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"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/display-notification": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/display-notification/-/display-notification-2.0.0.tgz",
|
||||
@@ -15326,7 +15361,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"
|
||||
@@ -15539,7 +15573,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"
|
||||
@@ -15898,6 +15931,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"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/postgres": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
|
||||
@@ -16425,6 +16467,157 @@
|
||||
],
|
||||
"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/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"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/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"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.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@@ -16650,7 +16843,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -16666,6 +16858,12 @@
|
||||
"node": ">=0.10.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.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -17103,6 +17301,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"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/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -17462,6 +17666,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/speakeasy": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
|
||||
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base32.js": "0.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
@@ -19328,6 +19544,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/widest-line": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
|
||||
@@ -19422,7 +19644,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",
|
||||
@@ -19476,7 +19697,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -19486,7 +19706,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
|
||||
@@ -82,12 +82,14 @@
|
||||
"pg": "^8.16.3",
|
||||
"pino-http": "^11.0.0",
|
||||
"prisma": "^7.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"redis": "^5.10.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"slugify": "^1.6.6",
|
||||
"socket.io": "^4.8.1",
|
||||
"speakeasy": "^2.0.0",
|
||||
"stripe": "^20.1.0",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.19.0",
|
||||
@@ -109,6 +111,8 @@
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"autocannon": "^8.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
|
||||
@@ -83,6 +83,12 @@ model User {
|
||||
facebookId String? @unique
|
||||
twitterId String? @unique
|
||||
|
||||
// Two-Factor Authentication
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorSecret String? // Encrypted TOTP secret
|
||||
twoFactorBackupCodes String? // JSON array of hashed backup codes
|
||||
twoFactorVerifiedAt DateTime? // When 2FA was enabled
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { TwoFactorService } from './two-factor/two-factor.service';
|
||||
import {
|
||||
RegisterDto,
|
||||
LoginDto,
|
||||
@@ -27,13 +28,23 @@ import {
|
||||
RefreshTokenDto,
|
||||
VerifyEmailDto,
|
||||
} from './dto';
|
||||
import {
|
||||
VerifyTwoFactorDto,
|
||||
VerifyTwoFactorLoginDto,
|
||||
VerifyBackupCodeDto,
|
||||
DisableTwoFactorDto,
|
||||
RegenerateBackupCodesDto,
|
||||
} from './two-factor/dto';
|
||||
import { JwtAuthGuard } from './guards';
|
||||
import { Public, CurrentUser } from './decorators';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly twoFactorService: TwoFactorService,
|
||||
) {}
|
||||
|
||||
// ==========================================
|
||||
// REGISTER
|
||||
@@ -356,4 +367,162 @@ export class AuthController {
|
||||
async logoutAll(@CurrentUser('id') userId: string) {
|
||||
return this.authService.logout(userId);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TWO-FACTOR AUTHENTICATION ENDPOINTS
|
||||
// ==========================================
|
||||
|
||||
// Setup 2FA - Generate secret and QR code
|
||||
@Post('2fa/setup')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Setup two-factor authentication' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Returns secret and QR code for 2FA setup',
|
||||
schema: {
|
||||
example: {
|
||||
secret: 'JBSWY3DPEHPK3PXP',
|
||||
qrCode: 'data:image/png;base64,...',
|
||||
manualEntry: 'JBSWY3DPEHPK3PXP',
|
||||
},
|
||||
},
|
||||
})
|
||||
async setupTwoFactor(@CurrentUser('id') userId: string) {
|
||||
return this.twoFactorService.setupTwoFactor(userId);
|
||||
}
|
||||
|
||||
// Enable 2FA - Verify token and enable
|
||||
@Post('2fa/enable')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Enable two-factor authentication' })
|
||||
@ApiBody({ type: VerifyTwoFactorDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '2FA enabled successfully with backup codes',
|
||||
schema: {
|
||||
example: {
|
||||
backupCodes: ['ABC12345', 'DEF67890', '...'],
|
||||
},
|
||||
},
|
||||
})
|
||||
async enableTwoFactor(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: VerifyTwoFactorDto,
|
||||
) {
|
||||
return this.twoFactorService.enableTwoFactor(userId, dto.token);
|
||||
}
|
||||
|
||||
// Disable 2FA
|
||||
@Post('2fa/disable')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Disable two-factor authentication' })
|
||||
@ApiBody({ type: DisableTwoFactorDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '2FA disabled successfully',
|
||||
})
|
||||
async disableTwoFactor(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: DisableTwoFactorDto,
|
||||
) {
|
||||
await this.twoFactorService.disableTwoFactor(userId, dto.password);
|
||||
return { message: 'Two-factor authentication disabled successfully' };
|
||||
}
|
||||
|
||||
// Verify 2FA token during login
|
||||
@Public()
|
||||
@Post('2fa/verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Verify 2FA token during login' })
|
||||
@ApiBody({ type: VerifyTwoFactorLoginDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Login successful with 2FA verification',
|
||||
schema: {
|
||||
example: {
|
||||
user: {
|
||||
id: 'uuid',
|
||||
email: 'john@example.com',
|
||||
role: 'USER',
|
||||
},
|
||||
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
},
|
||||
},
|
||||
})
|
||||
async verifyTwoFactorLogin(@Body() dto: VerifyTwoFactorLoginDto) {
|
||||
return this.authService.verifyTwoFactorLogin(
|
||||
dto.tempToken,
|
||||
dto.token,
|
||||
dto.userAgent,
|
||||
dto.ipAddress,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify backup code during login
|
||||
@Public()
|
||||
@Post('2fa/verify-backup')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Verify backup code during login' })
|
||||
@ApiBody({ type: VerifyBackupCodeDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Login successful with backup code verification',
|
||||
})
|
||||
async verifyBackupCode(@Body() dto: VerifyBackupCodeDto) {
|
||||
return this.authService.verifyBackupCodeLogin(
|
||||
dto.tempToken,
|
||||
dto.backupCode,
|
||||
dto.userAgent,
|
||||
dto.ipAddress,
|
||||
);
|
||||
}
|
||||
|
||||
// Get 2FA status
|
||||
@Get('2fa/status')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Get 2FA status' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '2FA status',
|
||||
schema: {
|
||||
example: {
|
||||
enabled: true,
|
||||
verifiedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
async getTwoFactorStatus(@CurrentUser('id') userId: string) {
|
||||
return this.twoFactorService.getStatus(userId);
|
||||
}
|
||||
|
||||
// Regenerate backup codes
|
||||
@Post('2fa/backup-codes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Regenerate backup codes' })
|
||||
@ApiBody({ type: RegenerateBackupCodesDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'New backup codes generated',
|
||||
schema: {
|
||||
example: {
|
||||
backupCodes: ['ABC12345', 'DEF67890', '...'],
|
||||
},
|
||||
},
|
||||
})
|
||||
async regenerateBackupCodes(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: RegenerateBackupCodesDto,
|
||||
) {
|
||||
return this.twoFactorService.regenerateBackupCodes(userId, dto.password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { TwoFactorService } from './two-factor/two-factor.service';
|
||||
import { JwtStrategy } from './strategies';
|
||||
import { PrismaModule } from '../prisma';
|
||||
|
||||
@@ -27,7 +28,7 @@ import { PrismaModule } from '../prisma';
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule, PassportModule],
|
||||
providers: [AuthService, TwoFactorService, JwtStrategy],
|
||||
exports: [AuthService, TwoFactorService, JwtModule, PassportModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
VerifyEmailDto,
|
||||
} from './dto';
|
||||
import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
|
||||
import { TwoFactorService } from './two-factor/two-factor.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -30,6 +33,8 @@ export class AuthService {
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
@Inject(forwardRef(() => TwoFactorService))
|
||||
private readonly twoFactorService: TwoFactorService,
|
||||
) {}
|
||||
|
||||
// ==========================================
|
||||
@@ -162,6 +167,21 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled
|
||||
if (user.twoFactorEnabled) {
|
||||
// Generate temporary token for 2FA verification
|
||||
const tempToken = await this.twoFactorService.generateTempToken(user.id);
|
||||
|
||||
return {
|
||||
requiresTwoFactor: true,
|
||||
tempToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||||
|
||||
@@ -615,6 +635,141 @@ export class AuthService {
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TWO-FACTOR AUTHENTICATION - VERIFY LOGIN
|
||||
// ==========================================
|
||||
async verifyTwoFactorLogin(
|
||||
tempToken: string,
|
||||
token: string,
|
||||
userAgent?: string,
|
||||
ipAddress?: string,
|
||||
) {
|
||||
// Verify temp token and get user ID
|
||||
const userId = await this.twoFactorService.verifyTempToken(tempToken);
|
||||
|
||||
// Verify the TOTP token
|
||||
const isValid = await this.twoFactorService.verifyToken(userId, token);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid verification code');
|
||||
}
|
||||
|
||||
// Get user with profile
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
userProfile: true,
|
||||
agentProfile: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || user.status !== UserStatus.ACTIVE) {
|
||||
throw new UnauthorizedException('User not found or inactive');
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||||
|
||||
// Create session
|
||||
await this.createSession(
|
||||
user.id,
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
userAgent,
|
||||
ipAddress,
|
||||
);
|
||||
|
||||
// Update last login
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
// Get profile based on role
|
||||
const profile =
|
||||
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
firstName: profile?.firstName,
|
||||
lastName: profile?.lastName,
|
||||
avatar: profile?.avatar,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TWO-FACTOR AUTHENTICATION - VERIFY BACKUP CODE
|
||||
// ==========================================
|
||||
async verifyBackupCodeLogin(
|
||||
tempToken: string,
|
||||
backupCode: string,
|
||||
userAgent?: string,
|
||||
ipAddress?: string,
|
||||
) {
|
||||
// Verify temp token and get user ID
|
||||
const userId = await this.twoFactorService.verifyTempToken(tempToken);
|
||||
|
||||
// Verify and consume the backup code
|
||||
const isValid = await this.twoFactorService.verifyBackupCode(
|
||||
userId,
|
||||
backupCode,
|
||||
);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid backup code');
|
||||
}
|
||||
|
||||
// Get user with profile
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
userProfile: true,
|
||||
agentProfile: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || user.status !== UserStatus.ACTIVE) {
|
||||
throw new UnauthorizedException('User not found or inactive');
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||||
|
||||
// Create session
|
||||
await this.createSession(
|
||||
user.id,
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
userAgent,
|
||||
ipAddress,
|
||||
);
|
||||
|
||||
// Update last login
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
// Get profile based on role
|
||||
const profile =
|
||||
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
firstName: profile?.firstName,
|
||||
lastName: profile?.lastName,
|
||||
avatar: profile?.avatar,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GET CURRENT USER
|
||||
// ==========================================
|
||||
|
||||
13
src/auth/two-factor/dto/disable-two-factor.dto.ts
Normal file
13
src/auth/two-factor/dto/disable-two-factor.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class DisableTwoFactorDto {
|
||||
@IsString()
|
||||
@MinLength(6, { message: 'Password must be at least 6 characters' })
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class RegenerateBackupCodesDto {
|
||||
@IsString()
|
||||
@MinLength(6, { message: 'Password must be at least 6 characters' })
|
||||
password: string;
|
||||
}
|
||||
2
src/auth/two-factor/dto/index.ts
Normal file
2
src/auth/two-factor/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './verify-two-factor.dto';
|
||||
export * from './disable-two-factor.dto';
|
||||
41
src/auth/two-factor/dto/verify-two-factor.dto.ts
Normal file
41
src/auth/two-factor/dto/verify-two-factor.dto.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { IsString, Length, IsOptional } from 'class-validator';
|
||||
|
||||
export class VerifyTwoFactorDto {
|
||||
@IsString()
|
||||
@Length(6, 6, { message: 'Verification code must be 6 digits' })
|
||||
token: string;
|
||||
}
|
||||
|
||||
export class VerifyTwoFactorLoginDto {
|
||||
@IsString()
|
||||
tempToken: string;
|
||||
|
||||
@IsString()
|
||||
@Length(6, 8, { message: 'Code must be 6-8 characters' })
|
||||
token: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userAgent?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export class VerifyBackupCodeDto {
|
||||
@IsString()
|
||||
tempToken: string;
|
||||
|
||||
@IsString()
|
||||
@Length(8, 8, { message: 'Backup code must be 8 characters' })
|
||||
backupCode: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userAgent?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ipAddress?: string;
|
||||
}
|
||||
360
src/auth/two-factor/two-factor.service.ts
Normal file
360
src/auth/two-factor/two-factor.service.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as speakeasy from 'speakeasy';
|
||||
import * as QRCode from 'qrcode';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as crypto from 'crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorService {
|
||||
private readonly encryptionKey: Buffer;
|
||||
private readonly algorithm = 'aes-256-gcm';
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {
|
||||
// Use JWT secret as base for encryption key (derive a 32-byte key)
|
||||
const secret = this.configService.get<string>('jwt.secret') || 'default-secret';
|
||||
this.encryptionKey = crypto.scryptSync(secret, 'salt', 32);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ENCRYPTION HELPERS
|
||||
// ==========================================
|
||||
private encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(this.algorithm, this.encryptionKey, iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||
}
|
||||
|
||||
private decrypt(encryptedData: string): string {
|
||||
const [ivHex, authTagHex, encrypted] = encryptedData.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv(this.algorithm, this.encryptionKey, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SETUP 2FA - Generate secret and QR code
|
||||
// ==========================================
|
||||
async setupTwoFactor(userId: string): Promise<{ secret: string; qrCode: string; manualEntry: string }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new BadRequestException('Two-factor authentication is already enabled');
|
||||
}
|
||||
|
||||
// Generate secret
|
||||
const secret = speakeasy.generateSecret({
|
||||
name: `Re-Quest:${user.email}`,
|
||||
issuer: 'Re-Quest',
|
||||
length: 32,
|
||||
});
|
||||
|
||||
// Store encrypted secret temporarily (not yet verified)
|
||||
const encryptedSecret = this.encrypt(secret.base32);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorSecret: encryptedSecret },
|
||||
});
|
||||
|
||||
// Generate QR code
|
||||
const qrCode = await QRCode.toDataURL(secret.otpauth_url!);
|
||||
|
||||
return {
|
||||
secret: secret.base32,
|
||||
qrCode,
|
||||
manualEntry: secret.base32,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ENABLE 2FA - Verify token and enable
|
||||
// ==========================================
|
||||
async enableTwoFactor(userId: string, token: string): Promise<{ backupCodes: string[] }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user || !user.twoFactorSecret) {
|
||||
throw new BadRequestException('Please setup two-factor authentication first');
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new BadRequestException('Two-factor authentication is already enabled');
|
||||
}
|
||||
|
||||
// Decrypt and verify token
|
||||
const decryptedSecret = this.decrypt(user.twoFactorSecret);
|
||||
const isValid = speakeasy.totp.verify({
|
||||
secret: decryptedSecret,
|
||||
encoding: 'base32',
|
||||
token,
|
||||
window: 1, // Allow 1 step before/after for clock drift
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
throw new BadRequestException('Invalid verification code');
|
||||
}
|
||||
|
||||
// Generate backup codes
|
||||
const backupCodes = await this.generateBackupCodesInternal();
|
||||
const hashedCodes = await Promise.all(
|
||||
backupCodes.map((code) => argon2.hash(code)),
|
||||
);
|
||||
|
||||
// Enable 2FA
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorBackupCodes: JSON.stringify(hashedCodes),
|
||||
twoFactorVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { backupCodes };
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DISABLE 2FA
|
||||
// ==========================================
|
||||
async disableTwoFactor(userId: string, password: string): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new BadRequestException('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if (!user.password) {
|
||||
throw new BadRequestException('Cannot disable 2FA for social login accounts');
|
||||
}
|
||||
|
||||
const isPasswordValid = await argon2.verify(user.password, password);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Invalid password');
|
||||
}
|
||||
|
||||
// Disable 2FA
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorVerifiedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate all sessions except current
|
||||
await this.prisma.session.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VERIFY TOKEN (during login)
|
||||
// ==========================================
|
||||
async verifyToken(userId: string, token: string): Promise<boolean> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user || !user.twoFactorEnabled || !user.twoFactorSecret) {
|
||||
throw new BadRequestException('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const decryptedSecret = this.decrypt(user.twoFactorSecret);
|
||||
const isValid = speakeasy.totp.verify({
|
||||
secret: decryptedSecret,
|
||||
encoding: 'base32',
|
||||
token,
|
||||
window: 1,
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VERIFY BACKUP CODE
|
||||
// ==========================================
|
||||
async verifyBackupCode(userId: string, code: string): Promise<boolean> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user || !user.twoFactorEnabled || !user.twoFactorBackupCodes) {
|
||||
throw new BadRequestException('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const hashedCodes: string[] = JSON.parse(user.twoFactorBackupCodes);
|
||||
|
||||
// Find and verify the backup code
|
||||
for (let i = 0; i < hashedCodes.length; i++) {
|
||||
try {
|
||||
const isValid = await argon2.verify(hashedCodes[i], code);
|
||||
if (isValid) {
|
||||
// Remove used code
|
||||
hashedCodes.splice(i, 1);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
twoFactorBackupCodes: JSON.stringify(hashedCodes),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// REGENERATE BACKUP CODES
|
||||
// ==========================================
|
||||
async regenerateBackupCodes(userId: string, password: string): Promise<{ backupCodes: string[] }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new BadRequestException('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if (!user.password) {
|
||||
throw new BadRequestException('Cannot regenerate codes for social login accounts');
|
||||
}
|
||||
|
||||
const isPasswordValid = await argon2.verify(user.password, password);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Invalid password');
|
||||
}
|
||||
|
||||
// Generate new backup codes
|
||||
const backupCodes = await this.generateBackupCodesInternal();
|
||||
const hashedCodes = await Promise.all(
|
||||
backupCodes.map((code) => argon2.hash(code)),
|
||||
);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
twoFactorBackupCodes: JSON.stringify(hashedCodes),
|
||||
},
|
||||
});
|
||||
|
||||
return { backupCodes };
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GET 2FA STATUS
|
||||
// ==========================================
|
||||
async getStatus(userId: string): Promise<{ enabled: boolean; verifiedAt: Date | null }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorVerifiedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: user.twoFactorEnabled,
|
||||
verifiedAt: user.twoFactorVerifiedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GENERATE TEMP TOKEN FOR 2FA FLOW
|
||||
// ==========================================
|
||||
async generateTempToken(userId: string): Promise<string> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
return this.jwtService.sign(
|
||||
{ sub: userId, email: user.email, type: '2fa-pending' },
|
||||
{
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
expiresIn: '5m',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VERIFY TEMP TOKEN
|
||||
// ==========================================
|
||||
verifyTempToken(token: string): string {
|
||||
try {
|
||||
const payload = this.jwtService.verify(token, {
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
});
|
||||
|
||||
if (payload.type !== '2fa-pending') {
|
||||
throw new UnauthorizedException('Invalid temporary token');
|
||||
}
|
||||
|
||||
return payload.sub;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired temporary token');
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// HELPER: Generate backup codes
|
||||
// ==========================================
|
||||
private async generateBackupCodesInternal(): Promise<string[]> {
|
||||
const codes: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
// Generate 8 character alphanumeric codes
|
||||
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
|
||||
codes.push(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user