feat: Implement two-factor authentication (2FA) with dedicated endpoints for setup, enabling, disabling, verification, and backup code management.

This commit is contained in:
pradeepkumar
2026-02-06 09:30:12 +05:30
parent ea05e18c15
commit 750cb55764
10 changed files with 989 additions and 19 deletions

251
package-lock.json generated
View File

@@ -64,12 +64,14 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"prisma": "^7.2.0", "prisma": "^7.2.0",
"qrcode": "^1.5.4",
"redis": "^5.10.0", "redis": "^5.10.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"speakeasy": "^2.0.0",
"stripe": "^20.1.0", "stripe": "^20.1.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winston": "^3.19.0", "winston": "^3.19.0",
@@ -91,6 +93,8 @@
"@types/passport-google-oauth20": "^2.0.17", "@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"autocannon": "^8.0.0", "autocannon": "^8.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
@@ -2711,16 +2715,6 @@
"@electric-sql/pglite": "0.3.2" "@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": { "node_modules/@epic-web/invariant": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
@@ -6817,6 +6811,16 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -6904,6 +6908,16 @@
"@types/node": "*" "@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": { "node_modules/@types/stack-utils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -8016,6 +8030,12 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT" "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": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "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": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -9498,6 +9527,12 @@
"node": ">=0.3.1" "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": { "node_modules/display-notification": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/display-notification/-/display-notification-2.0.0.tgz", "resolved": "https://registry.npmjs.org/display-notification/-/display-notification-2.0.0.tgz",
@@ -15326,7 +15361,6 @@
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -15539,7 +15573,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -15898,6 +15931,15 @@
"node": ">=4" "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": { "node_modules/postgres": {
"version": "3.4.7", "version": "3.4.7",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
@@ -16425,6 +16467,157 @@
], ],
"license": "MIT" "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": { "node_modules/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -16650,7 +16843,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -16666,6 +16858,12 @@
"node": ">=0.10.0" "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": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -17103,6 +17301,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -17462,6 +17666,18 @@
"node": ">=0.10.0" "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": { "node_modules/split2": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -19328,6 +19544,12 @@
"node": ">= 8" "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": { "node_modules/widest-line": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
@@ -19422,7 +19644,6 @@
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
@@ -19476,7 +19697,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -19486,7 +19706,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"

View File

@@ -82,12 +82,14 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"prisma": "^7.2.0", "prisma": "^7.2.0",
"qrcode": "^1.5.4",
"redis": "^5.10.0", "redis": "^5.10.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"speakeasy": "^2.0.0",
"stripe": "^20.1.0", "stripe": "^20.1.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winston": "^3.19.0", "winston": "^3.19.0",
@@ -109,6 +111,8 @@
"@types/passport-google-oauth20": "^2.0.17", "@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"autocannon": "^8.0.0", "autocannon": "^8.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",

View File

@@ -83,6 +83,12 @@ model User {
facebookId String? @unique facebookId String? @unique
twitterId 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 // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -18,6 +18,7 @@ import {
import type { Request } from 'express'; import type { Request } from 'express';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { TwoFactorService } from './two-factor/two-factor.service';
import { import {
RegisterDto, RegisterDto,
LoginDto, LoginDto,
@@ -27,13 +28,23 @@ import {
RefreshTokenDto, RefreshTokenDto,
VerifyEmailDto, VerifyEmailDto,
} from './dto'; } from './dto';
import {
VerifyTwoFactorDto,
VerifyTwoFactorLoginDto,
VerifyBackupCodeDto,
DisableTwoFactorDto,
RegenerateBackupCodesDto,
} from './two-factor/dto';
import { JwtAuthGuard } from './guards'; import { JwtAuthGuard } from './guards';
import { Public, CurrentUser } from './decorators'; import { Public, CurrentUser } from './decorators';
@ApiTags('Auth') @ApiTags('Auth')
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(
private readonly authService: AuthService,
private readonly twoFactorService: TwoFactorService,
) {}
// ========================================== // ==========================================
// REGISTER // REGISTER
@@ -356,4 +367,162 @@ export class AuthController {
async logoutAll(@CurrentUser('id') userId: string) { async logoutAll(@CurrentUser('id') userId: string) {
return this.authService.logout(userId); 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);
}
} }

View File

@@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { TwoFactorService } from './two-factor/two-factor.service';
import { JwtStrategy } from './strategies'; import { JwtStrategy } from './strategies';
import { PrismaModule } from '../prisma'; import { PrismaModule } from '../prisma';
@@ -27,7 +28,7 @@ import { PrismaModule } from '../prisma';
}), }),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy], providers: [AuthService, TwoFactorService, JwtStrategy],
exports: [AuthService, JwtModule, PassportModule], exports: [AuthService, TwoFactorService, JwtModule, PassportModule],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -4,6 +4,8 @@ import {
ConflictException, ConflictException,
BadRequestException, BadRequestException,
NotFoundException, NotFoundException,
Inject,
forwardRef,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@@ -22,6 +24,7 @@ import {
VerifyEmailDto, VerifyEmailDto,
} from './dto'; } from './dto';
import { UserRole, AuthProvider, UserStatus } from '@prisma/client'; import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
import { TwoFactorService } from './two-factor/two-factor.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -30,6 +33,8 @@ export class AuthService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2, 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'); 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 // Generate tokens
const tokens = await this.generateTokens(user.id, user.email, user.role); const tokens = await this.generateTokens(user.id, user.email, user.role);
@@ -615,6 +635,141 @@ export class AuthService {
return { message: 'Logged out successfully' }; 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 // GET CURRENT USER
// ========================================== // ==========================================

View 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;
}

View File

@@ -0,0 +1,2 @@
export * from './verify-two-factor.dto';
export * from './disable-two-factor.dto';

View 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;
}

View 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;
}
}