534 lines
14 KiB
TypeScript
534 lines
14 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: any) => 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 };
|
|
},
|
|
});
|