Harden auth and clean project setup

This commit is contained in:
Cauê Faleiros
2026-05-28 14:51:00 -03:00
parent be8f056434
commit 2c102eb2dd
9 changed files with 284 additions and 80 deletions

View File

@@ -13,21 +13,32 @@ const pool = require('./db');
const app = express();
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'fasto_super_secret_key';
const isProduction = process.env.NODE_ENV === 'production';
const JWT_SECRET = process.env.JWT_SECRET || (isProduction ? null : 'fasto_dev_secret_change_me');
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({
host: process.env.SMTP_HOST || 'mail.blyzer.com.br',
port: parseInt(process.env.SMTP_PORT) || 587,
secure: false, // false para 587 (STARTTLS)
auth: {
user: (process.env.SMTP_USER || 'nao-responda@blyzer.com.br').replace(/^"|"$/g, ''),
pass: (process.env.SMTP_PASS || 'Compor@2017#').replace(/^"|"$/g, ''),
user: stripEnvQuotes(process.env.SMTP_USER || 'nao-responda@blyzer.com.br'),
pass: stripEnvQuotes(process.env.SMTP_PASS || ''),
},
tls: {
ciphers: 'SSLv3',
rejectUnauthorized: false
},
debug: true,
logger: true
debug: process.env.SMTP_DEBUG === 'true',
logger: process.env.SMTP_DEBUG === 'true'
});
// Helper para obter a URL base
@@ -53,7 +64,20 @@ const getStartupBaseUrl = () => {
return 'http://localhost:3001';
};
app.use(cors());
const allowedOrigins = (process.env.CORS_ORIGIN || '')
.split(',')
.map(origin => origin.trim())
.filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
if (!origin || !isProduction || allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Origem não permitida pelo CORS.'));
},
credentials: true
}));
app.use(express.json());
// Logger de Requisições
@@ -111,7 +135,10 @@ const authenticateToken = async (req, res, next) => {
if (authHeader && authHeader.startsWith('Bearer fasto_sk_')) {
const apiKey = authHeader.split(' ')[1];
try {
const [keys] = await pool.query('SELECT * FROM api_keys WHERE secret_key = ?', [apiKey]);
const [keys] = await pool.query(
'SELECT * FROM api_keys WHERE secret_hash = ? OR secret_key = ?',
[hashSecret(apiKey), apiKey]
);
if (keys.length === 0) return res.status(401).json({ error: 'Chave de API inválida.' });
// Update last used timestamp
@@ -247,8 +274,8 @@ apiRouter.post('/auth/login', async (req, res) => {
// Store Refresh Token in database (expires in 30 days)
await pool.query(
'INSERT INTO refresh_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[refreshId, user.id, refreshToken]
'INSERT INTO refresh_tokens (id, user_id, token, token_hash, expires_at) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[refreshId, user.id, maskSecret(refreshId, refreshToken), hashSecret(refreshToken)]
);
res.json({
@@ -269,25 +296,25 @@ apiRouter.post('/auth/refresh', async (req, res) => {
try {
// Verifies if the token exists and hasn't expired
const [tokens] = await pool.query(
'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE r.token = ? AND r.expires_at > NOW()',
[refreshToken]
'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE (r.token_hash = ? OR r.token = ?) AND r.expires_at > NOW()',
[hashSecret(refreshToken), refreshToken]
);
if (tokens.length === 0) {
// If invalid, optionally delete the bad token if it's just expired
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' });
}
const user = tokens[0];
if (user.status !== 'active') {
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
return res.status(403).json({ error: 'Sua conta está inativa.' });
}
// Sliding Expiration: Extend the refresh token's life by another 30 days
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token = ?', [refreshToken]);
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
// Issue a new short-lived access token
const newAccessToken = jwt.sign(
@@ -307,7 +334,7 @@ apiRouter.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.body;
try {
if (refreshToken) {
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
}
res.json({ message: 'Logout bem-sucedido.' });
} catch (error) {
@@ -433,7 +460,7 @@ apiRouter.get('/users', async (req, res) => {
const { tenantId } = req.query;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
let q = 'SELECT * FROM users';
let q = `SELECT ${USER_PUBLIC_FIELDS} FROM users`;
const params = [];
if (effectiveTenantId && effectiveTenantId !== 'all') {
q += ' WHERE tenant_id = ?';
@@ -458,13 +485,23 @@ apiRouter.get('/users', async (req, res) => {
apiRouter.get('/users/:idOrSlug', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
const [rows] = await pool.query(`SELECT ${USER_PUBLIC_FIELDS} FROM users WHERE id = ? OR slug = ?`, [req.params.idOrSlug, req.params.idOrSlug]);
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.' });
}
res.json(rows[0]);
} catch (error) {
console.error('Error in GET /users/:idOrSlug:', error);
@@ -482,6 +519,7 @@ apiRouter.post('/users', requireRole(['admin', 'manager', 'super_admin']), async
let finalTeamId = team_id || null;
if (req.user.role === 'manager') {
if (!req.user.team_id) return res.status(403).json({ error: 'Gerente sem time não pode criar membros.' });
finalRole = 'agent'; // Force manager creations to be agents
finalTeamId = req.user.team_id; // Force assignment to manager's team
}
@@ -557,8 +595,14 @@ apiRouter.put('/users/:id', async (req, res) => {
// 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;
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
const finalEmail = email !== undefined ? email : existing[0].email;
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);
if (finalEmail !== existing[0].email) {
@@ -1076,13 +1120,24 @@ apiRouter.get('/attendances', async (req, res) => {
apiRouter.get('/attendances/:id', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM attendances WHERE id = ?', [req.params.id]);
const [rows] = await pool.query(
'SELECT a.*, u.team_id FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.id = ?',
[req.params.id]
);
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.' });
}
const r = rows[0];
res.json({
...r,
@@ -1100,7 +1155,10 @@ apiRouter.get('/api-keys', requireRole(['admin', 'super_admin']), async (req, re
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]);
const [rows] = await pool.query('SELECT id, name, created_at, last_used_at, CONCAT(SUBSTRING(secret_key, 1, 14), "...") as masked_key FROM api_keys WHERE tenant_id = ?', [effectiveTenantId]);
const [rows] = await pool.query(
'SELECT id, name, created_at, last_used_at, CASE WHEN secret_key LIKE "masked:%" THEN CONCAT("fasto_sk_", RIGHT(secret_key, 6), "...") ELSE CONCAT(SUBSTRING(secret_key, 1, 14), "...") END as masked_key FROM api_keys WHERE tenant_id = ?',
[effectiveTenantId]
);
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -1116,8 +1174,8 @@ apiRouter.post('/api-keys', requireRole(['admin', 'super_admin']), async (req, r
const secretKey = `fasto_sk_${crypto.randomBytes(32).toString('hex')}`;
await pool.query(
'INSERT INTO api_keys (id, tenant_id, name, secret_key) VALUES (?, ?, ?, ?)',
[id, effectiveTenantId, name || 'Nova Integração API', secretKey]
'INSERT INTO api_keys (id, tenant_id, name, secret_key, secret_hash) VALUES (?, ?, ?, ?, ?)',
[id, effectiveTenantId, name || 'Nova Integração API', maskSecret(id, secretKey), hashSecret(secretKey)]
);
// We only return the actual secret key ONCE during creation.
@@ -1639,28 +1697,56 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
tenant_id varchar(36) NOT NULL,
name varchar(255) NOT NULL,
secret_key varchar(255) NOT NULL,
secret_hash varchar(64) DEFAULT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at timestamp NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY secret_key (secret_key),
UNIQUE KEY secret_hash (secret_hash),
KEY tenant_id (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
try {
await connection.query("ALTER TABLE api_keys ADD COLUMN secret_hash VARCHAR(64) DEFAULT NULL");
} catch (err) {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (api_keys.secret_hash):', err.message);
}
try {
await connection.query("ALTER TABLE api_keys ADD UNIQUE KEY secret_hash (secret_hash)");
} catch (err) {
if (err.code !== 'ER_DUP_KEYNAME') console.log('Schema update note (api_keys.secret_hash index):', err.message);
}
// Create refresh_tokens table for persistent sessions
await connection.query(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id varchar(36) NOT NULL,
user_id varchar(36) NOT NULL,
token varchar(255) NOT NULL,
token_hash varchar(64) DEFAULT NULL,
expires_at timestamp NOT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY token (token),
UNIQUE KEY token_hash (token_hash),
KEY user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
try {
await connection.query("ALTER TABLE refresh_tokens ADD COLUMN token_hash VARCHAR(64) DEFAULT NULL");
} catch (err) {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (refresh_tokens.token_hash):', err.message);
}
try {
await connection.query("ALTER TABLE refresh_tokens ADD UNIQUE KEY token_hash (token_hash)");
} catch (err) {
if (err.code !== 'ER_DUP_KEYNAME') console.log('Schema update note (refresh_tokens.token_hash index):', err.message);
}
// Add funnel_id to teams
try {
await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL");