Blog

Autenticação e autorização: confundir os dois custa caro.

Autenticação responde “quem é você”. Autorização responde “você pode fazer isso”.

Todo mundo sabe a definição. Mesmo assim, a falha de segurança mais comum em API é ter autenticação impecável e autorização nenhuma.

O motivo é estrutural: autenticação é um problema resolvido, centralizado num middleware, testado uma vez. Autorização é uma decisão por endpoint, por recurso, por campo. Ela se espalha, e o que se espalha se esquece.

O formato do bug

@UseGuards(JwtAuthGuard)   // autenticação: perfeita
@Controller("invoices")
export class InvoiceController {
  @Get(":id")
  findOne(@Param("id") id: string) {
    return this.invoiceService.findById(id);   // autorização: nenhuma
  }
}

Esse endpoint exige token válido. Qualquer usuário logado consegue ler a fatura de qualquer outro trocando o ID na URL.

O guard fez o trabalho dele e a maioria dos times para por aí, porque o cadeado na porta dá a sensação de que a casa está protegida. Só que a porta separa a rua da recepção, não a recepção do cofre.

Isso tem nome, IDOR, e continua sendo o achado número um em teste de invasão de aplicação. Não porque é difícil de evitar, mas porque é fácil de esquecer.

Por que “adicionar um if” não resolve

A correção óbvia:

const invoice = await this.invoiceService.findById(id);
if (invoice.userId !== user.id) throw new ForbiddenException();
return invoice;

Funciona. E é frágil por três motivos.

Depende de alguém lembrar em cada endpoint novo. Vai falhar no décimo quinto.

Busca primeiro e valida depois, o que já vazou a informação de que aquele recurso existe, e em alguns fluxos vaza mais do que isso quando o objeto passa por log ou por cache antes da verificação.

E replica a regra em todo lugar. No dia em que “usuário vê a própria fatura” virar “usuário vê a própria fatura, e admin da organização vê todas da organização”, você tem quinze lugares pra mudar e vai achar treze.

O escopo pertence à consulta

O padrão que sobrevive é fazer a autorização parte da forma como o dado é buscado.

async findOneForUser(id: string, user: User): Promise<Invoice> {
  const invoice = await this.repo.findOne({
    where: { id, ...this.scopeFor(user) },
  });
  if (!invoice) throw new NotFoundException();
  return invoice;
}

private scopeFor(user: User) {
  if (user.hasPermission(Permission.ORG_ADMIN)) {
    return { organization: { id: user.organizationId } };
  }
  return { userId: user.id };
}

Agora a regra mora num lugar só. Um endpoint novo que use findOneForUser já nasce protegido, e um que use findById direto salta aos olhos no code review, porque a assinatura sem usuário fica visivelmente diferente do resto.

Repare também no NotFoundException em vez de ForbiddenException. Responder 403 confirma que o recurso existe, o que entrega informação a quem está sondando IDs. 404 nos dois casos não conta nada.

Papel ou permissão

Duas formas de modelar, e a escolha errada trava o produto depois.

Papel é grosso: admin, consultor, usuário. Fácil de entender, fácil de explicar, e engessa. No dia em que um cliente pedir “esse cargo aqui pode ver relatório mas não pode convidar gente”, você cria um papel novo. Depois outro. Em dois anos são catorze papéis e ninguém sabe a diferença entre três deles.

Permissão é fina: invoice.read, user.invite, report.export. O código pergunta por permissão, nunca por papel. Papel vira só um pacote de permissões, uma conveniência de administração.

// frágil: amarra a regra ao organograma
if (user.role === "admin") { ... }

// resistente: amarra a regra ao que precisa ser feito
if (user.can(Permission.INVOICE_EXPORT)) { ... }

A migração de papel pra permissão depois que o sistema cresceu é dolorosa. Começar já perguntando por permissão custa quase nada.

O contexto que quase todo mundo esquece

Em sistema multi-tenant, a permissão sozinha não basta. invoice.read significa ler qualquer fatura ou as faturas da organização daquela pessoa?

A pergunta certa tem três partes: quem, pode fazer o quê, sobre qual recurso. Deixar a terceira implícita é como o vazamento entre clientes acontece, e é o tipo de incidente que encerra contrato.

O frontend não autoriza nada

Esconder o botão é experiência do usuário. Não é controle de acesso.

Todo controle precisa existir no servidor, do jeito que existiria se o frontend não existisse. Porque, na prática, ele não existe: a pessoa que está te atacando chama a API direto.

Vale o inverso também: se o backend autoriza direito, uma tela que mostra um botão a mais é um bug de usabilidade, não uma falha de segurança. Priorize nessa ordem.

O teste que trava a regressão

Para cada regra de autorização que importa, um teste:

it("não deixa membro comum exportar relatório da organização", async () => {
  const { token } = await loginAs(memberWithoutPermission);
  const res = await request(app)
    .get(`/organizations/${org.id}/reports/export`)
    .set("Authorization", `Bearer ${token}`);

  expect(res.status).toBe(403);
});

É o único mecanismo que impede a regra de voltar a quebrar em silêncio daqui a oito meses, quando alguém refatorar o serviço achando que aquele parâmetro extra não fazia nada.

Escrevi sobre o resto do básico em como construir aplicações seguras e sobre como achar esses buracos em modelagem de ameaças em uma hora.

← Voltar pro blog