Files
sgse-app/packages/backend/convex/autenticacao.ts

515 lines
13 KiB
TypeScript

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";
/**
* Helper para verificar se usuário está bloqueado
*/
async function verificarBloqueioUsuario(ctx: any, usuarioId: Id<"usuarios">) {
const bloqueio = await ctx.db
.query("bloqueiosUsuarios")
.withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioId))
.filter((q: any) => q.eq(q.field("ativo"), true))
.first();
return bloqueio !== null;
}
/**
* Helper para verificar rate limiting por IP
*/
async function verificarRateLimitIP(ctx: any, ipAddress: string) {
// Últimas 15 minutos
const dataLimite = Date.now() - 15 * 60 * 1000;
const tentativas = await ctx.db
.query("logsLogin")
.withIndex("by_ip", (q: any) => q.eq("ipAddress", ipAddress))
.filter((q: any) => q.gte(q.field("timestamp"), dataLimite))
.collect();
const falhas = tentativas.filter((t: any) => !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 {
usuario = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matriculaOuEmail))
.first();
}
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,
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 };
},
});