Alguém quer mostrar um projeto para um cliente que não tem conta. A solução óbvia é um código curto: seis dígitos, expira em cinco minutos, o convidado digita e entra. Implementei assim numa ferramenta de notas e arquivos compartilhados, e funcionou desde o primeiro dia. O problema não foi não funcionar. Foi que três meses depois tive que refazer aquilo duas vezes.
Três iterações, e a terceira é a que deveria ter sido a primeira.
O que um código curto faz e o que não faz
A primeira tabela era literalmente isto:
CREATE TABLE guest_access_codes (
id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
user_id text NOT NULL,
code char(6) NOT NULL,
permission permission NOT NULL,
session_duration_minutes integer NOT NULL,
code_expires_at timestamptz NOT NULL,
is_used boolean DEFAULT false NOT NULL,
used_at timestamptz,
session_expires_at timestamptz
);
Dois vencimentos distintos, e isso esteve certo desde o início: code_expires_at é quanto o código demora a morrer se ninguém usar (cinco minutos), e session_duration_minutes é quanto dura a visita depois de entrar. São coisas diferentes e misturá-las é um erro clássico. Um código que vive oito horas no WhatsApp é um buraco; uma sessão que corta em cinco minutos é inutilizável.
Com isso você já tem permissões (read ou read_write), uma tabela ponte para decidir a quais projetos o código dá acesso, e expiração. Parece completo. Não está: o código não identifica ninguém. Identifica o convite, não o convidado.
Primeira tentativa: passar o id da linha
A primeira correção foi parar de mandar o código de seis dígitos em cada requisição e mandar o id da linha. O cliente guardava um codeId, o backend o resolvia, e as requisições iam com Authorization: Bearer <codeId>.
Isso conserta uma coisa real: o código de seis dígitos para de viajar. Não está mais num log de acesso nem numa URL. Mas introduz algo pior. O codeId é um inteiro autoincremental. É adivinhável. É enumerável. Qualquer um com um id válido pode testar o de ao lado e, se aquela sessão continuar viva, entra. Troquei um segredo curto por um identificador que não é segredo nenhum.
E continua sem resolver o problema original, que era este: se duas pessoas usam o mesmo convite, compartilham o mesmo codeId. Você não pode expulsar uma sem expulsar a outra. Não pode saber qual das duas apagou o arquivo.
O que o token de sessão resolve
A terceira versão separa as duas coisas que passaram três meses juntas. O código de seis dígitos é um resgate: usa-se uma vez, no endpoint de conexão, e nunca mais. O que sai dali é um token de sessão:
const suffix = randomBytes(4).toString("hex");
const sessionToken = `${code}-${suffix}`;
await db
.update(guestAccessCodes)
.set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
.where(eq(guestAccessCodes.id, record.id));
A resposta não devolve mais codeId, devolve sessionToken. A partir daí todo o frontend o carrega: onde havia guestCodeId passou a haver guestSessionToken, e o !!guestCodeId que decidia se alguém era convidado virou !!guestSessionToken.
O que muda de verdade:
- Revogar. Colocar o
sessionTokencomo null numa linha expulsa aquela sessão e só aquela. Com o código você não tinha para quem apontar. - Auditar. O token está na linha, então cada ação pode ser atribuída a um resgate concreto e a um horário concreto.
- Não adivinhável. Já não é um inteiro sequencial. Quatro bytes aleatórios em hexadecimal não se enumeram.
- Um único lugar onde expira. O
sessionExpiresAté escrito no mesmo update que emite o token. Não há dois relógios que possam dessincronizar.
O que a auditoria encontrou, e eu não
Meses depois passei uma auditoria no código. Saiu de tudo — uma rota de exportação para PDF sem nenhum guard de autenticação é o pior da lista — mas a que mais me fez pensar fui eu mesmo que anotei no TODO: mesmo que o código de seis seja consumido, o que ele devolve é um token de seis mais quatro, e é esse que autentica.
Aí está a falha. O código curto está protegido pela vida de cinco minutos e pelo fato de só poder ser usado uma vez. O token de sessão não tem nenhuma dessas duas coisas: dura horas e é usado em cada requisição. Trocar código por token tira um vetor e abre outro, e você só percebe se olhar depois com a cabeça fria. Quatro bytes bastam contra enumeração casual, não contra alguém com oito horas e um loop.
A outra coisa que apareceu, e que é de manual, é que o endpoint de conexão não envolvia nada numa transação. Entre ler que o código não está usado e marcá-lo como usado há uma janela. Duas requisições simultâneas com o mesmo código passavam as duas.
Banir por IP é mais frágil do que parece
O contador de tentativas falhas precisa saber de qual IP vem a requisição, e é aí que quase todo mundo erra igual. Em outro projeto eu tinha banimento permanente por IP e pegava o primeiro valor de x-forwarded-for. Esse header é uma lista cliente, proxy1, proxy2: cada proxy acrescenta à direita, mas ninguém limpa o que já vinha escrito. Pegar o primeiro é pegar algo que o cliente pode inventar.
Mudei para contar a partir do final, conforme quantos proxies de confiança há na frente:
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map(s => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
Com um proxy na frente, o último valor é o que o proxy escreve e o cliente não pode tocar. Se você pega o primeiro, qualquer um escapa do banimento mandando lixo, ou pior, bane outra pessoa.
O código de seis dígitos não era a parte difícil. A parte difícil era aceitar que um convite e uma sessão são dois objetos distintos, e que misturá-los deixa você sem poder revogar, sem poder auditar e com um identificador sequencial fazendo as vezes de credencial.
