feat: implement security enhancements for Jitsi integration, including JWT token generation and automatic blocking of detected attacks, improving system resilience and user authentication
This commit is contained in:
@@ -22,7 +22,7 @@ type ConvexCtx = QueryCtx | MutationCtx | ActionCtx;
|
||||
* Função helper para converter contexto do Convex para GenericCtx do better-auth
|
||||
* Os tipos são estruturalmente compatíveis, apenas há diferença nas definições de tipo
|
||||
*/
|
||||
function toGenericCtx(ctx: ConvexCtx): GenericCtx<DataModel> {
|
||||
export function toGenericCtx(ctx: ConvexCtx): GenericCtx<DataModel> {
|
||||
// Os tipos são estruturalmente idênticos, apenas há diferença nas definições de tipo
|
||||
// entre a versão do Convex usada pelo projeto e a usada pelo @convex-dev/better-auth
|
||||
return ctx as unknown as GenericCtx<DataModel>;
|
||||
|
||||
@@ -214,3 +214,19 @@ export async function decryptSMTPPassword(encryptedPassword: string): Promise<st
|
||||
throw new Error('Falha ao descriptografar senha SMTP');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Criptografa um JWT secret usando AES-GCM (mesma lógica de SMTP password)
|
||||
*/
|
||||
export async function encryptJWTSecret(secret: string): Promise<string> {
|
||||
// Reutilizar a mesma função de criptografia de SMTP password
|
||||
return encryptSMTPPassword(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptografa um JWT secret usando AES-GCM (mesma lógica de SMTP password)
|
||||
*/
|
||||
export async function decryptJWTSecret(encryptedSecret: string): Promise<string> {
|
||||
// Reutilizar a mesma função de descriptografia de SMTP password
|
||||
return decryptSMTPPassword(encryptedSecret);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,162 @@ import { v } from 'convex/values';
|
||||
import { mutation, query, action, internalMutation } from './_generated/server';
|
||||
import { registrarAtividade } from './logsAtividades';
|
||||
import { api, internal } from './_generated/api';
|
||||
import { getCurrentUserFunction, authComponent, toGenericCtx } from './auth';
|
||||
import { encryptJWTSecret, decryptJWTSecret } from './auth/utils';
|
||||
import type { Id } from './_generated/dataModel';
|
||||
|
||||
/**
|
||||
* Obter configuração de Jitsi ativa
|
||||
* Codificar base64url (sem padding, com substituição de caracteres)
|
||||
*/
|
||||
function base64UrlEncode(data: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...data));
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar token JWT para Jitsi (função helper interna)
|
||||
* Especificação: https://github.com/jitsi/lib-jitsi-meet/blob/master/doc/tokens.md
|
||||
*/
|
||||
async function criarTokenJWTJitsi(
|
||||
secret: string,
|
||||
payload: {
|
||||
iss: string; // Issuer (appId)
|
||||
aud: string; // Audience (domínio do servidor)
|
||||
sub: string; // Subject (ID do usuário ou email)
|
||||
room: string; // Nome da sala
|
||||
moderator: boolean; // Se é moderador
|
||||
exp?: number; // Expiração (timestamp, padrão: 1 hora)
|
||||
nbf?: number; // Not before (timestamp, padrão: agora)
|
||||
}
|
||||
): Promise<string> {
|
||||
// Header JWT
|
||||
const header = {
|
||||
alg: 'HS256',
|
||||
typ: 'JWT'
|
||||
};
|
||||
|
||||
// Payload com valores padrão
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const jwtPayload = {
|
||||
...payload,
|
||||
exp: payload.exp || now + 3600, // 1 hora por padrão
|
||||
nbf: payload.nbf || now
|
||||
};
|
||||
|
||||
// Codificar header e payload
|
||||
const headerEncoded = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header)));
|
||||
const payloadEncoded = base64UrlEncode(new TextEncoder().encode(JSON.stringify(jwtPayload)));
|
||||
|
||||
// Criar assinatura
|
||||
const message = `${headerEncoded}.${payloadEncoded}`;
|
||||
const keyData = new TextEncoder().encode(secret);
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
|
||||
const signatureEncoded = base64UrlEncode(new Uint8Array(signature));
|
||||
|
||||
// Retornar token completo
|
||||
return `${headerEncoded}.${payloadEncoded}.${signatureEncoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper para verificar se usuário tem permissão de TI (TI_MASTER, TI_USUARIO ou ADMIN)
|
||||
*/
|
||||
async function verificarPermissaoTI(
|
||||
ctx: Parameters<typeof getCurrentUserFunction>[0],
|
||||
usuarioId: Id<'usuarios'>
|
||||
): Promise<boolean> {
|
||||
const usuario = await ctx.db.get(usuarioId);
|
||||
if (!usuario || !usuario.roleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const role = await ctx.db.get(usuario.roleId);
|
||||
if (!role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rolesPermitidas = ['ti_master', 'ti_usuario', 'admin'];
|
||||
return rolesPermitidas.includes(role.nome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar formato de domínio (FQDN ou localhost:porta)
|
||||
*/
|
||||
function validarFormatoDominio(domain: string): { valido: boolean; erro?: string } {
|
||||
const trimmed = domain.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { valido: false, erro: 'Domínio não pode estar vazio' };
|
||||
}
|
||||
|
||||
// Padrão para localhost:porta (ex: localhost:8443, 127.0.0.1:8080)
|
||||
const localhostPattern = /^(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?$/i;
|
||||
if (localhostPattern.test(trimmed)) {
|
||||
return { valido: true };
|
||||
}
|
||||
|
||||
// Padrão para FQDN (ex: meet.example.com, subdomain.example.com:8443)
|
||||
const fqdnPattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(:\d+)?$/;
|
||||
if (fqdnPattern.test(trimmed)) {
|
||||
return { valido: true };
|
||||
}
|
||||
|
||||
return {
|
||||
valido: false,
|
||||
erro: 'Domínio deve ser um FQDN válido (ex: meet.example.com) ou localhost:porta (ex: localhost:8443)'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar formato de App ID (alfanumérico, sem espaços)
|
||||
*/
|
||||
function validarAppId(appId: string): { valido: boolean; erro?: string } {
|
||||
const trimmed = appId.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { valido: false, erro: 'App ID não pode estar vazio' };
|
||||
}
|
||||
|
||||
const appIdPattern = /^[a-zA-Z0-9_-]+$/;
|
||||
if (!appIdPattern.test(trimmed)) {
|
||||
return {
|
||||
valido: false,
|
||||
erro: 'App ID deve conter apenas letras, números, hífens e underscores'
|
||||
};
|
||||
}
|
||||
|
||||
return { valido: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar JWT Secret (mínimo 32 caracteres recomendado)
|
||||
*/
|
||||
function validarJWTSecret(secret: string): { valido: boolean; erro?: string; aviso?: string } {
|
||||
if (secret.length === 0) {
|
||||
return { valido: true }; // JWT secret é opcional
|
||||
}
|
||||
|
||||
if (secret.length < 16) {
|
||||
return { valido: false, erro: 'JWT Secret deve ter no mínimo 16 caracteres' };
|
||||
}
|
||||
|
||||
if (secret.length < 32) {
|
||||
return {
|
||||
valido: true,
|
||||
aviso: 'Recomendado usar JWT Secret com pelo menos 32 caracteres para maior segurança'
|
||||
};
|
||||
}
|
||||
|
||||
return { valido: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter configuração de Jitsi ativa (compatibilidade com código existente)
|
||||
*/
|
||||
export const obterConfigJitsi = query({
|
||||
args: {},
|
||||
@@ -24,16 +177,102 @@ export const obterConfigJitsi = query({
|
||||
appId: config.appId,
|
||||
roomPrefix: config.roomPrefix,
|
||||
useHttps: config.useHttps,
|
||||
acceptSelfSignedCert: config.acceptSelfSignedCert ?? false, // Default para false se não existir
|
||||
acceptSelfSignedCert: config.acceptSelfSignedCert ?? false,
|
||||
ambiente: config.ambiente ?? null,
|
||||
ativo: config.ativo,
|
||||
testadoEm: config.testadoEm,
|
||||
atualizadoEm: config.atualizadoEm
|
||||
atualizadoEm: config.atualizadoEm,
|
||||
jwtSecret: config.jwtSecret,
|
||||
jwtIssuer: config.jwtIssuer,
|
||||
jwtAudience: config.jwtAudience
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Salvar configuração de Jitsi (apenas TI_MASTER)
|
||||
* Obter configuração de Jitsi por ambiente
|
||||
*/
|
||||
export const obterConfigJitsiPorAmbiente = query({
|
||||
args: {
|
||||
ambiente: v.optional(v.string())
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{
|
||||
_id: Id<'configuracaoJitsi'>;
|
||||
domain: string;
|
||||
appId: string;
|
||||
roomPrefix: string;
|
||||
useHttps: boolean;
|
||||
acceptSelfSignedCert: boolean;
|
||||
ambiente: string | null;
|
||||
ativo: boolean;
|
||||
testadoEm: number | undefined;
|
||||
atualizadoEm: number;
|
||||
jwtSecret: string | undefined;
|
||||
jwtIssuer: string | undefined;
|
||||
jwtAudience: string | undefined;
|
||||
} | null> => {
|
||||
// Se ambiente não especificado, buscar configuração ativa
|
||||
if (!args.ambiente) {
|
||||
return await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||
}
|
||||
|
||||
const config = await ctx.db
|
||||
.query('configuracaoJitsi')
|
||||
.withIndex('by_ativo_ambiente', (q) => q.eq('ativo', true).eq('ambiente', args.ambiente))
|
||||
.first();
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
_id: config._id,
|
||||
domain: config.domain,
|
||||
appId: config.appId,
|
||||
roomPrefix: config.roomPrefix,
|
||||
useHttps: config.useHttps,
|
||||
acceptSelfSignedCert: config.acceptSelfSignedCert ?? false,
|
||||
ambiente: config.ambiente ?? null,
|
||||
ativo: config.ativo,
|
||||
testadoEm: config.testadoEm,
|
||||
atualizadoEm: config.atualizadoEm,
|
||||
jwtSecret: config.jwtSecret,
|
||||
jwtIssuer: config.jwtIssuer,
|
||||
jwtAudience: config.jwtAudience
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Listar todas as configurações de Jitsi
|
||||
*/
|
||||
export const listarConfiguracoesJitsi = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const configs = await ctx.db.query('configuracaoJitsi').collect();
|
||||
|
||||
return configs.map((config) => ({
|
||||
_id: config._id,
|
||||
domain: config.domain,
|
||||
appId: config.appId,
|
||||
roomPrefix: config.roomPrefix,
|
||||
useHttps: config.useHttps,
|
||||
acceptSelfSignedCert: config.acceptSelfSignedCert ?? false,
|
||||
ambiente: config.ambiente ?? null,
|
||||
ativo: config.ativo,
|
||||
testadoEm: config.testadoEm,
|
||||
atualizadoEm: config.atualizadoEm,
|
||||
configuradoPor: config.configuradoPor
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Salvar configuração de Jitsi (apenas TI_MASTER/TI_USUARIO/ADMIN)
|
||||
* Suporta múltiplos ambientes
|
||||
*/
|
||||
export const salvarConfigJitsi = mutation({
|
||||
args: {
|
||||
@@ -42,24 +281,40 @@ export const salvarConfigJitsi = mutation({
|
||||
roomPrefix: v.string(),
|
||||
useHttps: v.boolean(),
|
||||
acceptSelfSignedCert: v.boolean(),
|
||||
configuradoPorId: v.id('usuarios')
|
||||
ambiente: v.optional(v.string()),
|
||||
jwtSecret: v.optional(v.string()), // JWT Secret em texto plano (será criptografado)
|
||||
jwtAudience: v.optional(v.string()),
|
||||
jwtIssuer: v.optional(v.string()),
|
||||
configuradoPorId: v.id('usuarios'),
|
||||
ativar: v.optional(v.boolean()) // Se true, desativa outras configs do mesmo ambiente
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true), configId: v.id('configuracaoJitsi') }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// Validar domínio (deve ser não vazio)
|
||||
if (!args.domain || args.domain.trim().length === 0) {
|
||||
return { sucesso: false as const, erro: 'Domínio não pode estar vazio' };
|
||||
// Verificar permissão
|
||||
const temPermissao = await verificarPermissaoTI(ctx, args.configuradoPorId);
|
||||
if (!temPermissao) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: 'Apenas usuários de TI ou administradores podem configurar Jitsi'
|
||||
};
|
||||
}
|
||||
|
||||
// Validar appId (deve ser não vazio)
|
||||
if (!args.appId || args.appId.trim().length === 0) {
|
||||
return { sucesso: false as const, erro: 'App ID não pode estar vazio' };
|
||||
// Validar domínio
|
||||
const validacaoDominio = validarFormatoDominio(args.domain);
|
||||
if (!validacaoDominio.valido) {
|
||||
return { sucesso: false as const, erro: validacaoDominio.erro || 'Domínio inválido' };
|
||||
}
|
||||
|
||||
// Validar roomPrefix (deve ser não vazio e alfanumérico)
|
||||
// Validar appId
|
||||
const validacaoAppId = validarAppId(args.appId);
|
||||
if (!validacaoAppId.valido) {
|
||||
return { sucesso: false as const, erro: validacaoAppId.erro || 'App ID inválido' };
|
||||
}
|
||||
|
||||
// Validar roomPrefix
|
||||
if (!args.roomPrefix || args.roomPrefix.trim().length === 0) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
@@ -67,7 +322,6 @@ export const salvarConfigJitsi = mutation({
|
||||
};
|
||||
}
|
||||
|
||||
// Validar formato do roomPrefix (apenas letras, números e hífens)
|
||||
const roomPrefixRegex = /^[a-zA-Z0-9-]+$/;
|
||||
if (!roomPrefixRegex.test(args.roomPrefix.trim())) {
|
||||
return {
|
||||
@@ -76,14 +330,47 @@ export const salvarConfigJitsi = mutation({
|
||||
};
|
||||
}
|
||||
|
||||
// Desativar config anterior
|
||||
const configsAntigas = await ctx.db
|
||||
.query('configuracaoJitsi')
|
||||
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||
.collect();
|
||||
// Validar JWT Secret (se fornecido)
|
||||
let jwtSecretCriptografado: string | undefined = undefined;
|
||||
if (args.jwtSecret && args.jwtSecret.trim().length > 0) {
|
||||
const validacaoJWT = validarJWTSecret(args.jwtSecret);
|
||||
if (!validacaoJWT.valido) {
|
||||
return { sucesso: false as const, erro: validacaoJWT.erro || 'JWT Secret inválido' };
|
||||
}
|
||||
|
||||
for (const config of configsAntigas) {
|
||||
await ctx.db.patch(config._id, { ativo: false });
|
||||
// Criptografar JWT secret
|
||||
try {
|
||||
jwtSecretCriptografado = await encryptJWTSecret(args.jwtSecret.trim());
|
||||
} catch (error) {
|
||||
console.error('Erro ao criptografar JWT secret:', error);
|
||||
return { sucesso: false as const, erro: 'Erro ao criptografar JWT secret' };
|
||||
}
|
||||
}
|
||||
|
||||
// Se ativar=true, desativar outras configs do mesmo ambiente (ou todas se ambiente não especificado)
|
||||
if (args.ativar !== false) {
|
||||
if (args.ambiente) {
|
||||
// Desativar outras configs do mesmo ambiente
|
||||
const configsMesmoAmbiente = await ctx.db
|
||||
.query('configuracaoJitsi')
|
||||
.withIndex('by_ambiente', (q) => q.eq('ambiente', args.ambiente))
|
||||
.filter((q) => q.eq(q.field('ativo'), true))
|
||||
.collect();
|
||||
|
||||
for (const config of configsMesmoAmbiente) {
|
||||
await ctx.db.patch(config._id, { ativo: false });
|
||||
}
|
||||
} else {
|
||||
// Desativar todas as configs ativas (comportamento antigo)
|
||||
const configsAntigas = await ctx.db
|
||||
.query('configuracaoJitsi')
|
||||
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||
.collect();
|
||||
|
||||
for (const config of configsAntigas) {
|
||||
await ctx.db.patch(config._id, { ativo: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Criar nova config
|
||||
@@ -93,7 +380,11 @@ export const salvarConfigJitsi = mutation({
|
||||
roomPrefix: args.roomPrefix.trim(),
|
||||
useHttps: args.useHttps,
|
||||
acceptSelfSignedCert: args.acceptSelfSignedCert,
|
||||
ativo: true,
|
||||
ambiente: args.ambiente?.trim() || undefined,
|
||||
jwtSecret: jwtSecretCriptografado,
|
||||
jwtAudience: args.jwtAudience?.trim() || undefined,
|
||||
jwtIssuer: args.jwtIssuer?.trim() || undefined,
|
||||
ativo: args.ativar !== false,
|
||||
configuradoPor: args.configuradoPorId,
|
||||
atualizadoEm: Date.now()
|
||||
});
|
||||
@@ -104,7 +395,7 @@ export const salvarConfigJitsi = mutation({
|
||||
args.configuradoPorId,
|
||||
'configurar',
|
||||
'jitsi',
|
||||
JSON.stringify({ domain: args.domain, appId: args.appId }),
|
||||
JSON.stringify({ domain: args.domain, appId: args.appId, ambiente: args.ambiente }),
|
||||
configId
|
||||
);
|
||||
|
||||
@@ -112,6 +403,194 @@ export const salvarConfigJitsi = mutation({
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Atualizar configuração existente
|
||||
*/
|
||||
export const atualizarConfigJitsi = mutation({
|
||||
args: {
|
||||
configId: v.id('configuracaoJitsi'),
|
||||
domain: v.optional(v.string()),
|
||||
appId: v.optional(v.string()),
|
||||
roomPrefix: v.optional(v.string()),
|
||||
useHttps: v.optional(v.boolean()),
|
||||
acceptSelfSignedCert: v.optional(v.boolean()),
|
||||
ambiente: v.optional(v.string()),
|
||||
jwtSecret: v.optional(v.string()), // Se fornecido, será criptografado e atualizado
|
||||
jwtAudience: v.optional(v.string()),
|
||||
jwtIssuer: v.optional(v.string()),
|
||||
atualizadoPorId: v.id('usuarios')
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// Verificar permissão
|
||||
const temPermissao = await verificarPermissaoTI(ctx, args.atualizadoPorId);
|
||||
if (!temPermissao) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: 'Apenas usuários de TI ou administradores podem atualizar configuração Jitsi'
|
||||
};
|
||||
}
|
||||
|
||||
const config = await ctx.db.get(args.configId);
|
||||
if (!config) {
|
||||
return { sucesso: false as const, erro: 'Configuração não encontrada' };
|
||||
}
|
||||
|
||||
// Validar campos se fornecidos
|
||||
if (args.domain !== undefined) {
|
||||
const validacaoDominio = validarFormatoDominio(args.domain);
|
||||
if (!validacaoDominio.valido) {
|
||||
return { sucesso: false as const, erro: validacaoDominio.erro || 'Domínio inválido' };
|
||||
}
|
||||
}
|
||||
|
||||
if (args.appId !== undefined) {
|
||||
const validacaoAppId = validarAppId(args.appId);
|
||||
if (!validacaoAppId.valido) {
|
||||
return { sucesso: false as const, erro: validacaoAppId.erro || 'App ID inválido' };
|
||||
}
|
||||
}
|
||||
|
||||
if (args.roomPrefix !== undefined) {
|
||||
const roomPrefixRegex = /^[a-zA-Z0-9-]+$/;
|
||||
if (!roomPrefixRegex.test(args.roomPrefix.trim())) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: 'Prefixo de sala deve conter apenas letras, números e hífens'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Criptografar JWT secret se fornecido
|
||||
let jwtSecretCriptografado: string | undefined = config.jwtSecret;
|
||||
if (args.jwtSecret !== undefined) {
|
||||
if (args.jwtSecret.trim().length > 0) {
|
||||
const validacaoJWT = validarJWTSecret(args.jwtSecret);
|
||||
if (!validacaoJWT.valido) {
|
||||
return { sucesso: false as const, erro: validacaoJWT.erro || 'JWT Secret inválido' };
|
||||
}
|
||||
|
||||
try {
|
||||
jwtSecretCriptografado = await encryptJWTSecret(args.jwtSecret.trim());
|
||||
} catch (error) {
|
||||
console.error('Erro ao criptografar JWT secret:', error);
|
||||
return { sucesso: false as const, erro: 'Erro ao criptografar JWT secret' };
|
||||
}
|
||||
} else {
|
||||
// Se string vazia, remover JWT secret
|
||||
jwtSecretCriptografado = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Atualizar campos
|
||||
const updates: Partial<typeof config> = {
|
||||
atualizadoEm: Date.now()
|
||||
};
|
||||
|
||||
if (args.domain !== undefined) updates.domain = args.domain.trim();
|
||||
if (args.appId !== undefined) updates.appId = args.appId.trim();
|
||||
if (args.roomPrefix !== undefined) updates.roomPrefix = args.roomPrefix.trim();
|
||||
if (args.useHttps !== undefined) updates.useHttps = args.useHttps;
|
||||
if (args.acceptSelfSignedCert !== undefined)
|
||||
updates.acceptSelfSignedCert = args.acceptSelfSignedCert;
|
||||
if (args.ambiente !== undefined) updates.ambiente = args.ambiente?.trim() || undefined;
|
||||
if (args.jwtSecret !== undefined) updates.jwtSecret = jwtSecretCriptografado;
|
||||
if (args.jwtAudience !== undefined) updates.jwtAudience = args.jwtAudience?.trim() || undefined;
|
||||
if (args.jwtIssuer !== undefined) updates.jwtIssuer = args.jwtIssuer?.trim() || undefined;
|
||||
|
||||
await ctx.db.patch(args.configId, updates);
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.atualizadoPorId,
|
||||
'atualizar',
|
||||
'jitsi',
|
||||
JSON.stringify({ configId: args.configId }),
|
||||
args.configId
|
||||
);
|
||||
|
||||
return { sucesso: true as const };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Ativar/desativar configuração
|
||||
*/
|
||||
export const ativarDesativarConfigJitsi = mutation({
|
||||
args: {
|
||||
configId: v.id('configuracaoJitsi'),
|
||||
ativo: v.boolean(),
|
||||
atualizadoPorId: v.id('usuarios')
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true) }),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
// Verificar permissão
|
||||
const temPermissao = await verificarPermissaoTI(ctx, args.atualizadoPorId);
|
||||
if (!temPermissao) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: 'Apenas usuários de TI ou administradores podem ativar/desativar configuração Jitsi'
|
||||
};
|
||||
}
|
||||
|
||||
const config = await ctx.db.get(args.configId);
|
||||
if (!config) {
|
||||
return { sucesso: false as const, erro: 'Configuração não encontrada' };
|
||||
}
|
||||
|
||||
// Se ativando, desativar outras configs do mesmo ambiente (ou todas se ambiente não especificado)
|
||||
if (args.ativo) {
|
||||
if (config.ambiente) {
|
||||
const configsMesmoAmbiente = await ctx.db
|
||||
.query('configuracaoJitsi')
|
||||
.withIndex('by_ambiente', (q) => q.eq('ambiente', config.ambiente))
|
||||
.filter((q) => q.eq(q.field('ativo'), true))
|
||||
.filter((q) => q.neq(q.field('_id'), args.configId))
|
||||
.collect();
|
||||
|
||||
for (const outraConfig of configsMesmoAmbiente) {
|
||||
await ctx.db.patch(outraConfig._id, { ativo: false });
|
||||
}
|
||||
} else {
|
||||
// Desativar todas as outras configs ativas
|
||||
const configsAntigas = await ctx.db
|
||||
.query('configuracaoJitsi')
|
||||
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||
.filter((q) => q.neq(q.field('_id'), args.configId))
|
||||
.collect();
|
||||
|
||||
for (const outraConfig of configsAntigas) {
|
||||
await ctx.db.patch(outraConfig._id, { ativo: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.configId, {
|
||||
ativo: args.ativo,
|
||||
atualizadoEm: Date.now()
|
||||
});
|
||||
|
||||
// Log de atividade
|
||||
await registrarAtividade(
|
||||
ctx,
|
||||
args.atualizadoPorId,
|
||||
args.ativo ? 'ativar' : 'desativar',
|
||||
'jitsi',
|
||||
JSON.stringify({ configId: args.configId }),
|
||||
args.configId
|
||||
);
|
||||
|
||||
return { sucesso: true as const };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Mutation interna para atualizar testadoEm
|
||||
*/
|
||||
@@ -129,13 +608,14 @@ export const atualizarTestadoEm = internalMutation({
|
||||
});
|
||||
|
||||
/**
|
||||
* Testar conexão com servidor Jitsi
|
||||
* Testar conexão com servidor Jitsi (melhorado)
|
||||
*/
|
||||
export const testarConexaoJitsi = action({
|
||||
args: {
|
||||
domain: v.string(),
|
||||
useHttps: v.boolean(),
|
||||
acceptSelfSignedCert: v.optional(v.boolean())
|
||||
acceptSelfSignedCert: v.optional(v.boolean()),
|
||||
configId: v.optional(v.id('configuracaoJitsi'))
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({ sucesso: v.literal(true), aviso: v.optional(v.string()) }),
|
||||
@@ -145,9 +625,10 @@ export const testarConexaoJitsi = action({
|
||||
ctx,
|
||||
args
|
||||
): Promise<{ sucesso: true; aviso?: string } | { sucesso: false; erro: string }> => {
|
||||
// Validações básicas
|
||||
if (!args.domain || args.domain.trim().length === 0) {
|
||||
return { sucesso: false as const, erro: 'Domínio não pode estar vazio' };
|
||||
// Validar formato de domínio
|
||||
const validacaoDominio = validarFormatoDominio(args.domain);
|
||||
if (!validacaoDominio.valido) {
|
||||
return { sucesso: false as const, erro: validacaoDominio.erro || 'Domínio inválido' };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -158,9 +639,8 @@ export const testarConexaoJitsi = action({
|
||||
const url = `${protocol}://${host}:${port}/http-bind`;
|
||||
|
||||
// Tentar fazer uma requisição HTTP para verificar se o servidor está acessível
|
||||
// Nota: No ambiente Node.js do Convex, podemos usar fetch
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 segundos de timeout
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 segundos de timeout
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
@@ -174,15 +654,20 @@ export const testarConexaoJitsi = action({
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Qualquer resposta indica que o servidor está acessível
|
||||
// Não precisamos verificar o status code exato, apenas se há resposta
|
||||
if (response.status >= 200 && response.status < 600) {
|
||||
// Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm
|
||||
const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||
|
||||
if (configAtiva) {
|
||||
// Se o teste foi bem-sucedido e há uma config, atualizar testadoEm
|
||||
if (args.configId) {
|
||||
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
||||
configId: configAtiva._id
|
||||
configId: args.configId
|
||||
});
|
||||
} else {
|
||||
// Tentar atualizar config ativa
|
||||
const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||
if (configAtiva) {
|
||||
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
||||
configId: configAtiva._id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { sucesso: true as const, aviso: undefined };
|
||||
@@ -200,7 +685,7 @@ export const testarConexaoJitsi = action({
|
||||
if (errorMessage.includes('aborted') || errorMessage.includes('timeout')) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: 'Timeout: Servidor não respondeu em 5 segundos'
|
||||
erro: 'Timeout: Servidor não respondeu em 10 segundos'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,15 +700,17 @@ export const testarConexaoJitsi = action({
|
||||
|
||||
// Se for erro de certificado e aceitar autoassinado está configurado
|
||||
if (isSSLError && args.acceptSelfSignedCert) {
|
||||
// Aceitar como sucesso se configurado para aceitar certificados autoassinados
|
||||
// (o servidor está acessível, apenas o certificado não é confiável)
|
||||
// Nota: No cliente (navegador), o usuário ainda precisará aceitar o certificado manualmente
|
||||
const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||
|
||||
if (configAtiva) {
|
||||
if (args.configId) {
|
||||
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
||||
configId: configAtiva._id
|
||||
configId: args.configId
|
||||
});
|
||||
} else {
|
||||
const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||
if (configAtiva) {
|
||||
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
||||
configId: configAtiva._id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -235,15 +722,18 @@ export const testarConexaoJitsi = action({
|
||||
|
||||
// Para servidores Jitsi, pode ser normal receber erro 405 (Method Not Allowed)
|
||||
// para GET em /http-bind, pois esse endpoint espera POST (BOSH)
|
||||
// Isso indica que o servidor está acessível, apenas não aceita GET
|
||||
if (errorMessage.includes('405') || errorMessage.includes('Method Not Allowed')) {
|
||||
// Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm
|
||||
const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||
|
||||
if (configAtiva) {
|
||||
if (args.configId) {
|
||||
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
||||
configId: configAtiva._id
|
||||
configId: args.configId
|
||||
});
|
||||
} else {
|
||||
const configAtiva = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||
if (configAtiva) {
|
||||
await ctx.runMutation(internal.configuracaoJitsi.atualizarTestadoEm, {
|
||||
configId: configAtiva._id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { sucesso: true as const, aviso: undefined };
|
||||
@@ -303,3 +793,162 @@ export const marcarConfiguradoNoServidor = internalMutation({
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Gerar token JWT para Jitsi
|
||||
* Valida que o usuário é participante da conversa e retorna token assinado
|
||||
*/
|
||||
export const gerarTokenJitsi = action({
|
||||
args: {
|
||||
roomName: v.string(),
|
||||
conversaId: v.id('conversas'),
|
||||
chamadaId: v.id('chamadas'),
|
||||
ambiente: v.optional(v.string())
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
sucesso: v.literal(true),
|
||||
token: v.string(),
|
||||
payload: v.object({
|
||||
iss: v.string(),
|
||||
aud: v.string(),
|
||||
sub: v.string(),
|
||||
room: v.string(),
|
||||
moderator: v.boolean(),
|
||||
exp: v.number(),
|
||||
nbf: v.number()
|
||||
})
|
||||
}),
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<
|
||||
| {
|
||||
sucesso: true;
|
||||
token: string;
|
||||
payload: {
|
||||
iss: string;
|
||||
aud: string;
|
||||
sub: string;
|
||||
room: string;
|
||||
moderator: boolean;
|
||||
exp: number;
|
||||
nbf: number;
|
||||
};
|
||||
}
|
||||
| { sucesso: false; erro: string }
|
||||
> => {
|
||||
// Obter usuário autenticado (em actions, precisamos usar authComponent)
|
||||
const authUser = await authComponent.safeGetAuthUser(toGenericCtx(ctx));
|
||||
if (!authUser) {
|
||||
return { sucesso: false as const, erro: 'Não autenticado' };
|
||||
}
|
||||
|
||||
// Buscar usuário no banco usando query
|
||||
const usuarioAtual = await ctx.runQuery(api.auth.getCurrentUser, {});
|
||||
if (!usuarioAtual) {
|
||||
return { sucesso: false as const, erro: 'Usuário não encontrado' };
|
||||
}
|
||||
|
||||
// Verificar se usuário participa da conversa
|
||||
const conversa = await ctx.runQuery(api.chamadas.obterChamada, {
|
||||
chamadaId: args.chamadaId
|
||||
});
|
||||
if (!conversa) {
|
||||
return { sucesso: false as const, erro: 'Chamada não encontrada ou você não tem acesso' };
|
||||
}
|
||||
|
||||
// Verificar se conversaId corresponde
|
||||
if (conversa.conversaId !== args.conversaId) {
|
||||
return { sucesso: false as const, erro: 'Conversa não corresponde à chamada' };
|
||||
}
|
||||
|
||||
// Verificar se chamada existe (já obtida acima como conversa)
|
||||
const chamada = conversa;
|
||||
if (!chamada) {
|
||||
return { sucesso: false as const, erro: 'Chamada não encontrada' };
|
||||
}
|
||||
|
||||
// Verificar se roomName corresponde
|
||||
if (chamada.roomName !== args.roomName) {
|
||||
return { sucesso: false as const, erro: 'Room name não corresponde à chamada' };
|
||||
}
|
||||
|
||||
// Buscar configuração Jitsi
|
||||
let config;
|
||||
if (args.ambiente) {
|
||||
config = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsiPorAmbiente, {
|
||||
ambiente: args.ambiente
|
||||
});
|
||||
} else {
|
||||
config = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return { sucesso: false as const, erro: 'Configuração Jitsi não encontrada' };
|
||||
}
|
||||
|
||||
// Se não há JWT secret configurado, retornar erro
|
||||
if (!config.jwtSecret) {
|
||||
return {
|
||||
sucesso: false as const,
|
||||
erro: 'JWT Secret não configurado. Configure o JWT Secret nas configurações do Jitsi.'
|
||||
};
|
||||
}
|
||||
|
||||
// Descriptografar JWT secret
|
||||
let jwtSecret: string;
|
||||
try {
|
||||
jwtSecret = await decryptJWTSecret(config.jwtSecret);
|
||||
} catch (error) {
|
||||
console.error('Erro ao descriptografar JWT secret:', error);
|
||||
return { sucesso: false as const, erro: 'Erro ao descriptografar JWT secret' };
|
||||
}
|
||||
|
||||
// Determinar se usuário é moderador (anfitrião da chamada)
|
||||
const ehModerador = chamada.criadoPor === usuarioAtual._id;
|
||||
|
||||
// Preparar claims do JWT
|
||||
const iss: string = config.jwtIssuer || config.appId;
|
||||
const aud: string = config.jwtAudience || config.domain;
|
||||
const sub: string = usuarioAtual.email || usuarioAtual._id;
|
||||
|
||||
// Gerar token
|
||||
try {
|
||||
const token = await criarTokenJWTJitsi(jwtSecret, {
|
||||
iss,
|
||||
aud,
|
||||
sub,
|
||||
room: args.roomName,
|
||||
moderator: ehModerador
|
||||
});
|
||||
|
||||
// Retornar token e payload decodificado para debug
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: {
|
||||
iss: string;
|
||||
aud: string;
|
||||
sub: string;
|
||||
room: string;
|
||||
moderator: boolean;
|
||||
exp: number;
|
||||
nbf: number;
|
||||
} = {
|
||||
iss,
|
||||
aud,
|
||||
sub,
|
||||
room: args.roomName,
|
||||
moderator: ehModerador,
|
||||
exp: now + 3600, // 1 hora
|
||||
nbf: now
|
||||
};
|
||||
|
||||
return { sucesso: true as const, token, payload };
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar token JWT:', error);
|
||||
return { sucesso: false as const, erro: 'Erro ao gerar token JWT' };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,7 +16,24 @@ http.route({
|
||||
const method = request.method;
|
||||
|
||||
// Extrair IP do cliente
|
||||
const ipOrigem = getClientIP(request);
|
||||
const ipOrigem = getClientIP(request) ?? '0.0.0.0';
|
||||
|
||||
// Enforcement (blacklist + rate limit) antes de processar
|
||||
const enforcement = await ctx.runMutation(api.security.enforceRequest, {
|
||||
ip: ipOrigem,
|
||||
path: url.pathname,
|
||||
method
|
||||
});
|
||||
if (!enforcement.allowed) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (enforcement.retryAfterMs) {
|
||||
headers['Retry-After'] = String(Math.ceil(enforcement.retryAfterMs / 1000));
|
||||
}
|
||||
return new Response(JSON.stringify(enforcement), {
|
||||
status: enforcement.status,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
// Extrair headers
|
||||
const headers: Record<string, string> = {};
|
||||
@@ -38,8 +55,8 @@ http.route({
|
||||
// Ignorar erros ao ler body
|
||||
}
|
||||
|
||||
// Analisar requisição para detectar ataques
|
||||
const resultado = await ctx.runMutation(api.security.analisarRequisicaoHTTP, {
|
||||
// Analisar requisição para detectar ataques ANTES de processar
|
||||
const analise = await ctx.runMutation(api.security.analisarRequisicaoHTTP, {
|
||||
url: url.pathname + url.search,
|
||||
method,
|
||||
headers,
|
||||
@@ -49,7 +66,25 @@ http.route({
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(resultado), {
|
||||
// Se ataque detectado e bloqueio automático aplicado, retornar 403 imediatamente
|
||||
if (analise.ataqueDetectado && analise.bloqueadoAutomatico) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ataqueDetectado: true,
|
||||
bloqueado: true,
|
||||
tipoAtaque: analise.tipoAtaque,
|
||||
severidade: analise.severidade,
|
||||
mensagem: 'Ataque detectado e bloqueado automaticamente'
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Se ataque detectado mas sem bloqueio automático, retornar resultado normalmente
|
||||
return new Response(JSON.stringify(analise), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
@@ -61,6 +96,19 @@ http.route({
|
||||
path: '/security/rate-limit/seed-dev',
|
||||
method: 'POST',
|
||||
handler: httpAction(async (ctx) => {
|
||||
// Hardening: endpoint apenas para dev/staging.
|
||||
// Em Convex, NODE_ENV pode ser 'production' mesmo em dev local,
|
||||
// então também aceitamos deployments locais "anonymous-*", ou uma flag explícita.
|
||||
const deployment = process.env.CONVEX_DEPLOYMENT;
|
||||
const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL;
|
||||
const enabled =
|
||||
process.env.SECURITY_DEV_TOOLS === 'true' ||
|
||||
(typeof siteUrl === 'string' && /localhost|127\.0\.0\.1/i.test(siteUrl)) ||
|
||||
(typeof deployment === 'string' && deployment.startsWith('anonymous-')) ||
|
||||
process.env.NODE_ENV !== 'production';
|
||||
if (!enabled) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
const resultado = await ctx.runMutation(api.security.seedRateLimitDev, {});
|
||||
return new Response(JSON.stringify(resultado), {
|
||||
status: 200,
|
||||
|
||||
@@ -3040,9 +3040,7 @@ export const excluirHomologacao = mutation({
|
||||
|
||||
if (bancoHoras) {
|
||||
// Remover o ajuste do array ajustesIds
|
||||
const novosAjustesIds = (bancoHoras.ajustesIds || []).filter(
|
||||
(id) => id !== ajuste._id
|
||||
);
|
||||
const novosAjustesIds = (bancoHoras.ajustesIds || []).filter((id) => id !== ajuste._id);
|
||||
|
||||
// Reverter o ajuste do saldo (subtrair o valor que foi adicionado)
|
||||
const novoSaldoMinutos = bancoHoras.saldoMinutos - ajuste.valorMinutos;
|
||||
@@ -3051,9 +3049,7 @@ export const excluirHomologacao = mutation({
|
||||
let novoTipoDia = bancoHoras.tipoDia;
|
||||
if (novosAjustesIds.length > 0) {
|
||||
// Se ainda há outros ajustes, verificar qual tipoDia deve ser mantido
|
||||
const outrosAjustes = await Promise.all(
|
||||
novosAjustesIds.map((id) => ctx.db.get(id))
|
||||
);
|
||||
const outrosAjustes = await Promise.all(novosAjustesIds.map((id) => ctx.db.get(id)));
|
||||
const temAjusteAbonar = outrosAjustes.some((a) => a?.tipo === 'abonar');
|
||||
const temAjusteDescontar = outrosAjustes.some((a) => a?.tipo === 'descontar');
|
||||
|
||||
@@ -3103,7 +3099,12 @@ export const excluirHomologacao = mutation({
|
||||
const estaRemovendoMesPassado = mes < mesAtual;
|
||||
|
||||
// Recalcular em cascata se for mês passado
|
||||
await calcularBancoHorasMensal(ctx, homologacao.funcionarioId, mes, estaRemovendoMesPassado);
|
||||
await calcularBancoHorasMensal(
|
||||
ctx,
|
||||
homologacao.funcionarioId,
|
||||
mes,
|
||||
estaRemovendoMesPassado
|
||||
);
|
||||
}
|
||||
|
||||
// Excluir o registro de ajuste do banco de dados
|
||||
|
||||
@@ -3,6 +3,7 @@ import { v } from 'convex/values';
|
||||
import { internalMutation, mutation, MutationCtx, query, QueryCtx } from './_generated/server';
|
||||
import { internal, api, components } from './_generated/api';
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import { getCurrentUserFunction } from './auth';
|
||||
import type {
|
||||
AtaqueCiberneticoTipo,
|
||||
SeveridadeSeguranca,
|
||||
@@ -38,6 +39,35 @@ type RegistroGeo = {
|
||||
longitude?: number;
|
||||
};
|
||||
|
||||
async function assertAdmin(ctx: QueryCtx | MutationCtx) {
|
||||
const usuarioAtual = await getCurrentUserFunction(ctx);
|
||||
if (!usuarioAtual) {
|
||||
throw new Error('acesso_negado');
|
||||
}
|
||||
if (!usuarioAtual.roleId) {
|
||||
throw new Error('acesso_negado');
|
||||
}
|
||||
const role = await ctx.db.get(usuarioAtual.roleId);
|
||||
if (!role || role.admin !== true) {
|
||||
throw new Error('acesso_negado');
|
||||
}
|
||||
return usuarioAtual;
|
||||
}
|
||||
|
||||
function assertDevOnly() {
|
||||
// Em produção, endpoints/mutations de teste devem ser desabilitados.
|
||||
// Observação: em ambientes Convex, NODE_ENV pode vir como 'production' mesmo em dev local,
|
||||
// então também aceitamos deployments locais "anonymous-*", ou uma flag explícita.
|
||||
const deployment = process.env.CONVEX_DEPLOYMENT;
|
||||
const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL;
|
||||
const enabled =
|
||||
process.env.SECURITY_DEV_TOOLS === 'true' ||
|
||||
(typeof siteUrl === 'string' && /localhost|127\.0\.0\.1/i.test(siteUrl)) ||
|
||||
(typeof deployment === 'string' && deployment.startsWith('anonymous-')) ||
|
||||
process.env.NODE_ENV !== 'production';
|
||||
if (!enabled) throw new Error('acesso_negado');
|
||||
}
|
||||
|
||||
type RegistroEventoArgs = {
|
||||
descricao?: string;
|
||||
tipoAtaque?: AtaqueCiberneticoTipo;
|
||||
@@ -83,7 +113,6 @@ const ATAQUES_PRIORITARIOS: Array<AtaqueCiberneticoTipo> = [
|
||||
'brute_force',
|
||||
'supply_chain',
|
||||
'malware',
|
||||
'engenharia_social',
|
||||
'cve_exploit',
|
||||
'bec',
|
||||
'side_channel'
|
||||
@@ -103,7 +132,6 @@ const BASE_SEVERIDADE: Record<AtaqueCiberneticoTipo, SeveridadeSeguranca> = {
|
||||
xxe: 'alto',
|
||||
man_in_the_middle: 'alto',
|
||||
ddos: 'alto',
|
||||
engenharia_social: 'moderado',
|
||||
cve_exploit: 'alto',
|
||||
apt: 'critico',
|
||||
zero_day: 'critico',
|
||||
@@ -298,7 +326,6 @@ const KEYWORDS: Record<AtaqueCiberneticoTipo, RegExp[]> = {
|
||||
],
|
||||
man_in_the_middle: [/mitm/i, /man-in-the-middle/i, /ssl strip/i, /tls downgrade/i],
|
||||
ddos: [/ddos/i, /flood/i, /pps/i, /distributed denial/i, /traffic flood/i],
|
||||
engenharia_social: [/social/i, /engenharia/i, /social engineering/i],
|
||||
cve_exploit: [/cve-\d{4}-\d+/i, /exploit/i, /cve/i],
|
||||
apt: [/apt/i, /persistent/i, /advanced persistent threat/i],
|
||||
zero_day: [/zero[-\s]?day/i, /unknown exploit/i, /0-day/i],
|
||||
@@ -332,7 +359,6 @@ const ataqueValidator = v.union(
|
||||
v.literal('xxe'),
|
||||
v.literal('man_in_the_middle'),
|
||||
v.literal('ddos'),
|
||||
v.literal('engenharia_social'),
|
||||
v.literal('cve_exploit'),
|
||||
v.literal('apt'),
|
||||
v.literal('zero_day'),
|
||||
@@ -404,6 +430,79 @@ const acaoIncidenteValidator = v.union(
|
||||
|
||||
const acaoOrigemValidator = v.union(v.literal('automatico'), v.literal('manual'));
|
||||
|
||||
export const enforceRequest = mutation({
|
||||
args: {
|
||||
ip: v.string(),
|
||||
path: v.string(),
|
||||
method: v.string()
|
||||
},
|
||||
returns: v.object({
|
||||
allowed: v.boolean(),
|
||||
status: v.number(),
|
||||
reason: v.optional(v.string()),
|
||||
retryAfterMs: v.optional(v.number())
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const agora = Date.now();
|
||||
const ip = args.ip.trim();
|
||||
const path = args.path.trim() || '/';
|
||||
const pathKey = path.replace(/^\/+/, '');
|
||||
|
||||
// 1) Blacklist enforcement (somente IP)
|
||||
const registroIp = await ctx.db
|
||||
.query('ipReputation')
|
||||
.withIndex('by_indicador', (q) => q.eq('indicador', ip))
|
||||
.order('desc')
|
||||
.first();
|
||||
|
||||
if (registroIp && registroIp.categoria === 'ip' && registroIp.blacklist === true) {
|
||||
const ativo = !registroIp.bloqueadoAte || registroIp.bloqueadoAte > agora;
|
||||
if (ativo) {
|
||||
return {
|
||||
allowed: false,
|
||||
status: 403,
|
||||
reason: 'ip_blacklisted'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Rate limit enforcement (endpoint + IP)
|
||||
// - endpoint rules usam o "pathKey" (ex.: api/auth/sign-in/email)
|
||||
// - ip rules usam o IP e namespace por endpoint
|
||||
const endpointCheck = await aplicarRateLimit(ctx, 'endpoint', pathKey || 'root', pathKey || 'root');
|
||||
if (!endpointCheck.permitido) {
|
||||
return {
|
||||
allowed: false,
|
||||
status: 429,
|
||||
reason: endpointCheck.motivo ?? 'rate_limited',
|
||||
retryAfterMs: endpointCheck.retryAfter
|
||||
};
|
||||
}
|
||||
|
||||
const ipCheck = await aplicarRateLimit(ctx, 'ip', ip, pathKey || 'root');
|
||||
if (!ipCheck.permitido) {
|
||||
return {
|
||||
allowed: false,
|
||||
status: 429,
|
||||
reason: ipCheck.motivo ?? 'rate_limited',
|
||||
retryAfterMs: ipCheck.retryAfter
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: para estratégias "throttle", o helper pode retornar retryAfter mas permitir.
|
||||
// Aqui optamos por permitir, deixando o servidor decidir se atrasa ou apenas sinaliza.
|
||||
const retryAfterMs =
|
||||
(endpointCheck.retryAfter ?? 0) > 0 ? endpointCheck.retryAfter : ipCheck.retryAfter;
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
status: 200,
|
||||
reason: undefined,
|
||||
retryAfterMs: retryAfterMs && retryAfterMs > 0 ? retryAfterMs : undefined
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Função para analisar string e detectar ataques
|
||||
function analisarStringParaAtaques(texto: string): AtaqueCiberneticoTipo | null {
|
||||
if (!texto) return null;
|
||||
@@ -700,6 +799,7 @@ export const listarEventosSeguranca = query({
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
const limit = args.limit && args.limit > 0 ? Math.min(args.limit, 500) : 100;
|
||||
const janelaInicial = args.apos ?? Date.now() - 6 * 60 * 60 * 1000;
|
||||
|
||||
@@ -809,6 +909,7 @@ export const obterVisaoCamadas = query({
|
||||
})
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
const agora = Date.now();
|
||||
const periodoMs = Math.max(1, args.periodoHoras ?? 6) * 60 * 60 * 1000;
|
||||
const inicioJanela = agora - periodoMs;
|
||||
@@ -899,6 +1000,7 @@ export const listarReputacoes = query({
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
const limit = args.limit && args.limit > 0 ? Math.min(args.limit, 500) : 200;
|
||||
|
||||
const builder =
|
||||
@@ -933,7 +1035,6 @@ export const listarReputacoes = query({
|
||||
|
||||
export const atualizarReputacaoIndicador = mutation({
|
||||
args: {
|
||||
usuarioId: v.id('usuarios'),
|
||||
indicador: v.string(),
|
||||
categoria: indicadorCategoriaValidator,
|
||||
acao: v.union(
|
||||
@@ -953,11 +1054,12 @@ export const atualizarReputacaoIndicador = mutation({
|
||||
status: v.string()
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await assertAdmin(ctx);
|
||||
// Aplicar rate limiting por usuário
|
||||
const rateLimitResult = await aplicarRateLimit(
|
||||
ctx,
|
||||
'usuario',
|
||||
args.usuarioId,
|
||||
usuarioAtual._id,
|
||||
'atualizarReputacaoIndicador'
|
||||
);
|
||||
if (!rateLimitResult.permitido) {
|
||||
@@ -995,7 +1097,7 @@ export const atualizarReputacaoIndicador = mutation({
|
||||
eventoId: await ctx.db.insert('securityEvents', {
|
||||
referencia: `auto-${args.indicador}-${agora}`,
|
||||
timestamp: agora,
|
||||
tipoAtaque: 'engenharia_social',
|
||||
tipoAtaque: 'malware',
|
||||
severidade: 'informativo',
|
||||
status: 'contido',
|
||||
descricao: 'Registro criado via painel de reputação.',
|
||||
@@ -1016,13 +1118,13 @@ export const atualizarReputacaoIndicador = mutation({
|
||||
tags: ['reputacao'],
|
||||
referenciasExternas: undefined,
|
||||
correlacoes: undefined,
|
||||
criadoPor: args.usuarioId,
|
||||
criadoPor: usuarioAtual._id,
|
||||
atualizadoEm: agora
|
||||
}),
|
||||
tipo: args.acao === 'forcar_blacklist' ? 'block_ip' : 'custom',
|
||||
origem: 'manual',
|
||||
status: 'concluido',
|
||||
executadoPor: args.usuarioId,
|
||||
executadoPor: usuarioAtual._id,
|
||||
detalhes: args.comentario,
|
||||
resultado: 'Registro inicial',
|
||||
relacionadoA: undefined,
|
||||
@@ -1071,7 +1173,6 @@ export const atualizarReputacaoIndicador = mutation({
|
||||
|
||||
export const configurarRegraPorta = mutation({
|
||||
args: {
|
||||
usuarioId: v.id('usuarios'),
|
||||
regraId: v.optional(v.id('portRules')),
|
||||
porta: v.number(),
|
||||
protocolo: protocoloValidator,
|
||||
@@ -1088,6 +1189,7 @@ export const configurarRegraPorta = mutation({
|
||||
status: v.string()
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await assertAdmin(ctx);
|
||||
const agora = Date.now();
|
||||
const expiraEm =
|
||||
args.temporario && args.duracaoSegundos ? agora + args.duracaoSegundos * 1000 : undefined;
|
||||
@@ -1100,7 +1202,7 @@ export const configurarRegraPorta = mutation({
|
||||
temporario: args.temporario,
|
||||
duracaoSegundos: args.duracaoSegundos,
|
||||
expiraEm,
|
||||
atualizadoPor: args.usuarioId,
|
||||
atualizadoPor: usuarioAtual._id,
|
||||
atualizadoEm: agora,
|
||||
severidadeMin: args.severidadeMin,
|
||||
notas: args.notas,
|
||||
@@ -1118,8 +1220,8 @@ export const configurarRegraPorta = mutation({
|
||||
severidadeMin: args.severidadeMin,
|
||||
duracaoSegundos: args.duracaoSegundos,
|
||||
expiraEm,
|
||||
criadoPor: args.usuarioId,
|
||||
atualizadoPor: args.usuarioId,
|
||||
criadoPor: usuarioAtual._id,
|
||||
atualizadoPor: usuarioAtual._id,
|
||||
criadoEm: agora,
|
||||
atualizadoEm: agora,
|
||||
notas: args.notas,
|
||||
@@ -1150,6 +1252,7 @@ export const listarRegrasPorta = query({
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
const builder =
|
||||
args.acao === undefined
|
||||
? ctx.db.query('portRules')
|
||||
@@ -1203,13 +1306,17 @@ export const registrarAcaoIncidente = mutation({
|
||||
acaoId: v.id('incidentActions')
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await assertAdmin(ctx);
|
||||
if (args.executadoPor && args.executadoPor !== usuarioAtual._id) {
|
||||
throw new Error('acesso_negado');
|
||||
}
|
||||
const agora = Date.now();
|
||||
const acaoId = await ctx.db.insert('incidentActions', {
|
||||
eventoId: args.eventoId,
|
||||
tipo: args.tipo,
|
||||
origem: args.origem,
|
||||
status: args.status ?? 'concluido',
|
||||
executadoPor: args.executadoPor,
|
||||
executadoPor: usuarioAtual._id,
|
||||
detalhes: args.detalhes,
|
||||
resultado: args.resultado,
|
||||
relacionadoA: args.relacionadoA,
|
||||
@@ -1223,7 +1330,6 @@ export const registrarAcaoIncidente = mutation({
|
||||
|
||||
export const solicitarRelatorioSeguranca = mutation({
|
||||
args: {
|
||||
solicitanteId: v.id('usuarios'),
|
||||
filtros: v.object({
|
||||
dataInicio: v.number(),
|
||||
dataFim: v.number(),
|
||||
@@ -1238,8 +1344,9 @@ export const solicitarRelatorioSeguranca = mutation({
|
||||
relatorioId: v.id('reportRequests')
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await assertAdmin(ctx);
|
||||
const relatorioId = await ctx.db.insert('reportRequests', {
|
||||
solicitanteId: args.solicitanteId,
|
||||
solicitanteId: usuarioAtual._id,
|
||||
filtros: {
|
||||
dataInicio: args.filtros.dataInicio,
|
||||
dataFim: args.filtros.dataFim,
|
||||
@@ -1286,6 +1393,7 @@ export const listarRelatoriosRecentes = query({
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
const max = Math.min(args.limit ?? 10, 50);
|
||||
const rows = await ctx.db
|
||||
.query('reportRequests')
|
||||
@@ -1311,6 +1419,7 @@ export const healthStatus = query({
|
||||
pendingReports: v.number()
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
await assertAdmin(ctx);
|
||||
// Contar rapidamente quantos relatórios pendentes existem (limitado)
|
||||
const pending = await ctx.db
|
||||
.query('reportRequests')
|
||||
@@ -1332,6 +1441,7 @@ export const deletarRelatorio = mutation({
|
||||
},
|
||||
returns: v.object({ success: v.boolean() }),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
const doc = await ctx.db.get(args.relatorioId);
|
||||
if (!doc) {
|
||||
return { success: false };
|
||||
@@ -1440,6 +1550,7 @@ export const listarAlertConfigs = query({
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
const max = Math.min(args.limit ?? 100, 200);
|
||||
const rows = await ctx.db
|
||||
.query('alertConfigs')
|
||||
@@ -1472,11 +1583,11 @@ export const salvarAlertConfig = mutation({
|
||||
severidadeMin: severidadeValidator,
|
||||
tiposAtaque: v.optional(v.array(ataqueValidator)),
|
||||
reenvioMin: v.number(),
|
||||
templateCodigo: v.optional(v.string()), // Template a ser usado
|
||||
criadoPor: v.id('usuarios')
|
||||
templateCodigo: v.optional(v.string()) // Template a ser usado
|
||||
},
|
||||
returns: v.object({ _id: v.id('alertConfigs') }),
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await assertAdmin(ctx);
|
||||
const agora = Date.now();
|
||||
if (args.configId) {
|
||||
await ctx.db.patch(args.configId, {
|
||||
@@ -1501,7 +1612,7 @@ export const salvarAlertConfig = mutation({
|
||||
tiposAtaque: args.tiposAtaque,
|
||||
reenvioMin: args.reenvioMin,
|
||||
templateCodigo: args.templateCodigo ?? 'incidente_critico', // Padrão
|
||||
criadoPor: args.criadoPor,
|
||||
criadoPor: usuarioAtual._id,
|
||||
criadoEm: agora,
|
||||
atualizadoEm: agora
|
||||
});
|
||||
@@ -1513,6 +1624,7 @@ export const deletarAlertConfig = mutation({
|
||||
args: { configId: v.id('alertConfigs') },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
await ctx.db.delete(args.configId);
|
||||
return null;
|
||||
}
|
||||
@@ -1566,10 +1678,9 @@ export const dispararAlertasInternos = internalMutation({
|
||||
command_injection: 'Command Injection',
|
||||
nosql_injection: 'NoSQL Injection',
|
||||
xxe: 'XXE',
|
||||
man_in_the_middle: 'MITM',
|
||||
ddos: 'DDoS',
|
||||
engenharia_social: 'Engenharia Social',
|
||||
cve_exploit: 'Exploração de CVE',
|
||||
man_in_the_middle: 'MITM',
|
||||
ddos: 'DDoS',
|
||||
cve_exploit: 'Exploração de CVE',
|
||||
apt: 'APT',
|
||||
zero_day: 'Zero-Day',
|
||||
supply_chain: 'Supply Chain',
|
||||
@@ -2113,7 +2224,6 @@ async function aplicarRateLimit(
|
||||
|
||||
export const criarConfigRateLimit = mutation({
|
||||
args: {
|
||||
usuarioId: v.id('usuarios'),
|
||||
nome: v.string(),
|
||||
tipo: v.union(
|
||||
v.literal('ip'),
|
||||
@@ -2137,6 +2247,7 @@ export const criarConfigRateLimit = mutation({
|
||||
},
|
||||
returns: v.id('rateLimitConfig'),
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await assertAdmin(ctx);
|
||||
const agora = Date.now();
|
||||
const configId = await ctx.db.insert('rateLimitConfig', {
|
||||
nome: args.nome,
|
||||
@@ -2149,7 +2260,7 @@ export const criarConfigRateLimit = mutation({
|
||||
bloqueioTemporarioSegundos: args.bloqueioTemporarioSegundos,
|
||||
ativo: true,
|
||||
prioridade: args.prioridade ?? 0,
|
||||
criadoPor: args.usuarioId,
|
||||
criadoPor: usuarioAtual._id,
|
||||
atualizadoPor: undefined,
|
||||
criadoEm: agora,
|
||||
atualizadoEm: agora,
|
||||
@@ -2164,7 +2275,6 @@ export const criarConfigRateLimit = mutation({
|
||||
export const atualizarConfigRateLimit = mutation({
|
||||
args: {
|
||||
configId: v.id('rateLimitConfig'),
|
||||
usuarioId: v.id('usuarios'),
|
||||
nome: v.optional(v.string()),
|
||||
limite: v.optional(v.number()),
|
||||
janelaSegundos: v.optional(v.number()),
|
||||
@@ -2182,6 +2292,7 @@ export const atualizarConfigRateLimit = mutation({
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await assertAdmin(ctx);
|
||||
const config = await ctx.db.get(args.configId);
|
||||
if (!config) {
|
||||
throw new Error('Configuração de rate limit não encontrada');
|
||||
@@ -2201,7 +2312,7 @@ export const atualizarConfigRateLimit = mutation({
|
||||
atualizadoPor: Id<'usuarios'>;
|
||||
atualizadoEm: number;
|
||||
} = {
|
||||
atualizadoPor: args.usuarioId,
|
||||
atualizadoPor: usuarioAtual._id,
|
||||
atualizadoEm: Date.now()
|
||||
};
|
||||
|
||||
@@ -2257,6 +2368,7 @@ export const listarConfigsRateLimit = query({
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
let builder;
|
||||
if (args.tipo !== undefined) {
|
||||
const tipo = args.tipo;
|
||||
@@ -2311,7 +2423,8 @@ export const analisarRequisicaoHTTP = mutation({
|
||||
ataqueDetectado: v.boolean(),
|
||||
tipoAtaque: v.optional(ataqueValidator),
|
||||
severidade: v.optional(severidadeValidator),
|
||||
eventoId: v.optional(v.id('securityEvents'))
|
||||
eventoId: v.optional(v.id('securityEvents')),
|
||||
bloqueadoAutomatico: v.optional(v.boolean())
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Combinar todos os dados da requisição para análise
|
||||
@@ -2336,13 +2449,38 @@ export const analisarRequisicaoHTTP = mutation({
|
||||
ataqueDetectado: false,
|
||||
tipoAtaque: undefined,
|
||||
severidade: undefined,
|
||||
eventoId: undefined
|
||||
eventoId: undefined,
|
||||
bloqueadoAutomatico: undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Calcular severidade
|
||||
const severidade = calcularSeveridade(tipoAtaque, undefined, undefined);
|
||||
|
||||
// Verificar configuração de bloqueio automático
|
||||
const autoBlockConfig = await ctx.db
|
||||
.query('autoBlockConfig')
|
||||
.withIndex('by_tipo', (q) => q.eq('tipoAtaque', tipoAtaque))
|
||||
.filter((q) => q.eq(q.field('ativo'), true))
|
||||
.first();
|
||||
|
||||
let bloqueadoAutomatico = false;
|
||||
let bloqueadoAte: number | undefined = undefined;
|
||||
|
||||
// Aplicar bloqueio automático se configurado
|
||||
if (
|
||||
autoBlockConfig &&
|
||||
autoBlockConfig.bloquearAutomatico &&
|
||||
SEVERIDADE_SCORE[severidade] >= SEVERIDADE_SCORE[autoBlockConfig.severidadeMinima]
|
||||
) {
|
||||
bloqueadoAutomatico = true;
|
||||
|
||||
// Calcular data de expiração se duração definida
|
||||
if (autoBlockConfig.duracaoBloqueioSegundos) {
|
||||
bloqueadoAte = Date.now() + autoBlockConfig.duracaoBloqueioSegundos * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// Permitir que o chamador informe o destino/protocolo via query string em cenários de dev/teste
|
||||
const destinoIp =
|
||||
(args.queryParams &&
|
||||
@@ -2353,13 +2491,20 @@ export const analisarRequisicaoHTTP = mutation({
|
||||
// Registrar evento de segurança
|
||||
const referencia = `http_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||
const agora = Date.now();
|
||||
const tags = ['detecção_automática', 'http', tipoAtaque];
|
||||
if (bloqueadoAutomatico) {
|
||||
tags.push('bloqueio_automatico');
|
||||
}
|
||||
|
||||
const eventoId = await ctx.db.insert('securityEvents', {
|
||||
referencia,
|
||||
timestamp: agora,
|
||||
tipoAtaque,
|
||||
severidade,
|
||||
status: statusInicial(severidade),
|
||||
descricao: `Ataque ${tipoAtaque} detectado na requisição HTTP ${args.method} ${args.url}`,
|
||||
descricao: `Ataque ${tipoAtaque} detectado na requisição HTTP ${args.method} ${args.url}${
|
||||
bloqueadoAutomatico ? ' (BLOQUEADO AUTOMATICAMENTE)' : ''
|
||||
}`,
|
||||
origemIp: args.ipOrigem,
|
||||
protocolo,
|
||||
transporte: 'tcp',
|
||||
@@ -2370,28 +2515,30 @@ export const analisarRequisicaoHTTP = mutation({
|
||||
}
|
||||
: undefined,
|
||||
destinoIp: destinoIp ?? undefined,
|
||||
tags: ['detecção_automática', 'http', tipoAtaque],
|
||||
tags,
|
||||
atualizadoEm: agora
|
||||
});
|
||||
|
||||
// Ajustar reputação do IP se fornecido
|
||||
if (args.ipOrigem) {
|
||||
const delta = SEVERIDADE_SCORE[severidade] * -10; // Penalidade baseada na severidade
|
||||
await ajustarReputacao(
|
||||
ctx,
|
||||
args.ipOrigem,
|
||||
'ip',
|
||||
delta,
|
||||
severidade,
|
||||
severidade === 'critico' || severidade === 'alto' ? { blacklist: true } : undefined
|
||||
);
|
||||
|
||||
// Se bloqueio automático ativo, aplicar blacklist
|
||||
const opcoesBlacklist = bloqueadoAutomatico
|
||||
? { blacklist: true, bloqueadoAte }
|
||||
: severidade === 'critico' || severidade === 'alto'
|
||||
? { blacklist: true }
|
||||
: undefined;
|
||||
|
||||
await ajustarReputacao(ctx, args.ipOrigem, 'ip', delta, severidade, opcoesBlacklist);
|
||||
}
|
||||
|
||||
return {
|
||||
ataqueDetectado: true,
|
||||
tipoAtaque,
|
||||
severidade,
|
||||
eventoId
|
||||
eventoId,
|
||||
bloqueadoAutomatico
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -2531,6 +2678,7 @@ export const criarEventosTeste = mutation({
|
||||
eventosIds: v.array(v.id('securityEvents'))
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
assertDevOnly();
|
||||
const quantidade = args.quantidade ?? 10;
|
||||
const eventosIds: Id<'securityEvents'>[] = [];
|
||||
const agora = Date.now();
|
||||
@@ -2701,11 +2849,11 @@ export const monitorarLogsLogin = internalMutation({
|
||||
|
||||
export const deletarConfigRateLimit = mutation({
|
||||
args: {
|
||||
configId: v.id('rateLimitConfig'),
|
||||
usuarioId: v.id('usuarios')
|
||||
configId: v.id('rateLimitConfig')
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
const config = await ctx.db.get(args.configId);
|
||||
if (!config) {
|
||||
throw new Error('Configuração de rate limit não encontrada');
|
||||
@@ -2723,8 +2871,9 @@ export const seedRateLimitDev = mutation({
|
||||
criadosOuAtualizados: v.number()
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
assertDevOnly();
|
||||
let count = 0;
|
||||
// Obter um usuário existente para campos de auditoria
|
||||
// Em dev, usar qualquer usuário existente para auditoria
|
||||
const algumUsuario = await ctx.db.query('usuarios').order('asc').take(1);
|
||||
if (algumUsuario.length === 0) {
|
||||
throw new Error('Seed de rate limit: nenhum usuário encontrado para auditoria (criadoPor).');
|
||||
@@ -2836,6 +2985,7 @@ export const limparEventosTeste = mutation({
|
||||
args: {},
|
||||
returns: v.object({ removidos: v.number() }),
|
||||
handler: async (ctx) => {
|
||||
assertDevOnly();
|
||||
const docs = await ctx.db
|
||||
.query('securityEvents')
|
||||
.withIndex('by_timestamp', (q) => q.gte('timestamp', 0))
|
||||
@@ -2855,11 +3005,11 @@ export const limparEventosTeste = mutation({
|
||||
// Deletar regra de porta
|
||||
export const deletarRegraPorta = mutation({
|
||||
args: {
|
||||
regraId: v.id('portRules'),
|
||||
usuarioId: v.id('usuarios')
|
||||
regraId: v.id('portRules')
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
const regra = await ctx.db.get(args.regraId);
|
||||
if (!regra) {
|
||||
throw new Error('Regra de porta não encontrada');
|
||||
@@ -2868,3 +3018,220 @@ export const deletarRegraPorta = mutation({
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// CONFIGURAÇÃO DE BLOQUEIO AUTOMÁTICO
|
||||
// ============================================
|
||||
|
||||
// Criar configuração de bloqueio automático
|
||||
export const criarAutoBlockConfig = mutation({
|
||||
args: {
|
||||
tipoAtaque: ataqueValidator,
|
||||
bloquearAutomatico: v.boolean(),
|
||||
severidadeMinima: severidadeValidator,
|
||||
duracaoBloqueioSegundos: v.optional(v.number()),
|
||||
ativo: v.boolean()
|
||||
},
|
||||
returns: v.id('autoBlockConfig'),
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await assertAdmin(ctx);
|
||||
const agora = Date.now();
|
||||
|
||||
// Verificar se já existe configuração para este tipo de ataque
|
||||
const existente = await ctx.db
|
||||
.query('autoBlockConfig')
|
||||
.withIndex('by_tipo', (q) => q.eq('tipoAtaque', args.tipoAtaque))
|
||||
.first();
|
||||
|
||||
if (existente) {
|
||||
throw new Error(`Já existe uma configuração para o tipo de ataque ${args.tipoAtaque}`);
|
||||
}
|
||||
|
||||
// Validação: não permitir bloqueio permanente para severidade baixa
|
||||
if (
|
||||
args.bloquearAutomatico &&
|
||||
!args.duracaoBloqueioSegundos &&
|
||||
(args.severidadeMinima === 'informativo' || args.severidadeMinima === 'baixo')
|
||||
) {
|
||||
throw new Error(
|
||||
'Bloqueio permanente só é permitido para severidade moderada, alta ou crítica'
|
||||
);
|
||||
}
|
||||
|
||||
const configId = await ctx.db.insert('autoBlockConfig', {
|
||||
tipoAtaque: args.tipoAtaque,
|
||||
bloquearAutomatico: args.bloquearAutomatico,
|
||||
severidadeMinima: args.severidadeMinima,
|
||||
duracaoBloqueioSegundos: args.duracaoBloqueioSegundos,
|
||||
ativo: args.ativo,
|
||||
criadoPor: usuarioAtual._id,
|
||||
atualizadoPor: undefined,
|
||||
criadoEm: agora,
|
||||
atualizadoEm: agora
|
||||
});
|
||||
|
||||
return configId;
|
||||
}
|
||||
});
|
||||
|
||||
// Atualizar configuração de bloqueio automático
|
||||
export const atualizarAutoBlockConfig = mutation({
|
||||
args: {
|
||||
configId: v.id('autoBlockConfig'),
|
||||
bloquearAutomatico: v.optional(v.boolean()),
|
||||
severidadeMinima: v.optional(severidadeValidator),
|
||||
duracaoBloqueioSegundos: v.optional(v.number()),
|
||||
ativo: v.optional(v.boolean())
|
||||
},
|
||||
returns: v.id('autoBlockConfig'),
|
||||
handler: async (ctx, args) => {
|
||||
const usuarioAtual = await assertAdmin(ctx);
|
||||
const config = await ctx.db.get(args.configId);
|
||||
|
||||
if (!config) {
|
||||
throw new Error('Configuração não encontrada');
|
||||
}
|
||||
|
||||
// Validação: não permitir bloqueio permanente para severidade baixa
|
||||
const severidadeMinima = args.severidadeMinima ?? config.severidadeMinima;
|
||||
const bloquearAutomatico = args.bloquearAutomatico ?? config.bloquearAutomatico;
|
||||
const duracaoBloqueioSegundos =
|
||||
args.duracaoBloqueioSegundos !== undefined
|
||||
? args.duracaoBloqueioSegundos
|
||||
: config.duracaoBloqueioSegundos;
|
||||
|
||||
if (
|
||||
bloquearAutomatico &&
|
||||
!duracaoBloqueioSegundos &&
|
||||
(severidadeMinima === 'informativo' || severidadeMinima === 'baixo')
|
||||
) {
|
||||
throw new Error(
|
||||
'Bloqueio permanente só é permitido para severidade moderada, alta ou crítica'
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.configId, {
|
||||
bloquearAutomatico: args.bloquearAutomatico ?? config.bloquearAutomatico,
|
||||
severidadeMinima: severidadeMinima,
|
||||
duracaoBloqueioSegundos: duracaoBloqueioSegundos,
|
||||
ativo: args.ativo ?? config.ativo,
|
||||
atualizadoPor: usuarioAtual._id,
|
||||
atualizadoEm: Date.now()
|
||||
});
|
||||
|
||||
return args.configId;
|
||||
}
|
||||
});
|
||||
|
||||
// Deletar configuração de bloqueio automático
|
||||
export const deletarAutoBlockConfig = mutation({
|
||||
args: {
|
||||
configId: v.id('autoBlockConfig')
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
const config = await ctx.db.get(args.configId);
|
||||
if (!config) {
|
||||
throw new Error('Configuração não encontrada');
|
||||
}
|
||||
await ctx.db.delete(args.configId);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Listar todas as configurações de bloqueio automático
|
||||
export const listarAutoBlockConfigs = query({
|
||||
args: {
|
||||
ativo: v.optional(v.boolean()),
|
||||
limit: v.optional(v.number())
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id('autoBlockConfig'),
|
||||
tipoAtaque: ataqueValidator,
|
||||
bloquearAutomatico: v.boolean(),
|
||||
severidadeMinima: severidadeValidator,
|
||||
duracaoBloqueioSegundos: v.optional(v.number()),
|
||||
ativo: v.boolean(),
|
||||
criadoPor: v.id('usuarios'),
|
||||
atualizadoPor: v.optional(v.id('usuarios')),
|
||||
criadoEm: v.number(),
|
||||
atualizadoEm: v.number()
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
|
||||
const queryInitializer = ctx.db.query('autoBlockConfig');
|
||||
const query = args.ativo !== undefined
|
||||
? queryInitializer.withIndex('by_ativo', (q) => {
|
||||
const ativoValue: boolean = args.ativo!;
|
||||
return q.eq('ativo', ativoValue);
|
||||
})
|
||||
: queryInitializer;
|
||||
|
||||
const configs = await query.order('desc').take(args.limit ?? 100);
|
||||
|
||||
return configs.map((config) => ({
|
||||
_id: config._id,
|
||||
tipoAtaque: config.tipoAtaque,
|
||||
bloquearAutomatico: config.bloquearAutomatico,
|
||||
severidadeMinima: config.severidadeMinima,
|
||||
duracaoBloqueioSegundos: config.duracaoBloqueioSegundos,
|
||||
ativo: config.ativo,
|
||||
criadoPor: config.criadoPor,
|
||||
atualizadoPor: config.atualizadoPor,
|
||||
criadoEm: config.criadoEm,
|
||||
atualizadoEm: config.atualizadoEm
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Obter configuração de bloqueio automático por tipo de ataque
|
||||
export const obterAutoBlockConfig = query({
|
||||
args: {
|
||||
tipoAtaque: ataqueValidator
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
_id: v.id('autoBlockConfig'),
|
||||
tipoAtaque: ataqueValidator,
|
||||
bloquearAutomatico: v.boolean(),
|
||||
severidadeMinima: severidadeValidator,
|
||||
duracaoBloqueioSegundos: v.optional(v.number()),
|
||||
ativo: v.boolean(),
|
||||
criadoPor: v.id('usuarios'),
|
||||
atualizadoPor: v.optional(v.id('usuarios')),
|
||||
criadoEm: v.number(),
|
||||
atualizadoEm: v.number()
|
||||
}),
|
||||
v.null()
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
await assertAdmin(ctx);
|
||||
|
||||
const config = await ctx.db
|
||||
.query('autoBlockConfig')
|
||||
.withIndex('by_tipo', (q) => q.eq('tipoAtaque', args.tipoAtaque))
|
||||
.filter((q) => q.eq(q.field('ativo'), true))
|
||||
.first();
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
_id: config._id,
|
||||
tipoAtaque: config.tipoAtaque,
|
||||
bloquearAutomatico: config.bloquearAutomatico,
|
||||
severidadeMinima: config.severidadeMinima,
|
||||
duracaoBloqueioSegundos: config.duracaoBloqueioSegundos,
|
||||
ativo: config.ativo,
|
||||
criadoPor: config.criadoPor,
|
||||
atualizadoPor: config.atualizadoPor,
|
||||
criadoEm: config.criadoEm,
|
||||
atualizadoEm: config.atualizadoEm
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ export const ataqueCiberneticoTipo = v.union(
|
||||
v.literal('xxe'),
|
||||
v.literal('man_in_the_middle'),
|
||||
v.literal('ddos'),
|
||||
v.literal('engenharia_social'),
|
||||
v.literal('cve_exploit'),
|
||||
v.literal('apt'),
|
||||
v.literal('zero_day'),
|
||||
@@ -326,5 +325,19 @@ export const securityTables = {
|
||||
})
|
||||
.index('by_status', ['status'])
|
||||
.index('by_solicitante', ['solicitanteId', 'status'])
|
||||
.index('by_criado_em', ['criadoEm'])
|
||||
.index('by_criado_em', ['criadoEm']),
|
||||
|
||||
autoBlockConfig: defineTable({
|
||||
tipoAtaque: ataqueCiberneticoTipo,
|
||||
bloquearAutomatico: v.boolean(),
|
||||
severidadeMinima: severidadeSeguranca,
|
||||
duracaoBloqueioSegundos: v.optional(v.number()),
|
||||
ativo: v.boolean(),
|
||||
criadoPor: v.id('usuarios'),
|
||||
atualizadoPor: v.optional(v.id('usuarios')),
|
||||
criadoEm: v.number(),
|
||||
atualizadoEm: v.number()
|
||||
})
|
||||
.index('by_tipo', ['tipoAtaque'])
|
||||
.index('by_ativo', ['ativo'])
|
||||
};
|
||||
|
||||
@@ -212,6 +212,10 @@ export const systemTables = {
|
||||
roomPrefix: v.string(), // Prefixo para nomes de salas
|
||||
useHttps: v.boolean(), // Usar HTTPS
|
||||
acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento)
|
||||
ambiente: v.optional(v.string()), // Ambiente da configuração (ex: "desenvolvimento", "staging", "producao")
|
||||
jwtSecret: v.optional(v.string()), // JWT Secret criptografado (usado para gerar tokens JWT)
|
||||
jwtAudience: v.optional(v.string()), // Audience do JWT (padrão: o próprio domínio)
|
||||
jwtIssuer: v.optional(v.string()), // Issuer do JWT (padrão: appId)
|
||||
ativo: v.boolean(), // Configuração ativa
|
||||
testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão
|
||||
configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker
|
||||
@@ -223,7 +227,10 @@ export const systemTables = {
|
||||
sshUsername: v.optional(v.string()), // Usuário SSH para acesso ao servidor
|
||||
sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada)
|
||||
sshPort: v.optional(v.number()) // Porta SSH (padrão: 22)
|
||||
}).index('by_ativo', ['ativo']),
|
||||
})
|
||||
.index('by_ativo', ['ativo'])
|
||||
.index('by_ambiente', ['ambiente'])
|
||||
.index('by_ativo_ambiente', ['ativo', 'ambiente']),
|
||||
|
||||
// Logs de Erros do Servidor (500, etc)
|
||||
errosServidor: defineTable({
|
||||
|
||||
@@ -48,9 +48,26 @@ export const obterPorId = query({
|
||||
const membros = await Promise.all(
|
||||
membrosRelacoes.map(async (rel) => {
|
||||
const funcionario = await ctx.db.get(rel.funcionarioId);
|
||||
// Buscar foto do perfil do funcionário através do usuário associado
|
||||
let fotoPerfilUrl: string | null = null;
|
||||
if (funcionario) {
|
||||
const usuario = await ctx.db
|
||||
.query('usuarios')
|
||||
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
|
||||
.first();
|
||||
if (usuario?.fotoPerfil) {
|
||||
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...rel,
|
||||
funcionario
|
||||
funcionario: funcionario
|
||||
? {
|
||||
...funcionario,
|
||||
fotoPerfilUrl
|
||||
}
|
||||
: null
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user