From aa59e642afc2d7359fcad423a6280b9cd29ff9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 28 May 2026 16:00:30 -0300 Subject: [PATCH] Add backend policy tests and API client split --- Dockerfile | 8 ++- backend/index.js | 65 ++++++--------------- backend/package-lock.json | 68 +++++++++++----------- backend/package.json | 7 ++- backend/policies/accessPolicy.js | 42 ++++++++++++++ backend/test/accessPolicy.test.js | 64 +++++++++++++++++++++ backend/test/security.test.js | 22 ++++++++ backend/utils/security.js | 11 ++++ package.json | 3 +- src/services/apiClient.ts | 93 +++++++++++++++++++++++++++++++ src/services/dataService.ts | 86 +--------------------------- 11 files changed, 298 insertions(+), 171 deletions(-) create mode 100644 backend/policies/accessPolicy.js create mode 100644 backend/test/accessPolicy.test.js create mode 100644 backend/test/security.test.js create mode 100644 backend/utils/security.js create mode 100644 src/services/apiClient.ts diff --git a/Dockerfile b/Dockerfile index de45d6c..30d392f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,15 +17,17 @@ WORKDIR /app ENV NODE_ENV=production -# Copy backend package.json as main package.json -COPY backend/package.json ./package.json +# Copy backend package metadata as runtime package metadata +COPY backend/package.json backend/package-lock.json ./ # Install dependencies -RUN npm install --omit=dev +RUN npm ci --omit=dev # Copy backend source directly into root COPY backend/index.js ./index.js COPY backend/db.js ./db.js +COPY backend/policies ./policies +COPY backend/utils ./utils # Copy built frontend COPY --from=builder /app/dist ./dist diff --git a/backend/index.js b/backend/index.js index b50ee5b..12fddf1 100644 --- a/backend/index.js +++ b/backend/index.js @@ -10,6 +10,15 @@ const multer = require('multer'); const { v4: uuidv4 } = require('uuid'); const fs = require('fs'); const pool = require('./db'); +const { stripEnvQuotes, hashSecret, maskSecret } = require('./utils/security'); +const { + canReadUser, + canUpdateUser, + canManageUserStatus, + canChangeUserEmail, + canManageUserRoleOrTeam, + canReadAttendance, +} = require('./policies/accessPolicy'); const app = express(); const PORT = process.env.PORT || 3001; @@ -20,9 +29,6 @@ if (!JWT_SECRET) { throw new Error('JWT_SECRET is required in production.'); } -const stripEnvQuotes = (value = '') => value.replace(/^"|"$/g, ''); -const hashSecret = (value) => crypto.createHash('sha256').update(value).digest('hex'); -const maskSecret = (id, value) => `masked:${id}:${value.slice(-6)}`; const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, sound_enabled, created_at'; const transporter = nodemailer.createTransport({ @@ -489,18 +495,7 @@ apiRouter.get('/users/:idOrSlug', async (req, res) => { if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' }); if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado' }); - if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) { - return res.status(403).json({ error: 'Acesso negado.' }); - } - - if (req.user.role === 'agent' && rows[0].id !== req.user.id) { - return res.status(403).json({ error: 'Acesso negado.' }); - } - - if (req.user.role === 'manager') { - const canSeeUser = rows[0].id === req.user.id || (req.user.team_id && rows[0].team_id === req.user.team_id); - if (!canSeeUser) return res.status(403).json({ error: 'Acesso negado.' }); - } + if (!canReadUser(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' }); res.json(rows[0]); } catch (error) { @@ -580,30 +575,14 @@ apiRouter.put('/users/:id', async (req, res) => { const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]); if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); - const isSelf = req.user.id === req.params.id; - const isManagerOrAdmin = ['admin', 'manager', 'super_admin'].includes(req.user.role); - const isAdmin = ['admin', 'super_admin'].includes(req.user.role); - - if (!isSelf && !isManagerOrAdmin) { - return res.status(403).json({ error: 'Acesso negado.' }); - } - - if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) { - return res.status(403).json({ error: 'Acesso negado.' }); - } + if (!canUpdateUser(req.user, existing[0])) return res.status(403).json({ error: 'Acesso negado.' }); // Only Admins can change roles and teams. Managers can only edit basic info of their team members. - const finalRole = isAdmin && role !== undefined ? role : existing[0].role; - const finalTeamId = isAdmin && team_id !== undefined ? team_id : existing[0].team_id; - if (req.user.role === 'manager') { - const canEditUser = existing[0].id !== req.user.id && req.user.team_id && existing[0].team_id === req.user.team_id && existing[0].role === 'agent'; - if (!isSelf && !canEditUser) return res.status(403).json({ error: 'Acesso negado.' }); - } - - const finalStatus = isAdmin && status !== undefined ? status : existing[0].status; - const canChangeEmail = isSelf || isAdmin; - const finalEmail = canChangeEmail && email !== undefined ? email : existing[0].email; - const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true); + const finalRole = canManageUserRoleOrTeam(req.user) && role !== undefined ? role : existing[0].role; + const finalTeamId = canManageUserRoleOrTeam(req.user) && team_id !== undefined ? team_id : existing[0].team_id; + const finalStatus = canManageUserStatus(req.user) && status !== undefined ? status : existing[0].status; + const finalEmail = canChangeUserEmail(req.user, existing[0]) && email !== undefined ? email : existing[0].email; + const finalSoundEnabled = req.user.id === req.params.id && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true); if (finalEmail !== existing[0].email) { const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]); @@ -1126,17 +1105,7 @@ apiRouter.get('/attendances/:id', async (req, res) => { ); if (rows.length === 0) return res.status(404).json({ error: 'Not found' }); - if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) { - return res.status(403).json({ error: 'Acesso negado.' }); - } - - if (req.user.role === 'agent' && rows[0].user_id !== req.user.id) { - return res.status(403).json({ error: 'Acesso negado.' }); - } - - if (req.user.role === 'manager' && (!req.user.team_id || rows[0].team_id !== req.user.team_id)) { - return res.status(403).json({ error: 'Acesso negado.' }); - } + if (!canReadAttendance(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' }); const r = rows[0]; res.json({ diff --git a/backend/package-lock.json b/backend/package-lock.json index ce4f5f7..779b03f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -73,9 +73,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -86,7 +86,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -331,9 +331,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -358,14 +358,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -384,7 +384,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -519,9 +519,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -771,9 +771,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz", - "integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -849,9 +849,9 @@ } }, "node_modules/nodemailer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", - "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.9.tgz", + "integrity": "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -900,9 +900,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/proxy-addr": { @@ -919,9 +919,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1080,13 +1080,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1233,9 +1233,9 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/backend/package.json b/backend/package.json index 664e2ad..01a99ec 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,15 +3,18 @@ "version": "1.0.0", "type": "commonjs", "main": "index.js", + "scripts": { + "test": "node --test" + }, "dependencies": { "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.18.2", "jsonwebtoken": "^9.0.3", - "multer": "^1.4.5-lts.1", + "multer": "^2.1.0", "mysql2": "^3.9.1", "nodemailer": "^8.0.1", - "uuid": "^9.0.1" + "uuid": "^13.0.0" } } diff --git a/backend/policies/accessPolicy.js b/backend/policies/accessPolicy.js new file mode 100644 index 0000000..2f2e948 --- /dev/null +++ b/backend/policies/accessPolicy.js @@ -0,0 +1,42 @@ +const sameTenant = (actor, resource) => actor.role === 'super_admin' || actor.tenant_id === resource.tenant_id; + +const canReadUser = (actor, targetUser) => { + if (!actor || !targetUser || !sameTenant(actor, targetUser)) return false; + if (actor.role === 'super_admin' || actor.role === 'admin') return true; + if (actor.role === 'agent') return targetUser.id === actor.id; + if (actor.role === 'manager') { + return targetUser.id === actor.id || Boolean(actor.team_id && targetUser.team_id === actor.team_id); + } + return false; +}; + +const canUpdateUser = (actor, targetUser) => { + if (!actor || !targetUser || !sameTenant(actor, targetUser)) return false; + if (actor.id === targetUser.id) return true; + if (actor.role === 'super_admin' || actor.role === 'admin') return true; + if (actor.role === 'manager') { + return Boolean(actor.team_id && targetUser.team_id === actor.team_id && targetUser.role === 'agent'); + } + return false; +}; + +const canManageUserStatus = (actor) => actor.role === 'super_admin' || actor.role === 'admin'; +const canChangeUserEmail = (actor, targetUser) => actor.id === targetUser.id || actor.role === 'super_admin' || actor.role === 'admin'; +const canManageUserRoleOrTeam = (actor) => actor.role === 'super_admin' || actor.role === 'admin'; + +const canReadAttendance = (actor, attendance) => { + if (!actor || !attendance || !sameTenant(actor, attendance)) return false; + if (actor.role === 'super_admin' || actor.role === 'admin') return true; + if (actor.role === 'agent') return attendance.user_id === actor.id; + if (actor.role === 'manager') return Boolean(actor.team_id && attendance.team_id === actor.team_id); + return false; +}; + +module.exports = { + canReadUser, + canUpdateUser, + canManageUserStatus, + canChangeUserEmail, + canManageUserRoleOrTeam, + canReadAttendance, +}; diff --git a/backend/test/accessPolicy.test.js b/backend/test/accessPolicy.test.js new file mode 100644 index 0000000..a3eca4e --- /dev/null +++ b/backend/test/accessPolicy.test.js @@ -0,0 +1,64 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + canReadUser, + canUpdateUser, + canManageUserStatus, + canChangeUserEmail, + canManageUserRoleOrTeam, + canReadAttendance, +} = require('../policies/accessPolicy'); + +const admin = { id: 'u_admin', tenant_id: 'tenant_a', role: 'admin', team_id: null }; +const manager = { id: 'u_manager', tenant_id: 'tenant_a', role: 'manager', team_id: 'team_a' }; +const managerWithoutTeam = { id: 'u_manager_2', tenant_id: 'tenant_a', role: 'manager', team_id: null }; +const agent = { id: 'u_agent', tenant_id: 'tenant_a', role: 'agent', team_id: 'team_a' }; +const otherAgent = { id: 'u_other_agent', tenant_id: 'tenant_a', role: 'agent', team_id: 'team_b' }; +const foreignAgent = { id: 'u_foreign', tenant_id: 'tenant_b', role: 'agent', team_id: 'team_x' }; +const superAdmin = { id: 'u_super', tenant_id: 'system', role: 'super_admin', team_id: null }; + +test('user read policy keeps tenants isolated', () => { + assert.equal(canReadUser(admin, agent), true); + assert.equal(canReadUser(admin, foreignAgent), false); + assert.equal(canReadUser(superAdmin, foreignAgent), true); +}); + +test('agents can only read and update themselves', () => { + assert.equal(canReadUser(agent, agent), true); + assert.equal(canReadUser(agent, otherAgent), false); + assert.equal(canUpdateUser(agent, agent), true); + assert.equal(canUpdateUser(agent, otherAgent), false); +}); + +test('managers can read their team and update only team agents', () => { + assert.equal(canReadUser(manager, agent), true); + assert.equal(canReadUser(manager, otherAgent), false); + assert.equal(canReadUser(managerWithoutTeam, agent), false); + assert.equal(canUpdateUser(manager, agent), true); + assert.equal(canUpdateUser(manager, admin), false); + assert.equal(canUpdateUser(manager, otherAgent), false); +}); + +test('only admins can manage role, team, and status fields', () => { + assert.equal(canManageUserRoleOrTeam(admin), true); + assert.equal(canManageUserRoleOrTeam(manager), false); + assert.equal(canManageUserStatus(admin), true); + assert.equal(canManageUserStatus(manager), false); + assert.equal(canChangeUserEmail(agent, agent), true); + assert.equal(canChangeUserEmail(manager, agent), false); +}); + +test('attendance detail policy matches role boundaries', () => { + const ownAttendance = { id: 'att_1', tenant_id: 'tenant_a', user_id: 'u_agent', team_id: 'team_a' }; + const teamAttendance = { id: 'att_2', tenant_id: 'tenant_a', user_id: 'u_another', team_id: 'team_a' }; + const otherTeamAttendance = { id: 'att_3', tenant_id: 'tenant_a', user_id: 'u_other_agent', team_id: 'team_b' }; + const foreignAttendance = { id: 'att_4', tenant_id: 'tenant_b', user_id: 'u_foreign', team_id: 'team_x' }; + + assert.equal(canReadAttendance(agent, ownAttendance), true); + assert.equal(canReadAttendance(agent, teamAttendance), false); + assert.equal(canReadAttendance(manager, teamAttendance), true); + assert.equal(canReadAttendance(manager, otherTeamAttendance), false); + assert.equal(canReadAttendance(admin, otherTeamAttendance), true); + assert.equal(canReadAttendance(admin, foreignAttendance), false); + assert.equal(canReadAttendance(superAdmin, foreignAttendance), true); +}); diff --git a/backend/test/security.test.js b/backend/test/security.test.js new file mode 100644 index 0000000..9d8f04e --- /dev/null +++ b/backend/test/security.test.js @@ -0,0 +1,22 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { stripEnvQuotes, hashSecret, maskSecret } = require('../utils/security'); + +test('stripEnvQuotes removes leading and trailing double quotes from env values', () => { + assert.equal(stripEnvQuotes('"secret"'), 'secret'); + assert.equal(stripEnvQuotes('secret'), 'secret'); + assert.equal(stripEnvQuotes('"partly'), 'partly'); +}); + +test('hashSecret returns a stable sha256 digest without exposing the secret', () => { + const first = hashSecret('fasto_sk_example'); + const second = hashSecret('fasto_sk_example'); + + assert.equal(first, second); + assert.equal(first.length, 64); + assert.notEqual(first, 'fasto_sk_example'); +}); + +test('maskSecret stores only an id and secret suffix', () => { + assert.equal(maskSecret('rt_123', 'abcdefghijklmnopqrstuvwxyz'), 'masked:rt_123:uvwxyz'); +}); diff --git a/backend/utils/security.js b/backend/utils/security.js new file mode 100644 index 0000000..62792d3 --- /dev/null +++ b/backend/utils/security.js @@ -0,0 +1,11 @@ +const crypto = require('crypto'); + +const stripEnvQuotes = (value = '') => value.replace(/^"|"$/g, ''); +const hashSecret = (value) => crypto.createHash('sha256').update(value).digest('hex'); +const maskSecret = (id, value) => `masked:${id}:${value.slice(-6)}`; + +module.exports = { + stripEnvQuotes, + hashSecret, + maskSecret, +}; diff --git a/package.json b/package.json index 9f6e6a9..2ebf39f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:backend": "npm --prefix backend test" }, "dependencies": { "bcryptjs": "^3.0.3", diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts new file mode 100644 index 0000000..0532828 --- /dev/null +++ b/src/services/apiClient.ts @@ -0,0 +1,93 @@ +// Em produção, usa caminho relativo porque o backend serve o frontend. +// Em desenvolvimento, aponta para o backend local. +export const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api'; + +export const getHeaders = (customToken?: string) => { + const token = customToken || localStorage.getItem('ctms_token'); + if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' }; + + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }; +}; + +let isRefreshing = false; +let refreshSubscribers: ((token: string) => void)[] = []; +let sessionExpiredHandler: (() => void) | null = null; + +export const setSessionExpiredHandler = (handler: () => void) => { + sessionExpiredHandler = handler; +}; + +const onRefreshed = (token: string) => { + refreshSubscribers.forEach(cb => cb(token)); + refreshSubscribers = []; +}; + +const addRefreshSubscriber = (cb: (token: string) => void) => { + refreshSubscribers.push(cb); +}; + +const expireSession = () => { + if (sessionExpiredHandler) { + sessionExpiredHandler(); + return; + } + + localStorage.removeItem('ctms_token'); + localStorage.removeItem('ctms_refresh_token'); + localStorage.removeItem('ctms_user_id'); + localStorage.removeItem('ctms_tenant_id'); +}; + +export const apiFetch = async (url: string, options: RequestInit = {}): Promise => { + let response = await fetch(url, options); + + if (response.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) { + const refreshToken = localStorage.getItem('ctms_refresh_token'); + + if (!refreshToken) { + expireSession(); + return response; + } + + if (isRefreshing) { + return new Promise(resolve => { + addRefreshSubscriber((newToken) => { + options.headers = getHeaders(newToken); + resolve(fetch(url, options)); + }); + }); + } + + isRefreshing = true; + + try { + const refreshResponse = await fetch(`${API_URL}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }) + }); + + if (!refreshResponse.ok) { + throw new Error('Refresh token invalid'); + } + + const data = await refreshResponse.json(); + localStorage.setItem('ctms_token', data.token); + + options.headers = getHeaders(data.token); + response = await fetch(url, options); + + onRefreshed(data.token); + } catch (err) { + console.error("Session expired or refresh failed:", err); + expireSession(); + } finally { + isRefreshing = false; + } + } + + return response; +}; diff --git a/src/services/dataService.ts b/src/services/dataService.ts index 7fe15f9..82e5d47 100644 --- a/src/services/dataService.ts +++ b/src/services/dataService.ts @@ -1,88 +1,6 @@ import { Attendance, DashboardFilter, User } from '../types'; - -// URL do Backend -// Em produção (import.meta.env.PROD), usa caminho relativo '/api' pois o backend serve o frontend -// Em desenvolvimento, aponta para o localhost:3001 -const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api'; - -const getHeaders = (customToken?: string) => { - const token = customToken || localStorage.getItem('ctms_token'); - // Evitar enviar "undefined" ou "null" como strings se o localStorage estiver corrompido - if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' }; - - return { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }; -}; - -// Global flag to prevent multiple simultaneous refresh attempts -let isRefreshing = false; -let refreshSubscribers: ((token: string) => void)[] = []; - -const onRefreshed = (token: string) => { - refreshSubscribers.forEach(cb => cb(token)); - refreshSubscribers = []; -}; - -const addRefreshSubscriber = (cb: (token: string) => void) => { - refreshSubscribers.push(cb); -}; - -export const apiFetch = async (url: string, options: RequestInit = {}): Promise => { - let response = await fetch(url, options); - - // If unauthorized, attempt to refresh the token - if (response.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) { - const refreshToken = localStorage.getItem('ctms_refresh_token'); - - if (!refreshToken) { - logout(); - return response; - } - - if (isRefreshing) { - // If a refresh is already in progress, wait for it to finish and retry - return new Promise(resolve => { - addRefreshSubscriber((newToken) => { - options.headers = getHeaders(newToken); - resolve(fetch(url, options)); - }); - }); - } - - isRefreshing = true; - - try { - const refreshResponse = await fetch(`${API_URL}/auth/refresh`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refreshToken }) - }); - - if (!refreshResponse.ok) { - throw new Error('Refresh token invalid'); - } - - const data = await refreshResponse.json(); - localStorage.setItem('ctms_token', data.token); - - // Retry the original request - options.headers = getHeaders(data.token); - response = await fetch(url, options); - - onRefreshed(data.token); - } catch (err) { - console.error("Session expired or refresh failed:", err); - logout(); - } finally { - isRefreshing = false; - } - } - - return response; -}; +import { API_URL, apiFetch, getHeaders, setSessionExpiredHandler } from './apiClient'; export const getNotifications = async (): Promise => { try { @@ -693,6 +611,8 @@ export const logout = () => { } }; +setSessionExpiredHandler(logout); + export const login = async (credentials: any): Promise => { const response = await fetch(`${API_URL}/auth/login`, { method: 'POST',