Files
VPS 4 Deploy Agent 31022c6a6f feat(rx): multi-tenant por clinica_id + página /clients de clientes ativos
- clinica_id (tenant do scoreodonto, clinicas.id) como eixo de isolamento em
  clinics_devices e images (+índices); clinic_name vira rótulo legível.
- Cadastro de device, socket (machine_token) e uploads (direct + legado) gravam clinica_id.
- Listagens (/images, /patients, /by-patient, /unique-clinics) filtram por clinica_id.
- /clients: lista clientes Windows ativos (inclui autenticados sem client-identify),
  tempo real + polling, exibe a clínica.
- send-image marcado como legado (client real usa Direct Upload).

Inclui trabalho de dev acumulado no working tree (não havia commits desde origin/main).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 16:17:09 +02:00

464 lines
16 KiB
JavaScript

const express = require('express');
const router = express.Router();
const db = require('../database');
const { verifyPassword, generateToken, hashPassword, authenticateToken } = require('../auth');
// ================================================================
// Rate limiter simples em memória — 5 tentativas por IP em 15 min
// ================================================================
const RATE_WINDOW_MS = 15 * 60 * 1000;
const RATE_MAX = 5;
const _loginAttempts = new Map();
function checkLoginRateLimit(ip) {
const now = Date.now();
const attempts = (_loginAttempts.get(ip) || []).filter(t => now - t < RATE_WINDOW_MS);
if (attempts.length >= RATE_MAX) return false;
attempts.push(now);
_loginAttempts.set(ip, attempts);
return true;
}
function clearLoginRateLimit(ip) {
_loginAttempts.delete(ip);
}
// ================================================================
// POST /api/auth/login - Login
// ================================================================
router.post('/login', async (req, res) => {
try {
const ip = req.ip || req.connection.remoteAddress || 'unknown';
if (!checkLoginRateLimit(ip)) {
return res.status(429).json({ error: 'Muitas tentativas. Aguarde 15 minutos.' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
error: 'Usuário e senha são obrigatórios'
});
}
// Buscar usuário
const user = await db.get(
'SELECT * FROM users WHERE username = $1 OR email = $2',
[username, username]
);
if (!user) {
return res.status(401).json({
error: 'Usuário ou senha inválidos'
});
}
// Verificar senha
const validPassword = await verifyPassword(password, user.password_hash);
if (!validPassword) {
return res.status(401).json({
error: 'Usuário ou senha inválidos'
});
}
clearLoginRateLimit(ip);
// Gerar token
const token = generateToken(user);
// Resposta
res.json({
success: true,
token: token,
user: {
id: user.id,
username: user.username,
email: user.email,
clinic_name: user.clinic_name,
pc_name: user.pc_name,
is_admin: user.is_admin
}
});
} catch (error) {
console.error('Erro no login:', error);
res.status(500).json({ error: 'Erro interno do servidor' });
}
});
// ================================================================
// POST /api/auth/verify-client-credentials - Verificar credenciais do client
// ================================================================
router.post('/verify-client-credentials', async (req, res) => {
try {
const { email, password, token } = req.body;
// Obter a API KEY do ambiente ou usar uma padrão (fallback local temporário)
const serverApiKey = process.env.CLIENT_API_KEY || 'rf-dental-secure-key-2026';
if (token) {
if (token === serverApiKey) {
return res.json({ success: true, message: 'API Key válida' });
}
// Verificar se é um machine_token (UUID tem hífen)
if (email && password && token.includes('-')) {
console.log(`[AUTH] Tentando validar machine_token: ${token} para email: ${email}`);
const device = await db.get(`SELECT * FROM clinics_devices WHERE machine_token = $1`, [token]);
if (device && device.email === email && device.is_active) {
const validPassword = await verifyPassword(password, device.password_hash);
console.log(`[AUTH] validPassword=${validPassword}`);
if (validPassword) {
return res.json({ success: true, message: 'Machine Token e Credenciais válidas', clinic: { name: device.clinic_name, pc: device.pc_name } });
}
} else {
console.log(`[AUTH] Falha: device=${!!device}, is_active=${device?.is_active}`);
}
return res.status(401).json({ error: 'Credenciais da clínica ou Token inválidos' });
}
// Se não for a API Key padrão nem machine_token, tentamos verificar se é um JWT
try {
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../auth');
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded) {
return res.json({ success: true, message: 'JWT válido' });
}
} catch (e) {
return res.status(401).json({ error: 'Token ou API Key inválidos' });
}
}
// Fallback se não mandou token mas mandou email/senha (Não é o padrão atual do client, mas por segurança)
if (email && password) {
const user = await db.get('SELECT * FROM users WHERE email = $1 OR username = $2', [email, email]);
if (user) {
const validPassword = await verifyPassword(password, user.password_hash);
if (validPassword) {
return res.json({ success: true, message: 'Credenciais válidas' });
}
}
}
res.status(401).json({ error: 'Credenciais inválidas ou ausentes' });
} catch (error) {
console.error('Erro na validação do client:', error);
res.status(500).json({ error: 'Erro interno do servidor' });
}
});
// ================================================================
// GET /api/auth/verify - Verificar token
// ================================================================
router.get('/verify', async (req, res) => {
try {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Token não fornecido' });
}
const jwt = require('jsonwebtoken');
const decoded = jwt.verify(token, require('../auth').JWT_SECRET);
const user = await db.get('SELECT * FROM users WHERE id = $1', [decoded.id]);
if (!user) {
return res.status(404).json({ error: 'Usuário não encontrado' });
}
res.json({
valid: true,
user: {
id: user.id,
username: user.username,
email: user.email,
clinic_name: user.clinic_name,
pc_name: user.pc_name,
is_admin: user.is_admin
}
});
} catch (error) {
res.status(401).json({ error: 'Token inválido' });
}
});
// ================================================================
// GET /api/auth/me - Retorna dados do perfil do usuário logado
// Usado pela tela "Minha Conta" do mobile
// ================================================================
router.get('/me', authenticateToken, async (req, res) => {
try {
const user = await db.get(
'SELECT id, username, email, clinic_name, pc_name, is_admin, created_at FROM users WHERE id = $1',
[req.user.id]
);
if (!user) {
return res.status(404).json({ error: 'Usuário não encontrado' });
}
res.json({ success: true, user });
} catch (error) {
console.error('Erro ao buscar perfil:', error);
res.status(500).json({ error: 'Erro interno do servidor' });
}
});
// ================================================================
// POST /api/auth/logout - Logout
// ================================================================
router.post('/logout', (req, res) => {
res.json({
success: true,
message: 'Logout realizado com sucesso'
});
});
// ================================================================
// POST /api/auth/create-user - Criar usuário (admin only)
// ================================================================
router.post('/create-user', authenticateToken, async (req, res) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({ error: 'Apenas administradores podem criar usuários' });
}
const { username, email, password, clinic_name, pc_name, is_admin } = req.body;
if (!username || !email || !password) {
return res.status(400).json({
error: 'Username, email e senha são obrigatórios'
});
}
// Verificar se usuário já existe
const existing = await db.get(
'SELECT * FROM users WHERE username = $1 OR email = $2',
[username, email]
);
if (existing) {
return res.status(409).json({
error: 'Usuário ou email já cadastrado'
});
}
// Hash da senha
const passwordHash = await hashPassword(password);
// Criar usuário
const result = await db.run(
`INSERT INTO users (username, email, password_hash, clinic_name, pc_name, is_admin, created_at)
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP) RETURNING id`,
[username, email, passwordHash, clinic_name || null, pc_name || null, !!is_admin]
);
res.json({
success: true,
message: 'Usuário criado com sucesso',
userId: result.lastID
});
} catch (error) {
console.error('Erro ao criar usuário:', error);
res.status(500).json({ error: 'Erro interno do servidor' });
}
});
// ================================================================
// GERENCIAMENTO DE USUÁRIOS/CLÍNICAS (ADMIN)
// ================================================================
router.get('/users', authenticateToken, async (req, res) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({ error: 'Acesso negado' });
}
const users = await db.all('SELECT id, username, email, clinic_name, pc_name, is_admin, created_at FROM users ORDER BY created_at DESC');
res.json({ success: true, users });
} catch (error) {
res.status(500).json({ error: 'Erro ao buscar usuários' });
}
});
router.delete('/users/:id', authenticateToken, async (req, res) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({ error: 'Acesso negado' });
}
const userId = req.params.id;
if (parseInt(userId) === req.user.id) {
return res.status(400).json({ error: 'Não é possível excluir o próprio usuário admin' });
}
await db.run('DELETE FROM users WHERE id = $1', [userId]);
res.json({ success: true, message: 'Usuário excluído com sucesso' });
} catch (error) {
res.status(500).json({ error: 'Erro ao excluir usuário' });
}
});
router.post('/users/:id/token', authenticateToken, async (req, res) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({ error: 'Acesso negado' });
}
const user = await db.get('SELECT * FROM users WHERE id = $1', [req.params.id]);
if (!user) return res.status(404).json({ error: 'Usuário não encontrado' });
// Gera um token com duração longa para uso no desktop (ex: 3650 dias = ~10 anos)
const token = generateToken(user, '3650d');
res.json({ success: true, token });
} catch (error) {
res.status(500).json({ error: 'Erro ao gerar token' });
}
});
// ================================================================
// PUT /api/auth/update-credentials - Alterar usuário/senha
// ================================================================
router.put('/update-credentials', authenticateToken, async (req, res) => {
try {
const { currentPassword, newUsername, newEmail, newPassword } = req.body;
const userId = req.user.id;
if (!currentPassword) {
return res.status(400).json({ error: 'A senha atual é obrigatória' });
}
if (!newUsername && !newEmail && !newPassword) {
return res.status(400).json({ error: 'Nenhum dado novo foi fornecido para atualização' });
}
// Buscar usuário atual para verificar a senha
const user = await db.get('SELECT * FROM users WHERE id = $1', [userId]);
if (!user) {
return res.status(404).json({ error: 'Usuário não encontrado' });
}
// Verificar senha atual
const validPassword = await verifyPassword(currentPassword, user.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'A senha atual está incorreta' });
}
let finalUsername = user.username;
let finalEmail = user.email;
let finalPasswordHash = user.password_hash;
// Verificar e aplicar novo username se fornecido
if (newUsername && newUsername !== user.username) {
const existing = await db.get('SELECT * FROM users WHERE username = $1 AND id != $2', [newUsername, userId]);
if (existing) {
return res.status(409).json({ error: 'Este nome de usuário já está em uso' });
}
finalUsername = newUsername;
}
// Verificar e aplicar novo e-mail se fornecido
if (newEmail && newEmail !== user.email) {
const existing = await db.get('SELECT * FROM users WHERE email = $1 AND id != $2', [newEmail, userId]);
if (existing) {
return res.status(409).json({ error: 'Este e-mail já está em uso' });
}
finalEmail = newEmail;
}
// Verificar e aplicar nova senha se fornecido
if (newPassword) {
if (newPassword.length < 4) {
return res.status(400).json({ error: 'A nova senha deve ter pelo menos 4 caracteres' });
}
finalPasswordHash = await hashPassword(newPassword);
}
// Atualizar no banco de dados
await db.run(
'UPDATE users SET username = $1, email = $2, password_hash = $3 WHERE id = $4',
[finalUsername, finalEmail, finalPasswordHash, userId]
);
// Gerar novo token pois o username ou e-mail podem ter mudado
const updatedUser = { id: userId, username: finalUsername, email: finalEmail, is_admin: user.is_admin };
const newToken = generateToken(updatedUser);
res.json({
success: true,
message: 'Credenciais atualizadas com sucesso',
token: newToken,
user: updatedUser
});
} catch (error) {
console.error('Erro ao atualizar credenciais:', error);
res.status(500).json({ error: 'Erro interno do servidor' });
}
});
// ================================================================
// API TOKENS ESTÁTICOS
// ================================================================
router.get('/tokens', authenticateToken, async (req, res) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({ error: 'Acesso negado' });
}
const tokens = await db.all('SELECT id, name, token, created_at, last_used_at FROM api_tokens ORDER BY created_at DESC');
res.json({ success: true, tokens });
} catch (error) {
console.error('Erro ao listar tokens:', error);
res.status(500).json({ error: 'Erro ao buscar tokens' });
}
});
router.post('/tokens', authenticateToken, async (req, res) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({ error: 'Acesso negado' });
}
const { name } = req.body;
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'O nome da integração é obrigatório' });
}
const crypto = require('crypto');
const token = 'api_' + crypto.randomBytes(24).toString('hex');
await db.run(
'INSERT INTO api_tokens (name, token, created_at) VALUES ($1, $2, CURRENT_TIMESTAMP)',
[name, token]
);
res.json({ success: true, token, name });
} catch (error) {
console.error('Erro ao gerar token:', error);
res.status(500).json({ error: 'Erro ao gerar token' });
}
});
router.delete('/tokens/:id', authenticateToken, async (req, res) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({ error: 'Acesso negado' });
}
await db.run('DELETE FROM api_tokens WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar token:', error);
res.status(500).json({ error: 'Erro ao excluir token' });
}
});
module.exports = router;