From 390bdd392394a223a5d63c80cf6f6aaeac5177c6 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 08:02:02 -0600 Subject: [PATCH] [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 --- package-lock.json | 222 +++++++++++- package.json | 4 + src/app.ts | 11 + src/modules/audit/audit.controller.ts | 72 ++++ src/modules/audit/audit.routes.ts | 16 + .../audit/middleware/audit.middleware.ts | 74 ++++ src/modules/audit/services/audit.instance.ts | 21 ++ src/modules/auth/mfa.controller.ts | 115 +++++++ src/modules/auth/mfa.routes.ts | 19 ++ src/modules/auth/services/auth.service.ts | 26 +- src/modules/auth/services/mfa.service.ts | 322 ++++++++++++++++++ .../feature-flags/feature-flags.controller.ts | 68 ++++ .../feature-flags/feature-flags.routes.ts | 16 + .../middleware/feature-flags.middleware.ts | 23 ++ .../services/feature-flags.instance.ts | 8 + 15 files changed, 1010 insertions(+), 7 deletions(-) create mode 100644 src/modules/audit/audit.controller.ts create mode 100644 src/modules/audit/audit.routes.ts create mode 100644 src/modules/audit/middleware/audit.middleware.ts create mode 100644 src/modules/audit/services/audit.instance.ts create mode 100644 src/modules/auth/mfa.controller.ts create mode 100644 src/modules/auth/mfa.routes.ts create mode 100644 src/modules/auth/services/mfa.service.ts create mode 100644 src/modules/feature-flags/feature-flags.controller.ts create mode 100644 src/modules/feature-flags/feature-flags.routes.ts create mode 100644 src/modules/feature-flags/middleware/feature-flags.middleware.ts create mode 100644 src/modules/feature-flags/services/feature-flags.instance.ts diff --git a/package-lock.json b/package-lock.json index b6e7ee6..715dbe9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,9 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", + "speakeasy": "^2.0.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.28", @@ -39,6 +41,8 @@ "@types/morgan": "^1.9.9", "@types/node": "^20.10.4", "@types/pg": "^8.10.9", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^9.0.7", @@ -2138,6 +2142,16 @@ "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": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -2192,6 +2206,16 @@ "@types/node": "*" } }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2780,6 +2804,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3065,7 +3095,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -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": { "version": "1.7.0", "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_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": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -6377,7 +6421,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6434,7 +6477,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6691,6 +6733,15 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6831,6 +6882,141 @@ ], "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": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -6948,6 +7134,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -7273,6 +7465,12 @@ "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": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7454,6 +7652,18 @@ "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": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -8361,6 +8571,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", diff --git a/package.json b/package.json index b28fbce..01b1a67 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", + "speakeasy": "^2.0.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.28", @@ -44,6 +46,8 @@ "@types/morgan": "^1.9.9", "@types/node": "^20.10.4", "@types/pg": "^8.10.9", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^9.0.7", diff --git a/src/app.ts b/src/app.ts index 4e9b568..8a6fbc7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,7 @@ import { logger } from './shared/utils/logger.js'; import { AppError, ApiResponse } from './shared/types/index.js'; import { setupSwagger } from './config/swagger.config.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 usersRoutes from './modules/users/users.routes.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 warehousesRoutes from './modules/warehouses/warehouses.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(); @@ -60,6 +64,7 @@ app.get('/health', (_req: Request, res: Response) => { // API routes app.use(`${apiPrefix}/auth`, authRoutes); +app.use(`${apiPrefix}/auth/mfa`, mfaRoutes); app.use(`${apiPrefix}/auth/api-keys`, apiKeysRoutes); app.use(`${apiPrefix}/users`, usersRoutes); app.use(`${apiPrefix}/roles`, rolesRoutes); @@ -81,6 +86,12 @@ app.use(`${apiPrefix}/invoices`, invoicesRoutes); app.use(`${apiPrefix}/products`, productsRoutes); app.use(`${apiPrefix}/warehouses`, warehousesRoutes); 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 app.use((_req: Request, res: Response) => { diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..e06168f --- /dev/null +++ b/src/modules/audit/audit.controller.ts @@ -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); + } + } +}; diff --git a/src/modules/audit/audit.routes.ts b/src/modules/audit/audit.routes.ts new file mode 100644 index 0000000..fd353dc --- /dev/null +++ b/src/modules/audit/audit.routes.ts @@ -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; diff --git a/src/modules/audit/middleware/audit.middleware.ts b/src/modules/audit/middleware/audit.middleware.ts new file mode 100644 index 0000000..dc71703 --- /dev/null +++ b/src/modules/audit/middleware/audit.middleware.ts @@ -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(); + }; +}; diff --git a/src/modules/audit/services/audit.instance.ts b/src/modules/audit/services/audit.instance.ts new file mode 100644 index 0000000..21d482f --- /dev/null +++ b/src/modules/audit/services/audit.instance.ts @@ -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) +); diff --git a/src/modules/auth/mfa.controller.ts b/src/modules/auth/mfa.controller.ts new file mode 100644 index 0000000..d84c939 --- /dev/null +++ b/src/modules/auth/mfa.controller.ts @@ -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); + } + }, +}; diff --git a/src/modules/auth/mfa.routes.ts b/src/modules/auth/mfa.routes.ts new file mode 100644 index 0000000..34b2787 --- /dev/null +++ b/src/modules/auth/mfa.routes.ts @@ -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; diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 0981473..2792142 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -3,12 +3,14 @@ import { Repository } from 'typeorm'; import { AppDataSource } from '../../../config/typeorm.js'; import { User, UserStatus, Role } from '../entities/index.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 { MfaService } from './mfa.service.js'; export interface LoginDto { email: string; password: string; + mfaCode?: string; metadata?: RequestMetadata; // IP and user agent for session tracking } @@ -64,13 +66,29 @@ class AuthService { }); if (!user) { - throw new UnauthorizedError('Credenciales inválidas'); + throw new UnauthorizedError('Credenciales inválidas'); } // Verify password const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash || ''); 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 @@ -99,7 +117,7 @@ class AuthService { 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 { user: userResponse as any, diff --git a/src/modules/auth/services/mfa.service.ts b/src/modules/auth/services/mfa.service.ts new file mode 100644 index 0000000..2b07150 --- /dev/null +++ b/src/modules/auth/services/mfa.service.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/modules/feature-flags/feature-flags.controller.ts b/src/modules/feature-flags/feature-flags.controller.ts new file mode 100644 index 0000000..b36e28d --- /dev/null +++ b/src/modules/feature-flags/feature-flags.controller.ts @@ -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); + } + } +}; diff --git a/src/modules/feature-flags/feature-flags.routes.ts b/src/modules/feature-flags/feature-flags.routes.ts new file mode 100644 index 0000000..d6332b6 --- /dev/null +++ b/src/modules/feature-flags/feature-flags.routes.ts @@ -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; diff --git a/src/modules/feature-flags/middleware/feature-flags.middleware.ts b/src/modules/feature-flags/middleware/feature-flags.middleware.ts new file mode 100644 index 0000000..dc1c9ea --- /dev/null +++ b/src/modules/feature-flags/middleware/feature-flags.middleware.ts @@ -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(); +}; diff --git a/src/modules/feature-flags/services/feature-flags.instance.ts b/src/modules/feature-flags/services/feature-flags.instance.ts new file mode 100644 index 0000000..3117759 --- /dev/null +++ b/src/modules/feature-flags/services/feature-flags.instance.ts @@ -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) +);