feat: enhance login process with IP capture and improved error handling

- Implemented an internal mutation for login that captures the user's IP address and user agent for better security and tracking.
- Enhanced the HTTP login endpoint to extract and log client IP, improving the overall authentication process.
- Added validation for IP addresses to ensure only valid formats are recorded, enhancing data integrity.
- Updated the login mutation to handle rate limiting and user status checks more effectively, providing clearer feedback on login attempts.
This commit is contained in:
2025-11-04 03:26:34 -03:00
parent f278ad4d17
commit c6c88f85a7
11 changed files with 3531 additions and 70 deletions

View File

@@ -0,0 +1,189 @@
<script lang="ts">
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
interface Props {
value?: string; // Id do funcionário selecionado
placeholder?: string;
disabled?: boolean;
required?: boolean;
}
let {
value = $bindable(),
placeholder = "Selecione um funcionário",
disabled = false,
required = false,
}: Props = $props();
let busca = $state("");
let mostrarDropdown = $state(false);
// Buscar funcionários
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const funcionarios = $derived(
funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []
);
// Filtrar funcionários baseado na busca
const funcionariosFiltrados = $derived.by(() => {
if (!busca.trim()) return funcionarios;
const termo = busca.toLowerCase().trim();
return funcionarios.filter((f) => {
const nomeMatch = f.nome?.toLowerCase().includes(termo);
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
const cpfMatch = f.cpf?.replace(/\D/g, "").includes(termo.replace(/\D/g, ""));
return nomeMatch || matriculaMatch || cpfMatch;
});
});
// Funcionário selecionado
const funcionarioSelecionado = $derived.by(() => {
if (!value) return null;
return funcionarios.find((f) => f._id === value);
});
function selecionarFuncionario(funcionarioId: string) {
value = funcionarioId;
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
busca = funcionario?.nome || "";
mostrarDropdown = false;
}
function limpar() {
value = undefined;
busca = "";
mostrarDropdown = false;
}
// Atualizar busca quando funcionário selecionado mudar externamente
$effect(() => {
if (value && !busca) {
const funcionario = funcionarios.find((f) => f._id === value);
busca = funcionario?.nome || "";
}
});
function handleFocus() {
if (!disabled) {
mostrarDropdown = true;
}
}
function handleBlur() {
// Delay para permitir click no dropdown
setTimeout(() => {
mostrarDropdown = false;
}, 200);
}
</script>
<div class="form-control w-full relative">
<label class="label">
<span class="label-text font-medium">
Funcionário
{#if required}
<span class="text-error">*</span>
{/if}
</span>
</label>
<div class="relative">
<input
type="text"
bind:value={busca}
{placeholder}
{disabled}
onfocus={handleFocus}
onblur={handleBlur}
class="input input-bordered w-full pr-10"
autocomplete="off"
/>
{#if value}
<button
type="button"
onclick={limpar}
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
disabled={disabled}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{:else}
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-base-content/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{/if}
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
<div
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto"
>
{#each funcionariosFiltrados as funcionario}
<button
type="button"
onclick={() => selecionarFuncionario(funcionario._id)}
class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors border-b border-base-200 last:border-b-0"
>
<div class="font-medium">{funcionario.nome}</div>
<div class="text-sm text-base-content/60">
{#if funcionario.matricula}
Matrícula: {funcionario.matricula}
{/if}
{#if funcionario.descricaoCargo}
{funcionario.matricula ? " • " : ""}
{funcionario.descricaoCargo}
{/if}
</div>
</button>
{/each}
</div>
{/if}
{#if mostrarDropdown && busca && funcionariosFiltrados.length === 0}
<div
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg p-4 text-center text-base-content/60"
>
Nenhum funcionário encontrado
</div>
{/if}
</div>
{#if funcionarioSelecionado}
<div class="text-xs text-base-content/60 mt-1">
Selecionado: {funcionarioSelecionado.nome}
{#if funcionarioSelecionado.matricula}
- {funcionarioSelecionado.matricula}
{/if}
</div>
{/if}
</div>

View File

@@ -101,7 +101,8 @@
carregandoLogin = true;
try {
// Capturar informações do navegador
// Usar mutation normal com WebRTC para capturar IP
// getBrowserInfo() tenta obter o IP local via WebRTC
const browserInfo = await getBrowserInfo();
const resultado = await convex.mutation(api.autenticacao.login, {

View File

@@ -14,7 +14,64 @@ export function getUserAgent(): string {
}
/**
* Tenta obter o IP local usando WebRTC
* Valida se uma string tem formato de IP válido
*/
function isValidIPFormat(ip: string): boolean {
if (!ip || ip.length < 7) return false; // IP mínimo: "1.1.1.1" = 7 chars
// Validar IPv4
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(ip)) {
const parts = ip.split('.');
return parts.length === 4 && parts.every(part => {
const num = parseInt(part, 10);
return !isNaN(num) && num >= 0 && num <= 255;
});
}
// Validar IPv6 básico (formato simplificado)
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$|^::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,6}$|^[0-9a-fA-F]{0,4}::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,5}$/;
if (ipv6Regex.test(ip)) {
return true;
}
return false;
}
/**
* Verifica se um IP é local/privado
*/
function isLocalIP(ip: string): boolean {
// IPs locais/privados
return (
ip.startsWith('127.') ||
ip.startsWith('192.168.') ||
ip.startsWith('10.') ||
ip.startsWith('172.16.') ||
ip.startsWith('172.17.') ||
ip.startsWith('172.18.') ||
ip.startsWith('172.19.') ||
ip.startsWith('172.20.') ||
ip.startsWith('172.21.') ||
ip.startsWith('172.22.') ||
ip.startsWith('172.23.') ||
ip.startsWith('172.24.') ||
ip.startsWith('172.25.') ||
ip.startsWith('172.26.') ||
ip.startsWith('172.27.') ||
ip.startsWith('172.28.') ||
ip.startsWith('172.29.') ||
ip.startsWith('172.30.') ||
ip.startsWith('172.31.') ||
ip.startsWith('169.254.') || // Link-local
ip === '::1' ||
ip.startsWith('fe80:') // IPv6 link-local
);
}
/**
* Tenta obter o IP usando WebRTC
* Prioriza IP público, mas retorna IP local se não encontrar
* Esta função não usa API externa, mas pode falhar em alguns navegadores
* Retorna undefined se não conseguir obter
*/
@@ -32,31 +89,87 @@ export async function getLocalIP(): Promise<string | undefined> {
});
let resolved = false;
let foundIPs: string[] = [];
let publicIP: string | undefined = undefined;
let localIP: string | undefined = undefined;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
pc.close();
resolve(undefined);
// Priorizar IP público, mas retornar local se não houver
resolve(publicIP || localIP || undefined);
}
}, 3000);
}, 5000); // Aumentar timeout para 5 segundos
pc.onicecandidate = (event) => {
if (event.candidate && !resolved) {
const candidate = event.candidate.candidate;
// Regex para extrair IP local (IPv4)
const ipMatch = candidate.match(/([0-9]{1,3}(\.[0-9]{1,3}){3})/);
if (ipMatch && ipMatch[1]) {
const ip = ipMatch[1];
// Verificar se não é IP localhost (127.0.0.1 ou ::1)
if (!ip.startsWith('127.') && !ip.startsWith('::1')) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
pc.close();
resolve(ip);
// Regex mais rigorosa para IPv4 - deve ser um IP completo e válido
// Formato: X.X.X.X onde X é 0-255
const ipv4Match = candidate.match(/\b([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\b/);
// Regex para IPv6 - mais específica
const ipv6Match = candidate.match(/\b([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){2,7}|::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,6}|[0-9a-fA-F]{1,4}::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,5})\b/);
let ip: string | undefined = undefined;
if (ipv4Match && ipv4Match[1]) {
const candidateIP = ipv4Match[1];
// Validar se cada octeto está entre 0-255
const parts = candidateIP.split('.');
if (parts.length === 4 && parts.every(part => {
const num = parseInt(part, 10);
return !isNaN(num) && num >= 0 && num <= 255;
})) {
ip = candidateIP;
}
} else if (ipv6Match && ipv6Match[1]) {
// Validar formato básico de IPv6
const candidateIP = ipv6Match[1];
if (candidateIP.includes(':') && candidateIP.length >= 3) {
ip = candidateIP;
}
}
// Validar se o IP é válido antes de processar
if (ip && isValidIPFormat(ip) && !foundIPs.includes(ip)) {
foundIPs.push(ip);
// Ignorar localhost
if (ip.startsWith('127.') || ip === '::1') {
return;
}
// Separar IPs públicos e locais
if (isLocalIP(ip)) {
if (!localIP) {
localIP = ip;
}
} else {
// IP público encontrado!
if (!publicIP) {
publicIP = ip;
// Se encontrou IP público, podemos resolver mais cedo
if (!resolved) {
resolved = true;
clearTimeout(timeout);
pc.close();
resolve(publicIP);
}
}
}
}
} else if (event.candidate === null) {
// No more candidates
if (!resolved) {
resolved = true;
clearTimeout(timeout);
pc.close();
// Retornar IP público se encontrou, senão local
resolve(publicIP || localIP || undefined);
}
}
};
@@ -69,10 +182,11 @@ export async function getLocalIP(): Promise<string | undefined> {
resolved = true;
clearTimeout(timeout);
pc.close();
resolve(undefined);
resolve(publicIP || localIP || undefined);
}
});
} catch (error) {
console.warn("Erro ao obter IP via WebRTC:", error);
resolve(undefined);
}
});