import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import { hashPassword, verifyPassword, generateToken, validarMatricula, validarSenha, } from "./auth/utils"; import { registrarLogin } from "./logsLogin"; import { Id } from "./_generated/dataModel"; import type { QueryCtx } from "./_generated/server"; /** * Helper para verificar se usuário está bloqueado */ async function verificarBloqueioUsuario(ctx: QueryCtx, usuarioId: Id<"usuarios">) { const bloqueio = await ctx.db .query("bloqueiosUsuarios") .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioId)) .filter((q) => q.eq(q.field("ativo"), true)) .first(); return bloqueio !== null; } /** * Helper para verificar rate limiting por IP */ async function verificarRateLimitIP(ctx: QueryCtx, ipAddress: string) { // Últimas 15 minutos const dataLimite = Date.now() - 15 * 60 * 1000; const tentativas = await ctx.db .query("logsLogin") .withIndex("by_ip", (q) => q.eq("ipAddress", ipAddress)) .filter((q) => q.gte(q.field("timestamp"), dataLimite)) .collect(); const falhas = tentativas.filter((t) => !t.sucesso).length; // Bloquear se 5 ou mais tentativas falhas em 15 minutos return falhas >= 5; } /** * Login do usuário (aceita matrícula OU email) */ export const login = mutation({ args: { matriculaOuEmail: v.string(), // Aceita matrícula ou email senha: v.string(), ipAddress: v.optional(v.string()), userAgent: v.optional(v.string()), }, returns: v.union( v.object({ sucesso: v.literal(true), token: v.string(), usuario: v.object({ _id: v.id("usuarios"), matricula: v.string(), nome: v.string(), email: v.string(), role: v.object({ _id: v.id("roles"), nome: v.string(), nivel: v.number(), setor: v.optional(v.string()), }), primeiroAcesso: v.boolean(), }), }), v.object({ sucesso: v.literal(false), erro: v.string(), }) ), handler: async (ctx, args) => { // Verificar rate limiting por IP if (args.ipAddress) { const ipBloqueado = await verificarRateLimitIP(ctx, args.ipAddress); if (ipBloqueado) { await registrarLogin(ctx, { matriculaOuEmail: args.matriculaOuEmail, sucesso: false, motivoFalha: "rate_limit_excedido", ipAddress: args.ipAddress, userAgent: args.userAgent, }); return { sucesso: false as const, erro: "Muitas tentativas de login. Tente novamente em 15 minutos.", }; } } // Determinar se é email ou matrícula const isEmail = args.matriculaOuEmail.includes("@"); // Buscar usuário let usuario; if (isEmail) { usuario = await ctx.db .query("usuarios") .withIndex("by_email", (q) => q.eq("email", args.matriculaOuEmail)) .first(); } else { funcionario = await ctx.db.query("funcionarios").withIndex("by_matricula", (q) => q.eq("matricula", args.matriculaOuEmail)).first(); if (funcionario) { usuario = await ctx.db.get(funcionario.usuarioId); } } if (!usuario) { await registrarLogin(ctx, { matriculaOuEmail: args.matriculaOuEmail, sucesso: false, motivoFalha: "usuario_inexistente", ipAddress: args.ipAddress, userAgent: args.userAgent, }); return { sucesso: false as const, erro: "Credenciais incorretas.", }; } // Verificar se usuário está bloqueado if ( usuario.bloqueado || (await verificarBloqueioUsuario(ctx, usuario._id)) ) { await registrarLogin(ctx, { usuarioId: usuario._id, matriculaOuEmail: args.matriculaOuEmail, sucesso: false, motivoFalha: "usuario_bloqueado", ipAddress: args.ipAddress, userAgent: args.userAgent, }); return { sucesso: false as const, erro: "Usuário bloqueado. Entre em contato com o TI.", }; } // Verificar se usuário está ativo if (!usuario.ativo) { await registrarLogin(ctx, { usuarioId: usuario._id, matriculaOuEmail: args.matriculaOuEmail, sucesso: false, motivoFalha: "usuario_inativo", ipAddress: args.ipAddress, userAgent: args.userAgent, }); return { sucesso: false as const, erro: "Usuário inativo. Entre em contato com o TI.", }; } // Verificar tentativas de login (bloqueio temporário) const tentativasRecentes = usuario.tentativasLogin || 0; const ultimaTentativa = usuario.ultimaTentativaLogin || 0; const tempoDecorrido = Date.now() - ultimaTentativa; const TEMPO_BLOQUEIO = 30 * 60 * 1000; // 30 minutos // Se tentou 5 vezes e ainda não passou o tempo de bloqueio if (tentativasRecentes >= 5 && tempoDecorrido < TEMPO_BLOQUEIO) { await registrarLogin(ctx, { usuarioId: usuario._id, matriculaOuEmail: args.matriculaOuEmail, sucesso: false, motivoFalha: "bloqueio_temporario", ipAddress: args.ipAddress, userAgent: args.userAgent, }); const minutosRestantes = Math.ceil( (TEMPO_BLOQUEIO - tempoDecorrido) / 60000 ); return { sucesso: false as const, erro: `Conta temporariamente bloqueada. Tente novamente em ${minutosRestantes} minutos.`, }; } // Resetar tentativas se passou o tempo de bloqueio if (tempoDecorrido > TEMPO_BLOQUEIO) { await ctx.db.patch(usuario._id, { tentativasLogin: 0, ultimaTentativaLogin: Date.now(), }); } // Verificar senha const senhaValida = await verifyPassword(args.senha, usuario.senhaHash); if (!senhaValida) { // Incrementar tentativas const novasTentativas = tempoDecorrido > TEMPO_BLOQUEIO ? 1 : tentativasRecentes + 1; await ctx.db.patch(usuario._id, { tentativasLogin: novasTentativas, ultimaTentativaLogin: Date.now(), }); await registrarLogin(ctx, { usuarioId: usuario._id, matriculaOuEmail: args.matriculaOuEmail, sucesso: false, motivoFalha: "senha_incorreta", ipAddress: args.ipAddress, userAgent: args.userAgent, }); const tentativasRestantes = 5 - novasTentativas; if (tentativasRestantes > 0) { return { sucesso: false as const, erro: `Credenciais incorretas. ${tentativasRestantes} tentativas restantes.`, }; } else { return { sucesso: false as const, erro: "Conta bloqueada por 30 minutos devido a múltiplas tentativas falhas.", }; } } // Login bem-sucedido! Resetar tentativas await ctx.db.patch(usuario._id, { tentativasLogin: 0, ultimaTentativaLogin: undefined, }); // Buscar role do usuário const role = await ctx.db.get(usuario.roleId); if (!role) { return { sucesso: false as const, erro: "Erro ao carregar permissões do usuário.", }; } // Gerar token de sessão const token = generateToken(); const agora = Date.now(); const expiraEm = agora + 8 * 60 * 60 * 1000; // 8 horas // Criar sessão await ctx.db.insert("sessoes", { usuarioId: usuario._id, token, ipAddress: args.ipAddress, userAgent: args.userAgent, criadoEm: agora, expiraEm, ativo: true, }); // Atualizar último acesso await ctx.db.patch(usuario._id, { ultimoAcesso: agora, atualizadoEm: agora, }); // Log de login bem-sucedido await registrarLogin(ctx, { usuarioId: usuario._id, matriculaOuEmail: args.matriculaOuEmail, sucesso: true, ipAddress: args.ipAddress, userAgent: args.userAgent, }); await ctx.db.insert("logsAcesso", { usuarioId: usuario._id, tipo: "login", ipAddress: args.ipAddress, userAgent: args.userAgent, detalhes: "Login realizado com sucesso", timestamp: agora, }); return { sucesso: true as const, token, usuario: { _id: usuario._id, matricula: usuario.matricula, nome: usuario.nome, email: usuario.email, funcionarioId: usuario.funcionarioId, role: { _id: role._id, nome: role.nome, nivel: role.nivel, setor: role.setor, }, primeiroAcesso: usuario.primeiroAcesso, }, }; }, }); /** * Logout do usuário */ export const logout = mutation({ args: { token: v.string(), }, returns: v.null(), handler: async (ctx, args) => { // Buscar sessão const sessao = await ctx.db .query("sessoes") .withIndex("by_token", (q) => q.eq("token", args.token)) .first(); if (sessao) { // Desativar sessão await ctx.db.patch(sessao._id, { ativo: false, }); // Log de logout await ctx.db.insert("logsAcesso", { usuarioId: sessao.usuarioId, tipo: "logout", timestamp: Date.now(), }); } return null; }, }); /** * Verificar se token é válido e retornar usuário */ export const verificarSessao = query({ args: { token: v.string(), }, returns: v.union( v.object({ valido: v.literal(true), usuario: v.object({ _id: v.id("usuarios"), matricula: v.string(), nome: v.string(), email: v.string(), role: v.object({ _id: v.id("roles"), nome: v.string(), nivel: v.number(), setor: v.optional(v.string()), }), primeiroAcesso: v.boolean(), }), }), v.object({ valido: v.literal(false), motivo: v.optional(v.string()), }) ), handler: async (ctx, args) => { // Buscar sessão const sessao = await ctx.db .query("sessoes") .withIndex("by_token", (q) => q.eq("token", args.token)) .first(); if (!sessao || !sessao.ativo) { return { valido: false as const, motivo: "Sessão não encontrada ou inativa", }; } // Verificar se sessão expirou if (sessao.expiraEm < Date.now()) { // Não podemos fazer patch/insert em uma query // A expiração será tratada por uma mutation separada return { valido: false as const, motivo: "Sessão expirada" }; } // Buscar usuário const usuario = await ctx.db.get(sessao.usuarioId); if (!usuario || !usuario.ativo) { return { valido: false as const, motivo: "Usuário não encontrado ou inativo", }; } // Buscar role const role = await ctx.db.get(usuario.roleId); if (!role) { return { valido: false as const, motivo: "Role não encontrada" }; } return { valido: true as const, usuario: { _id: usuario._id, matricula: usuario.matricula, nome: usuario.nome, email: usuario.email, role: { _id: role._id, nome: role.nome, nivel: role.nivel, setor: role.setor, }, primeiroAcesso: usuario.primeiroAcesso, }, }; }, }); /** * Limpar sessões expiradas (chamada periodicamente) */ export const limparSessoesExpiradas = mutation({ args: {}, returns: v.object({ sessoesLimpas: v.number(), }), handler: async (ctx) => { const agora = Date.now(); const sessoes = await ctx.db .query("sessoes") .withIndex("by_ativo", (q) => q.eq("ativo", true)) .collect(); let sessoesLimpas = 0; for (const sessao of sessoes) { if (sessao.expiraEm < agora) { await ctx.db.patch(sessao._id, { ativo: false }); await ctx.db.insert("logsAcesso", { usuarioId: sessao.usuarioId, tipo: "sessao_expirada", timestamp: agora, }); sessoesLimpas++; } } return { sessoesLimpas }; }, }); /** * Alterar senha (primeiro acesso ou reset) */ export const alterarSenha = mutation({ args: { token: v.string(), senhaAtual: v.optional(v.string()), novaSenha: v.string(), }, returns: v.union( v.object({ sucesso: v.literal(true) }), v.object({ sucesso: v.literal(false), erro: v.string() }) ), handler: async (ctx, args) => { // Verificar sessão const sessao = await ctx.db .query("sessoes") .withIndex("by_token", (q) => q.eq("token", args.token)) .first(); if (!sessao || !sessao.ativo) { return { sucesso: false as const, erro: "Sessão inválida" }; } const usuario = await ctx.db.get(sessao.usuarioId); if (!usuario) { return { sucesso: false as const, erro: "Usuário não encontrado" }; } // Se não for primeiro acesso, verificar senha atual if (!usuario.primeiroAcesso && args.senhaAtual) { const senhaAtualValida = await verifyPassword( args.senhaAtual, usuario.senhaHash ); if (!senhaAtualValida) { return { sucesso: false as const, erro: "Senha atual incorreta" }; } } // Validar nova senha if (!validarSenha(args.novaSenha)) { return { sucesso: false as const, erro: "Senha deve ter no mínimo 8 caracteres, incluindo letras, números e símbolos", }; } // Gerar hash da nova senha const novoHash = await hashPassword(args.novaSenha); // Atualizar senha await ctx.db.patch(usuario._id, { senhaHash: novoHash, primeiroAcesso: false, atualizadoEm: Date.now(), }); // Log await ctx.db.insert("logsAcesso", { usuarioId: usuario._id, tipo: "senha_alterada", timestamp: Date.now(), }); return { sucesso: true as const }; }, });