[SYNC] feat: Add audit, MFA, and feature flags modules
- Add audit controller and routes - Add audit middleware and services - Add MFA controller, routes and service - Add feature flags controller, routes, middleware and services - Update package.json dependencies - Update app.ts with new modules Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7a957a69c7
commit
390bdd3923
222
package-lock.json
generated
222
package-lock.json
generated
@ -20,7 +20,9 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"speakeasy": "^2.0.0",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
@ -39,6 +41,8 @@
|
|||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/node": "^20.10.4",
|
"@types/node": "^20.10.4",
|
||||||
"@types/pg": "^8.10.9",
|
"@types/pg": "^8.10.9",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/speakeasy": "^2.0.10",
|
||||||
"@types/swagger-jsdoc": "^6.0.4",
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
@ -2138,6 +2142,16 @@
|
|||||||
"pg-types": "^2.2.0"
|
"pg-types": "^2.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
@ -2192,6 +2206,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",
|
||||||
@ -2780,6 +2804,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",
|
||||||
@ -3065,7 +3095,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@ -3448,6 +3477,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/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz",
|
||||||
@ -3544,6 +3582,12 @@
|
|||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"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/dir-glob": {
|
"node_modules/dir-glob": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||||
@ -6377,7 +6421,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"
|
||||||
@ -6434,7 +6477,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"
|
||||||
@ -6691,6 +6733,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@ -6831,6 +6882,141 @@
|
|||||||
],
|
],
|
||||||
"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/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/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.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": {
|
"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",
|
||||||
@ -6948,6 +7134,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",
|
||||||
@ -7273,6 +7465,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@ -7454,6 +7652,18 @@
|
|||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.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",
|
||||||
@ -8361,6 +8571,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/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.19",
|
"version": "1.1.19",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||||
|
|||||||
@ -25,7 +25,9 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"speakeasy": "^2.0.0",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
@ -44,6 +46,8 @@
|
|||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/node": "^20.10.4",
|
"@types/node": "^20.10.4",
|
||||||
"@types/pg": "^8.10.9",
|
"@types/pg": "^8.10.9",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/speakeasy": "^2.0.10",
|
||||||
"@types/swagger-jsdoc": "^6.0.4",
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
|
|||||||
11
src/app.ts
11
src/app.ts
@ -8,6 +8,7 @@ import { logger } from './shared/utils/logger.js';
|
|||||||
import { AppError, ApiResponse } from './shared/types/index.js';
|
import { AppError, ApiResponse } from './shared/types/index.js';
|
||||||
import { setupSwagger } from './config/swagger.config.js';
|
import { setupSwagger } from './config/swagger.config.js';
|
||||||
import authRoutes from './modules/auth/auth.routes.js';
|
import authRoutes from './modules/auth/auth.routes.js';
|
||||||
|
import mfaRoutes from './modules/auth/mfa.routes.js';
|
||||||
import apiKeysRoutes from './modules/auth/apiKeys.routes.js';
|
import apiKeysRoutes from './modules/auth/apiKeys.routes.js';
|
||||||
import usersRoutes from './modules/users/users.routes.js';
|
import usersRoutes from './modules/users/users.routes.js';
|
||||||
import { rolesRoutes, permissionsRoutes } from './modules/roles/index.js';
|
import { rolesRoutes, permissionsRoutes } from './modules/roles/index.js';
|
||||||
@ -28,6 +29,9 @@ import invoicesRoutes from './modules/invoices/invoices.routes.js';
|
|||||||
import productsRoutes from './modules/products/products.routes.js';
|
import productsRoutes from './modules/products/products.routes.js';
|
||||||
import warehousesRoutes from './modules/warehouses/warehouses.routes.js';
|
import warehousesRoutes from './modules/warehouses/warehouses.routes.js';
|
||||||
import fiscalRoutes from './modules/fiscal/fiscal.routes.js';
|
import fiscalRoutes from './modules/fiscal/fiscal.routes.js';
|
||||||
|
import auditRoutes from './modules/audit/audit.routes.js';
|
||||||
|
import featureFlagsRoutes from './modules/feature-flags/feature-flags.routes.js';
|
||||||
|
import { featureFlagsMiddleware } from './modules/feature-flags/middleware/feature-flags.middleware.js';
|
||||||
|
|
||||||
const app: Application = express();
|
const app: Application = express();
|
||||||
|
|
||||||
@ -60,6 +64,7 @@ app.get('/health', (_req: Request, res: Response) => {
|
|||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
app.use(`${apiPrefix}/auth`, authRoutes);
|
app.use(`${apiPrefix}/auth`, authRoutes);
|
||||||
|
app.use(`${apiPrefix}/auth/mfa`, mfaRoutes);
|
||||||
app.use(`${apiPrefix}/auth/api-keys`, apiKeysRoutes);
|
app.use(`${apiPrefix}/auth/api-keys`, apiKeysRoutes);
|
||||||
app.use(`${apiPrefix}/users`, usersRoutes);
|
app.use(`${apiPrefix}/users`, usersRoutes);
|
||||||
app.use(`${apiPrefix}/roles`, rolesRoutes);
|
app.use(`${apiPrefix}/roles`, rolesRoutes);
|
||||||
@ -81,6 +86,12 @@ app.use(`${apiPrefix}/invoices`, invoicesRoutes);
|
|||||||
app.use(`${apiPrefix}/products`, productsRoutes);
|
app.use(`${apiPrefix}/products`, productsRoutes);
|
||||||
app.use(`${apiPrefix}/warehouses`, warehousesRoutes);
|
app.use(`${apiPrefix}/warehouses`, warehousesRoutes);
|
||||||
app.use(`${apiPrefix}/fiscal`, fiscalRoutes);
|
app.use(`${apiPrefix}/fiscal`, fiscalRoutes);
|
||||||
|
app.use(`${apiPrefix}/audit`, auditRoutes);
|
||||||
|
app.use(`${apiPrefix}/feature-flags`, featureFlagsRoutes);
|
||||||
|
|
||||||
|
// Global helper middlewares (after auth logic if any global auth existed,
|
||||||
|
// but here routes handle auth, so we apply it to be available in all controllers)
|
||||||
|
app.use(featureFlagsMiddleware);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((_req: Request, res: Response) => {
|
app.use((_req: Request, res: Response) => {
|
||||||
|
|||||||
72
src/modules/audit/audit.controller.ts
Normal file
72
src/modules/audit/audit.controller.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { AuthenticatedRequest, ApiResponse } from '../../shared/types/index.js';
|
||||||
|
import { auditService } from './services/audit.instance.js';
|
||||||
|
|
||||||
|
export const auditController = {
|
||||||
|
/**
|
||||||
|
* Get audit logs with filters
|
||||||
|
*/
|
||||||
|
async getLogs(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
entityType,
|
||||||
|
action,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
page = 1,
|
||||||
|
limit = 20
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
userId: userId as string,
|
||||||
|
entityType: entityType as string,
|
||||||
|
action: action as string,
|
||||||
|
startDate: startDate ? new Date(startDate as string) : undefined,
|
||||||
|
endDate: endDate ? new Date(endDate as string) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await auditService.findAuditLogs(
|
||||||
|
req.user!.tenantId,
|
||||||
|
filters,
|
||||||
|
{ page: Number(page), limit: Number(limit) }
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
totalPages: Math.ceil(result.total / Number(limit))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get logs for a specific entity
|
||||||
|
*/
|
||||||
|
async getEntityLogs(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { entityType, entityId } = req.params;
|
||||||
|
const result = await auditService.findAuditLogsByEntity(
|
||||||
|
req.user!.tenantId,
|
||||||
|
entityType,
|
||||||
|
entityId
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
16
src/modules/audit/audit.routes.ts
Normal file
16
src/modules/audit/audit.routes.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { auditController } from './audit.controller.js';
|
||||||
|
import { authenticate } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
// import { requirePermission } from '../../shared/middleware/rbac.middleware.js'; // To implement later
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// List logs (add permission check later, e.g. 'audit.read')
|
||||||
|
router.get('/', (req, res, next) => auditController.getLogs(req, res, next));
|
||||||
|
|
||||||
|
// Entity logs
|
||||||
|
router.get('/:entityType/:entityId', (req, res, next) => auditController.getEntityLogs(req, res, next));
|
||||||
|
|
||||||
|
export default router;
|
||||||
74
src/modules/audit/middleware/audit.middleware.ts
Normal file
74
src/modules/audit/middleware/audit.middleware.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { AuthenticatedRequest } from '../../../shared/types/index.js';
|
||||||
|
import { auditService } from '../services/audit.instance.js';
|
||||||
|
import { AuditAction } from '../entities/audit-log.entity.js'; // Asumo que existe el enum
|
||||||
|
|
||||||
|
export const auditMiddleware = (action?: string, resourceType?: string) => {
|
||||||
|
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
// Solo auditamos si hay usuario y tenant (request autenticado)
|
||||||
|
if (!req.user) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalSend = res.send;
|
||||||
|
let responseBody: any;
|
||||||
|
|
||||||
|
// Intercept response to log status and body (optional)
|
||||||
|
res.send = function (body) {
|
||||||
|
responseBody = body;
|
||||||
|
return originalSend.apply(res, arguments as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
// Logic to determine action if not provided
|
||||||
|
let derivedAction = action;
|
||||||
|
if (!derivedAction) {
|
||||||
|
if (req.method === 'POST') derivedAction = 'CREATE';
|
||||||
|
else if (req.method === 'PUT' || req.method === 'PATCH') derivedAction = 'UPDATE';
|
||||||
|
else if (req.method === 'DELETE') derivedAction = 'DELETE';
|
||||||
|
else derivedAction = 'READ';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logic to determine resourceType if not provided
|
||||||
|
let derivedResource = resourceType;
|
||||||
|
if (!derivedResource) {
|
||||||
|
// Try to guess from URL: /api/v1/users -> users
|
||||||
|
const parts = req.baseUrl.split('/');
|
||||||
|
derivedResource = parts[parts.length - 1] || 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evitar loguear READs masivos si no es necesario (opcional, por ahora logueamos todo lo que pase por este middleware)
|
||||||
|
// Si el middleware se usa globalmente, filtrar GETs.
|
||||||
|
// Si se usa por ruta, asumimos que queremos auditar.
|
||||||
|
|
||||||
|
// Solo loguear operaciones exitosas o errores críticos?
|
||||||
|
// Logueamos todo.
|
||||||
|
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
// Podríamos loguear fallos con otra acción o flag
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
auditService.createAuditLog(req.user!.tenantId, {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: derivedAction as any, // Cast to specific enum if needed
|
||||||
|
resourceType: derivedResource,
|
||||||
|
resourceId: req.params.id, // Common pattern
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
oldValues: undefined, // Dificil de obtener automaticamente sin hacer query previo
|
||||||
|
newValues: req.method !== 'GET' ? req.body : undefined,
|
||||||
|
metadata: {
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl,
|
||||||
|
statusCode: res.statusCode
|
||||||
|
}
|
||||||
|
}).catch(err => console.error('Audit log error', err));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audit middleware error', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
21
src/modules/audit/services/audit.instance.ts
Normal file
21
src/modules/audit/services/audit.instance.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
|
import {
|
||||||
|
AuditLog,
|
||||||
|
EntityChange,
|
||||||
|
LoginHistory,
|
||||||
|
SensitiveDataAccess,
|
||||||
|
DataExport,
|
||||||
|
PermissionChange,
|
||||||
|
ConfigChange,
|
||||||
|
} from '../entities/index.js'; // Ajustar path de entities
|
||||||
|
import { AuditService } from './audit.service.js';
|
||||||
|
|
||||||
|
export const auditService = new AuditService(
|
||||||
|
AppDataSource.getRepository(AuditLog),
|
||||||
|
AppDataSource.getRepository(EntityChange),
|
||||||
|
AppDataSource.getRepository(LoginHistory),
|
||||||
|
AppDataSource.getRepository(SensitiveDataAccess),
|
||||||
|
AppDataSource.getRepository(DataExport),
|
||||||
|
AppDataSource.getRepository(PermissionChange),
|
||||||
|
AppDataSource.getRepository(ConfigChange)
|
||||||
|
);
|
||||||
115
src/modules/auth/mfa.controller.ts
Normal file
115
src/modules/auth/mfa.controller.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { MfaService } from './services/mfa.service.js';
|
||||||
|
import { ApiResponse, AuthenticatedRequest } from '../../shared/types/index.js';
|
||||||
|
|
||||||
|
export const mfaController = {
|
||||||
|
/**
|
||||||
|
* Initialize MFA setup
|
||||||
|
*/
|
||||||
|
async setup(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId; // Assuming req.user is populated by auth middleware
|
||||||
|
const result = await MfaService.setupMfa(userId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify MFA setup and enable
|
||||||
|
*/
|
||||||
|
async verifySetup(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const { secret, code } = req.body;
|
||||||
|
|
||||||
|
if (!secret || !code) {
|
||||||
|
throw new Error('Secret and code are required'); // Should use Zod/Validation middleware ideally
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await MfaService.verifyMfaSetup(userId, secret, code);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
data: { backupCodes: result.backupCodes },
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable MFA
|
||||||
|
*/
|
||||||
|
async disable(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const { code, password } = req.body;
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('Verification code is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await MfaService.disableMfa(userId, code, password);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MFA status
|
||||||
|
*/
|
||||||
|
async getStatus(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const result = await MfaService.getMfaStatus(userId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate backup codes
|
||||||
|
*/
|
||||||
|
async regenerateBackupCodes(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const { code, password } = req.body;
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('Verification code is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await MfaService.regenerateBackupCodes(userId, code, password);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
data: { backupCodes: result.backupCodes },
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
19
src/modules/auth/mfa.routes.ts
Normal file
19
src/modules/auth/mfa.routes.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { mfaController } from './mfa.controller.js';
|
||||||
|
import { authenticate } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All MFA routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
router.post('/setup', (req, res, next) => mfaController.setup(req, res, next));
|
||||||
|
router.post('/verify-setup', (req, res, next) => mfaController.verifySetup(req, res, next));
|
||||||
|
|
||||||
|
// Management
|
||||||
|
router.get('/status', (req, res, next) => mfaController.getStatus(req, res, next));
|
||||||
|
router.post('/disable', (req, res, next) => mfaController.disable(req, res, next));
|
||||||
|
router.post('/regenerate-codes', (req, res, next) => mfaController.regenerateBackupCodes(req, res, next));
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -3,12 +3,14 @@ import { Repository } from 'typeorm';
|
|||||||
import { AppDataSource } from '../../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { User, UserStatus, Role } from '../entities/index.js';
|
import { User, UserStatus, Role } from '../entities/index.js';
|
||||||
import { tokenService, TokenPair, RequestMetadata } from './token.service.js';
|
import { tokenService, TokenPair, RequestMetadata } from './token.service.js';
|
||||||
import { UnauthorizedError, ValidationError, NotFoundError } from '../../../shared/types/index.js';
|
import { UnauthorizedError, ValidationError, NotFoundError } from '../../../shared/types/index.js';
|
||||||
import { logger } from '../../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
import { MfaService } from './mfa.service.js';
|
||||||
|
|
||||||
export interface LoginDto {
|
export interface LoginDto {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
mfaCode?: string;
|
||||||
metadata?: RequestMetadata; // IP and user agent for session tracking
|
metadata?: RequestMetadata; // IP and user agent for session tracking
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,13 +66,29 @@ class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedError('Credenciales inválidas');
|
throw new UnauthorizedError('Credenciales inválidas');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash || '');
|
const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash || '');
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
throw new UnauthorizedError('Credenciales inválidas');
|
throw new UnauthorizedError('Credenciales inválidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// MFA Verification
|
||||||
|
if (user.mfaEnabled) {
|
||||||
|
if (!dto.mfaCode) {
|
||||||
|
throw new ValidationError('MFA Code Required', [{
|
||||||
|
code: 'mfa_required',
|
||||||
|
message: 'Multi-factor authentication code is required',
|
||||||
|
path: ['mfaCode']
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMfaValid = await MfaService.verifyMfaCode(user.id, dto.mfaCode);
|
||||||
|
if (!isMfaValid) {
|
||||||
|
throw new UnauthorizedError('Invalid MFA code');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last login
|
// Update last login
|
||||||
@ -99,7 +117,7 @@ class AuthService {
|
|||||||
lastName,
|
lastName,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info('User logged in', { userId: user.id, email: user.email });
|
logger.info('User logged in', { userId: user.id, email: user.email, mfa: user.mfaEnabled });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: userResponse as any,
|
user: userResponse as any,
|
||||||
|
|||||||
322
src/modules/auth/services/mfa.service.ts
Normal file
322
src/modules/auth/services/mfa.service.ts
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
import speakeasy from 'speakeasy';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
|
import { User } from '../entities/user.entity';
|
||||||
|
import { config } from '../../../config';
|
||||||
|
import { AppError } from '../../../shared/types';
|
||||||
|
|
||||||
|
export class MfaService {
|
||||||
|
private static get userRepository() {
|
||||||
|
return AppDataSource.getRepository(User);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static get appName() {
|
||||||
|
return 'ERP Core'; // Or config.appName if available
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize MFA setup - generate secret and QR code
|
||||||
|
*/
|
||||||
|
static async setupMfa(userId: string) {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.mfaEnabled) {
|
||||||
|
throw new AppError('MFA is already enabled', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate TOTP secret
|
||||||
|
const secret = speakeasy.generateSecret({
|
||||||
|
name: `${this.appName} (${user.email})`,
|
||||||
|
length: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url!);
|
||||||
|
|
||||||
|
// Generate backup codes
|
||||||
|
const backupCodes = this.generateBackupCodes();
|
||||||
|
|
||||||
|
return {
|
||||||
|
secret: secret.base32,
|
||||||
|
qrCodeDataUrl,
|
||||||
|
backupCodes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify MFA setup and enable
|
||||||
|
*/
|
||||||
|
static async verifyMfaSetup(
|
||||||
|
userId: string,
|
||||||
|
secretToken: string,
|
||||||
|
code: string
|
||||||
|
) {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.mfaEnabled) {
|
||||||
|
throw new AppError('MFA is already enabled', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the TOTP code
|
||||||
|
const isValid = speakeasy.totp.verify({
|
||||||
|
secret: secretToken,
|
||||||
|
encoding: 'base32',
|
||||||
|
token: code,
|
||||||
|
window: 1, // Allow 1 step (30 seconds) tolerance
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new AppError('Invalid verification code', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate and hash backup codes
|
||||||
|
const backupCodes = this.generateBackupCodes();
|
||||||
|
const hashedBackupCodes = await Promise.all(
|
||||||
|
backupCodes.map((c) => bcrypt.hash(c, 10))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable MFA and store secret
|
||||||
|
user.mfaEnabled = true;
|
||||||
|
user.mfaSecretEncrypted = this.encryptSecret(secretToken);
|
||||||
|
user.mfaBackupCodes = hashedBackupCodes;
|
||||||
|
// user.mfaEnabledAt = new Date(); // Field not in entity yet, skipping
|
||||||
|
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'MFA enabled successfully. Please save your backup codes.',
|
||||||
|
backupCodes, // Return unhashed backup codes one last time
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify TOTP code during login
|
||||||
|
*/
|
||||||
|
static async verifyMfaCode(
|
||||||
|
userId: string,
|
||||||
|
code: string,
|
||||||
|
isBackupCode: boolean = false
|
||||||
|
): Promise<boolean> {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.mfaEnabled || !user.mfaSecretEncrypted) {
|
||||||
|
throw new AppError('MFA is not enabled for this user', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBackupCode) {
|
||||||
|
return this.verifyBackupCode(user, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt secret and verify TOTP
|
||||||
|
const secret = this.decryptSecret(user.mfaSecretEncrypted);
|
||||||
|
|
||||||
|
const isValid = speakeasy.totp.verify({
|
||||||
|
secret,
|
||||||
|
encoding: 'base32',
|
||||||
|
token: code,
|
||||||
|
window: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable MFA for user
|
||||||
|
*/
|
||||||
|
static async disableMfa(userId: string, code: string, password?: string) {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.mfaEnabled) {
|
||||||
|
throw new AppError('MFA is not enabled', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Verify password if provided
|
||||||
|
if (password) {
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new AppError('Invalid password', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify MFA code
|
||||||
|
const isMfaValid = await this.verifyMfaCode(userId, code, code.length > 6);
|
||||||
|
if (!isMfaValid) {
|
||||||
|
throw new AppError('Invalid verification code', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable MFA
|
||||||
|
user.mfaEnabled = false;
|
||||||
|
user.mfaSecretEncrypted = null as any; // TypeORM nullable handling
|
||||||
|
user.mfaBackupCodes = null as any;
|
||||||
|
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'MFA disabled successfully',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MFA status for user
|
||||||
|
*/
|
||||||
|
static async getMfaStatus(userId: string) {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupCodesRemaining = user.mfaBackupCodes?.length || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: user.mfaEnabled || false,
|
||||||
|
backupCodesRemaining,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate backup codes
|
||||||
|
*/
|
||||||
|
static async regenerateBackupCodes(
|
||||||
|
userId: string,
|
||||||
|
code: string,
|
||||||
|
password?: string
|
||||||
|
) {
|
||||||
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.mfaEnabled) {
|
||||||
|
throw new AppError('MFA is not enabled', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Verify password if provided
|
||||||
|
if (password) {
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new AppError('Invalid password', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify MFA code
|
||||||
|
const isMfaValid = await this.verifyMfaCode(userId, code);
|
||||||
|
if (!isMfaValid) {
|
||||||
|
throw new AppError('Invalid verification code', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new backup codes
|
||||||
|
const backupCodes = this.generateBackupCodes();
|
||||||
|
const hashedBackupCodes = await Promise.all(
|
||||||
|
backupCodes.map((c) => bcrypt.hash(c, 10))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
user.mfaBackupCodes = hashedBackupCodes;
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backupCodes,
|
||||||
|
message: 'New backup codes generated. Please save them securely.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Private Methods ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate 10 random backup codes
|
||||||
|
*/
|
||||||
|
private static generateBackupCodes(): string[] {
|
||||||
|
const codes: string[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
|
||||||
|
// Format as XXXX-XXXX for readability
|
||||||
|
codes.push(`${code.slice(0, 4)}-${code.slice(4)}`);
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a backup code
|
||||||
|
*/
|
||||||
|
private static async verifyBackupCode(user: User, code: string): Promise<boolean> {
|
||||||
|
if (!user.mfaBackupCodes || user.mfaBackupCodes.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize code (remove dashes, uppercase)
|
||||||
|
const normalizedCode = code.replace(/-/g, '').toUpperCase();
|
||||||
|
const formattedCode = `${normalizedCode.slice(0, 4)}-${normalizedCode.slice(4)}`;
|
||||||
|
|
||||||
|
// Check each hashed backup code
|
||||||
|
for (let i = 0; i < user.mfaBackupCodes.length; i++) {
|
||||||
|
const isMatch = await bcrypt.compare(formattedCode, user.mfaBackupCodes[i]);
|
||||||
|
if (isMatch) {
|
||||||
|
// Remove the used backup code
|
||||||
|
const updatedCodes = [...user.mfaBackupCodes];
|
||||||
|
updatedCodes.splice(i, 1);
|
||||||
|
user.mfaBackupCodes = updatedCodes;
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt MFA secret for storage
|
||||||
|
*/
|
||||||
|
private static encryptSecret(secret: string): string {
|
||||||
|
const encryptionKey = config.jwt.secret; // Using JWT secret as fallback
|
||||||
|
|
||||||
|
// Use first 32 bytes of key for AES-256
|
||||||
|
const key = crypto.createHash('sha256').update(encryptionKey).digest();
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(secret, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
// Return IV + encrypted data
|
||||||
|
return iv.toString('hex') + ':' + encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt MFA secret from storage
|
||||||
|
*/
|
||||||
|
private static decryptSecret(encryptedSecret: string): string {
|
||||||
|
const encryptionKey = config.jwt.secret;
|
||||||
|
|
||||||
|
const key = crypto.createHash('sha256').update(encryptionKey).digest();
|
||||||
|
const [ivHex, encrypted] = encryptedSecret.split(':');
|
||||||
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/modules/feature-flags/feature-flags.controller.ts
Normal file
68
src/modules/feature-flags/feature-flags.controller.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { AuthenticatedRequest, ApiResponse } from '../../shared/types/index.js';
|
||||||
|
import { featureFlagsService } from './services/feature-flags.instance.js';
|
||||||
|
|
||||||
|
export const featureFlagsController = {
|
||||||
|
/**
|
||||||
|
* Get all flags (Admin only ideally)
|
||||||
|
*/
|
||||||
|
async getAllFlags(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const flags = await featureFlagsService.findAllFlagsIncludingInactive();
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: flags,
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate flags for current tenant
|
||||||
|
*/
|
||||||
|
async evaluateFlags(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { codes } = req.body;
|
||||||
|
if (!Array.isArray(codes)) {
|
||||||
|
throw new Error('Codes must be an array of strings');
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await featureFlagsService.evaluateFlags(codes, req.user!.tenantId);
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: results,
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or Update an override for a tenant
|
||||||
|
*/
|
||||||
|
async upsertOverride(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { flagId, tenantId, enabled, expiresAt } = req.body;
|
||||||
|
|
||||||
|
const existing = await featureFlagsService.findOverride(flagId, tenantId);
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
result = await featureFlagsService.updateOverride(existing.id, { enabled, expiresAt });
|
||||||
|
} else {
|
||||||
|
result = await featureFlagsService.createOverride({ flagId, tenantId, enabled, expiresAt }, req.user!.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
16
src/modules/feature-flags/feature-flags.routes.ts
Normal file
16
src/modules/feature-flags/feature-flags.routes.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { featureFlagsController } from './feature-flags.controller.js';
|
||||||
|
import { authenticate } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// Public evaluation (for current tenant)
|
||||||
|
router.post('/evaluate', (req, res, next) => featureFlagsController.evaluateFlags(req, res, next));
|
||||||
|
|
||||||
|
// Admin routes (should add isSuperuser or permission check)
|
||||||
|
router.get('/', (req, res, next) => featureFlagsController.getAllFlags(req, res, next));
|
||||||
|
router.post('/overrides', (req, res, next) => featureFlagsController.upsertOverride(req, res, next));
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { AuthenticatedRequest } from '../../../shared/types/index.js';
|
||||||
|
import { featureFlagsService } from '../services/feature-flags.instance.js';
|
||||||
|
|
||||||
|
export const featureFlagsMiddleware = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
// Solo evaluamos si hay tenant (request autenticado)
|
||||||
|
if (!req.user || !req.user.tenantId) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Inyectamos un objeto helper en el request para chequear flags facilmente en controladores/servicios
|
||||||
|
(req as any).flags = {
|
||||||
|
isEnabled: async (code: string) => featureFlagsService.isEnabled(code, req.user!.tenantId),
|
||||||
|
evaluate: async (code: string) => featureFlagsService.evaluateFlag(code, req.user!.tenantId),
|
||||||
|
evaluateMany: async (codes: string[]) => featureFlagsService.evaluateFlags(codes, req.user!.tenantId)
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Feature flags middleware error', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
|
import { Flag, TenantOverride } from '../entities/index.js';
|
||||||
|
import { FeatureFlagsService } from './feature-flags.service.js';
|
||||||
|
|
||||||
|
export const featureFlagsService = new FeatureFlagsService(
|
||||||
|
AppDataSource.getRepository(Flag),
|
||||||
|
AppDataSource.getRepository(TenantOverride)
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user