From 7b39c57008f6d46fc5d94aa726a83b54366cd61f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 29 May 2026 10:49:10 -0300 Subject: [PATCH] Extract backend auth routes --- Dockerfile | 1 + backend/index.js | 275 +---------------------------------- backend/routes/authRoutes.js | 262 +++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 273 deletions(-) create mode 100644 backend/routes/authRoutes.js diff --git a/Dockerfile b/Dockerfile index 1a94c07..a90cb55 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,7 @@ COPY backend/db.js ./db.js COPY backend/config ./config COPY backend/middleware ./middleware COPY backend/policies ./policies +COPY backend/routes ./routes COPY backend/services ./services COPY backend/utils ./utils diff --git a/backend/index.js b/backend/index.js index b8dd99c..686e4b6 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,8 +1,6 @@ require('dotenv').config(); const express = require('express'); const path = require('path'); -const bcrypt = require('bcryptjs'); -const jwt = require('jsonwebtoken'); const crypto = require('crypto'); const multer = require('multer'); const { v4: uuidv4 } = require('uuid'); @@ -13,6 +11,7 @@ const transporter = require('./services/mailer'); const { createCorsMiddleware } = require('./config/cors'); const { allowedOrigins, getBaseUrl, getStartupBaseUrl, isProduction, jwtSecret: JWT_SECRET, port: PORT } = require('./config/runtime'); const { authenticateToken, requireRole } = require('./middleware/auth'); +const { createAuthRouter } = require('./routes/authRoutes'); const { canReadUser, canUpdateUser, @@ -76,277 +75,7 @@ apiRouter.use(authenticateToken); // --- Auth Routes --- -// Register -apiRouter.post('/auth/register', async (req, res) => { - const { name, email, password, organizationName } = req.body; - try { - const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]); - if (existing.length > 0) return res.status(400).json({ error: 'E-mail já cadastrado.' }); - - const passwordHash = await bcrypt.hash(password, 10); - const verificationCode = Math.floor(100000 + Math.random() * 900000).toString(); - const expiresAt = new Date(Date.now() + 15 * 60 * 1000); - - await pool.query( - 'INSERT INTO pending_registrations (email, password_hash, full_name, organization_name, verification_code, expires_at) VALUES (?, ?, ?, ?, ?, ?)', - [email, passwordHash, name, organizationName, verificationCode, expiresAt] - ); - - const mailOptions = { - from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`, - to: email, - subject: 'Seu código de verificação Fasto', - text: `Olá ${name}, seu código de verificação é: ${verificationCode}`, - html: ` -
-

Bem-vindo ao Fasto!

-

Seu código: ${verificationCode}

-
` - }; - await transporter.sendMail(mailOptions); - res.json({ message: 'Código enviado.' }); - } catch (error) { - console.error('Register error:', error); - res.status(500).json({ error: error.message }); - } -}); - -// Verify -apiRouter.post('/auth/verify', async (req, res) => { - const { email, code } = req.body; - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - const [pending] = await connection.query( - 'SELECT * FROM pending_registrations WHERE email = ? AND verification_code = ? AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1', - [email, code] - ); - if (pending.length === 0) return res.status(400).json({ error: 'Código inválido.' }); - - const data = pending[0]; - const tenantId = `tenant_${crypto.randomUUID().split('-')[0]}`; - const userId = `u_${crypto.randomUUID().split('-')[0]}`; - - await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)', - [tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]); - await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)', - [userId, tenantId, data.full_name, email, data.password_hash, 'admin']); await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]); - - await connection.commit(); - res.json({ message: 'Sucesso.' }); - } catch (error) { - await connection.rollback(); - res.status(500).json({ error: error.message }); - } finally { - connection.release(); - } -}); - -// Login -apiRouter.post('/auth/login', async (req, res) => { - const { email, password } = req.body; - try { - const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]); - if (users.length === 0) return res.status(401).json({ error: 'Credenciais inválidas.' }); - - const user = users[0]; - - // Verificar se o usuário está ativo - if (user.status !== 'active') { - return res.status(403).json({ error: 'Sua conta está inativa. Contate o administrador.' }); - } - - const valid = await bcrypt.compare(password, user.password_hash); - if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' }); - - // Generate Access Token (short-lived) - const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '15m' }); - - // Generate Refresh Token (long-lived) - const refreshToken = crypto.randomBytes(40).toString('hex'); - const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`; - - // Store Refresh Token in database (expires in 30 days) - await pool.query( - '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({ - token, - refreshToken, - user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } - }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -// Refresh Token -apiRouter.post('/auth/refresh', async (req, res) => { - const { refreshToken } = req.body; - if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' }); - - 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_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_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_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_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]); - - // Issue a new short-lived access token - const newAccessToken = jwt.sign( - { id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, - JWT_SECRET, - { expiresIn: '15m' } - ); - - res.json({ token: newAccessToken }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -// Logout (Revoke Refresh Token) -apiRouter.post('/auth/logout', async (req, res) => { - const { refreshToken } = req.body; - try { - if (refreshToken) { - await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]); - } - res.json({ message: 'Logout bem-sucedido.' }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -// God Mode (Impersonate Tenant) -apiRouter.post('/impersonate/:tenantId', requireRole(['super_admin']), async (req, res) => { - try { - // Buscar o primeiro admin (ou qualquer usuário) do tenant para assumir a identidade - const [users] = await pool.query("SELECT * FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1", [req.params.tenantId]); - - if (users.length === 0) { - return res.status(404).json({ error: 'Nenhum administrador encontrado nesta organização para assumir a identidade.' }); - } - - const user = users[0]; - - if (user.status !== 'active') { - return res.status(403).json({ error: 'A conta do admin desta organização está inativa.' }); - } - - // Gerar um token JWT como se fôssemos o admin do tenant - const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '2h' }); - res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -// Forgot Password -apiRouter.post('/auth/forgot-password', async (req, res) => { - const { email } = req.body; - try { - const [users] = await pool.query('SELECT name FROM users WHERE email = ?', [email]); - if (users.length > 0) { - const token = crypto.randomBytes(32).toString('hex'); - await pool.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', [email, token]); - const link = `${getBaseUrl(req)}/#/reset-password?token=${token}`; - - const mailOptions = { from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`, - to: email, - subject: 'Recuperação de Senha - Fasto', - html: ` -
-

Olá, ${users[0].name}!

-

Você solicitou a recuperação de senha da sua conta no Fasto.

-

Clique no botão abaixo para criar uma nova senha:

-
- Redefinir Minha Senha -
-

Este link expira em 15 minutos. Se você não solicitou isso, pode ignorar este e-mail.

-
-

Desenvolvido por Blyzer

-
-
- ` - }; - await transporter.sendMail(mailOptions); - } - res.json({ message: 'Se o e-mail existir, enviamos as instruções.' }); - } catch (error) { - console.error('Forgot password error:', error); - res.status(500).json({ error: error.message }); - } -}); - -// Reset Password -apiRouter.post('/auth/reset-password', async (req, res) => { - const { token, password, name } = req.body; - try { - const [resets] = await pool.query('SELECT email FROM password_resets WHERE token = ? AND expires_at > NOW()', [token]); - if (resets.length === 0) return res.status(400).json({ error: 'Token inválido ou expirado.' }); - - const hash = await bcrypt.hash(password, 10); - - if (name) { - // If a name is provided (like in the initial admin setup flow), update it along with the password - await pool.query('UPDATE users SET password_hash = ?, name = ? WHERE email = ?', [hash, name, resets[0].email]); - - // Notify managers/admins of the same tenant - const [u] = await pool.query('SELECT id, name, tenant_id, role, team_id FROM users WHERE email = ?', [resets[0].email]); - if (u.length > 0) { - const user = u[0]; - const [notifiable] = await pool.query( - "SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager', 'super_admin') AND id != ?", - [user.tenant_id, user.id] - ); - for (const n of notifiable) { - await pool.query( - 'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)', - [crypto.randomUUID(), n.id, 'info', 'Novo Membro Ativo', `${name} concluiu o cadastro e já pode acessar o sistema.`, `/users/${user.id}`] - ); - } - - // If the new user is an admin, notify super_admins too - if (user.role === 'admin') { - const [superAdmins] = await pool.query("SELECT id FROM users WHERE role = 'super_admin'"); - for (const sa of superAdmins) { - await pool.query( - 'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)', - [crypto.randomUUID(), sa.id, 'success', 'Admin Ativo', `O admin ${name} da organização configurou sua conta.`, `/super-admin`] - ); - } - } - } - } else { - // Standard password reset, just update the hash - await pool.query('UPDATE users SET password_hash = ? WHERE email = ?', [hash, resets[0].email]); - } - - await pool.query('DELETE FROM password_resets WHERE email = ?', [resets[0].email]); - res.json({ message: 'Senha e perfil atualizados com sucesso.' }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); +apiRouter.use(createAuthRouter({ pool, transporter, getBaseUrl, jwtSecret: JWT_SECRET })); // --- User Routes --- apiRouter.get('/users', async (req, res) => { diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js new file mode 100644 index 0000000..f97ce94 --- /dev/null +++ b/backend/routes/authRoutes.js @@ -0,0 +1,262 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); +const { hashSecret, maskSecret } = require('../utils/security'); +const { requireRole } = require('../middleware/auth'); + +const createAuthRouter = ({ pool, transporter, getBaseUrl, jwtSecret }) => { + const router = express.Router(); + + router.post('/auth/register', async (req, res) => { + const { name, email, password, organizationName } = req.body; + try { + const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]); + if (existing.length > 0) return res.status(400).json({ error: 'E-mail já cadastrado.' }); + + const passwordHash = await bcrypt.hash(password, 10); + const verificationCode = Math.floor(100000 + Math.random() * 900000).toString(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + + await pool.query( + 'INSERT INTO pending_registrations (email, password_hash, full_name, organization_name, verification_code, expires_at) VALUES (?, ?, ?, ?, ?, ?)', + [email, passwordHash, name, organizationName, verificationCode, expiresAt] + ); + + await transporter.sendMail({ + from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`, + to: email, + subject: 'Seu código de verificação Fasto', + text: `Olá ${name}, seu código de verificação é: ${verificationCode}`, + html: ` +
+

Bem-vindo ao Fasto!

+

Seu código: ${verificationCode}

+
` + }); + res.json({ message: 'Código enviado.' }); + } catch (error) { + console.error('Register error:', error); + res.status(500).json({ error: error.message }); + } + }); + + router.post('/auth/verify', async (req, res) => { + const { email, code } = req.body; + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const [pending] = await connection.query( + 'SELECT * FROM pending_registrations WHERE email = ? AND verification_code = ? AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1', + [email, code] + ); + if (pending.length === 0) return res.status(400).json({ error: 'Código inválido.' }); + + const data = pending[0]; + const tenantId = `tenant_${crypto.randomUUID().split('-')[0]}`; + const userId = `u_${crypto.randomUUID().split('-')[0]}`; + + await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)', + [tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]); + await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)', + [userId, tenantId, data.full_name, email, data.password_hash, 'admin']); + await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]); + + await connection.commit(); + res.json({ message: 'Sucesso.' }); + } catch (error) { + await connection.rollback(); + res.status(500).json({ error: error.message }); + } finally { + connection.release(); + } + }); + + router.post('/auth/login', async (req, res) => { + const { email, password } = req.body; + try { + const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]); + if (users.length === 0) return res.status(401).json({ error: 'Credenciais inválidas.' }); + + const user = users[0]; + if (user.status !== 'active') { + return res.status(403).json({ error: 'Sua conta está inativa. Contate o administrador.' }); + } + + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' }); + + const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, jwtSecret, { expiresIn: '15m' }); + const refreshToken = crypto.randomBytes(40).toString('hex'); + const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`; + + await pool.query( + '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({ + token, + refreshToken, + user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + router.post('/auth/refresh', async (req, res) => { + const { refreshToken } = req.body; + if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' }); + + try { + 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_hash = ? OR r.token = ?) AND r.expires_at > NOW()', + [hashSecret(refreshToken), refreshToken] + ); + + if (tokens.length === 0) { + 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_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]); + return res.status(403).json({ error: 'Sua conta está inativa.' }); + } + + await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]); + + const newAccessToken = jwt.sign( + { id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, + jwtSecret, + { expiresIn: '15m' } + ); + + res.json({ token: newAccessToken }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + router.post('/auth/logout', async (req, res) => { + const { refreshToken } = req.body; + try { + if (refreshToken) { + await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]); + } + res.json({ message: 'Logout bem-sucedido.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + router.post('/impersonate/:tenantId', requireRole(['super_admin']), async (req, res) => { + try { + const [users] = await pool.query("SELECT * FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1", [req.params.tenantId]); + + if (users.length === 0) { + return res.status(404).json({ error: 'Nenhum administrador encontrado nesta organização para assumir a identidade.' }); + } + + const user = users[0]; + + if (user.status !== 'active') { + return res.status(403).json({ error: 'A conta do admin desta organização está inativa.' }); + } + + const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, jwtSecret, { expiresIn: '2h' }); + res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + router.post('/auth/forgot-password', async (req, res) => { + const { email } = req.body; + try { + const [users] = await pool.query('SELECT name FROM users WHERE email = ?', [email]); + if (users.length > 0) { + const token = crypto.randomBytes(32).toString('hex'); + await pool.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', [email, token]); + const link = `${getBaseUrl(req)}/#/reset-password?token=${token}`; + + await transporter.sendMail({ + from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`, + to: email, + subject: 'Recuperação de Senha - Fasto', + html: ` +
+

Olá, ${users[0].name}!

+

Você solicitou a recuperação de senha da sua conta no Fasto.

+

Clique no botão abaixo para criar uma nova senha:

+
+ Redefinir Minha Senha +
+

Este link expira em 15 minutos. Se você não solicitou isso, pode ignorar este e-mail.

+
+

Desenvolvido por Blyzer

+
+
+ ` + }); + } + res.json({ message: 'Se o e-mail existir, enviamos as instruções.' }); + } catch (error) { + console.error('Forgot password error:', error); + res.status(500).json({ error: error.message }); + } + }); + + router.post('/auth/reset-password', async (req, res) => { + const { token, password, name } = req.body; + try { + const [resets] = await pool.query('SELECT email FROM password_resets WHERE token = ? AND expires_at > NOW()', [token]); + if (resets.length === 0) return res.status(400).json({ error: 'Token inválido ou expirado.' }); + + const hash = await bcrypt.hash(password, 10); + + if (name) { + await pool.query('UPDATE users SET password_hash = ?, name = ? WHERE email = ?', [hash, name, resets[0].email]); + + const [u] = await pool.query('SELECT id, name, tenant_id, role, team_id FROM users WHERE email = ?', [resets[0].email]); + if (u.length > 0) { + const user = u[0]; + const [notifiable] = await pool.query( + "SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager', 'super_admin') AND id != ?", + [user.tenant_id, user.id] + ); + for (const n of notifiable) { + await pool.query( + 'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)', + [crypto.randomUUID(), n.id, 'info', 'Novo Membro Ativo', `${name} concluiu o cadastro e já pode acessar o sistema.`, `/users/${user.id}`] + ); + } + + if (user.role === 'admin') { + const [superAdmins] = await pool.query("SELECT id FROM users WHERE role = 'super_admin'"); + for (const sa of superAdmins) { + await pool.query( + 'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)', + [crypto.randomUUID(), sa.id, 'success', 'Admin Ativo', `O admin ${name} da organização configurou sua conta.`, `/super-admin`] + ); + } + } + } + } else { + await pool.query('UPDATE users SET password_hash = ? WHERE email = ?', [hash, resets[0].email]); + } + + await pool.query('DELETE FROM password_resets WHERE email = ?', [resets[0].email]); + res.json({ message: 'Senha e perfil atualizados com sucesso.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + return router; +}; + +module.exports = { createAuthRouter };