Extract backend auth routes

This commit is contained in:
Cauê Faleiros
2026-05-29 10:49:10 -03:00
parent c9375fb26e
commit 7b39c57008
3 changed files with 265 additions and 273 deletions

View File

@@ -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: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
<h2 style="color: #0f172a;">Bem-vindo ao Fasto!</h2>
<p style="color: #475569;">Seu código: <strong>${verificationCode}</strong></p>
</div>`
};
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: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
<h2 style="color: #0f172a;">Olá, ${users[0].name}!</h2>
<p style="color: #475569;">Você solicitou a recuperação de senha da sua conta no Fasto.</p>
<p style="color: #475569;">Clique no botão abaixo para criar uma nova senha:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${link}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Redefinir Minha Senha</a>
</div>
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos. Se você não solicitou isso, pode ignorar este e-mail.</p>
<div style="border-top: 1px solid #f1f5f9; margin-top: 20px; padding-top: 20px; text-align: center;">
<p style="font-size: 12px; color: #94a3b8;">Desenvolvido por <a href="https://blyzer.com.br" style="color: #3b82f6; text-decoration: none;">Blyzer</a></p>
</div>
</div>
`
};
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) => {