Autenticação e autorização com JWT: quem é você e o que pode fazer

Autenticação e autorização com JWT: quem é você e o que pode fazer

Quem pode fazer o quê na API da Livraria: 401 contra 403, a anatomia do JWT, assinatura simétrica e assimétrica, dotnet user-jwts, validação com JwtBearer, políticas com fallback fechado, autorização por recurso contra BOLA/IDOR e o token no OpenAPI.
Linguagem C#

• • 16 min de leitura

Até agora qualquer pessoa na rede pode apagar o catálogo da Livraria. Neste artigo a API passa a identificar quem faz cada requisição e a decidir, por políticas, o que cada um pode fazer.

Duas perguntas diferentes

Autenticação e autorização costumam aparecer juntas, abreviadas como "auth", mas respondem a perguntas diferentes:

  • Autenticação pergunta quem é você? O resultado é uma identidade: um ClaimsPrincipal com afirmações (claims) sobre o usuário, como identificador, nome, e-mail e papéis.
  • Autorização pergunta você pode fazer isto? O resultado é sim ou não, calculado a partir da identidade, do recurso e das regras.

A cada pergunta corresponde um código de status, e trocá-los é um erro comum:

  • 401 Unauthorized significa, apesar do nome, "não sei quem você é". A credencial faltou, expirou ou é inválida. A resposta traz o cabeçalho WWW-Authenticate: Bearer, que diz ao cliente como se autenticar.
  • 403 Forbidden significa "sei quem você é, e você não pode". Autenticar de novo não resolve.

No ASP.NET Core as duas etapas são middlewares separados, na ordem que vimos em "ASP.NET Core por dentro": UseAuthentication lê a credencial e preenche HttpContext.User, e UseAuthorization consulta os metadados do endpoint escolhido pelo roteamento e decide.

Sessões e tokens

Uma aplicação web tradicional autentica com cookie de sessão. O usuário faz login, o servidor guarda a sessão e o navegador reenvia o cookie a cada requisição. Isso funciona muito bem para sites, e é o que a Livraria vai usar na área administrativa em "Login, área administrativa, upload de capas e notificações em tempo real".

Uma API atende clientes que não são navegadores, como apps móveis, outros serviços e o jogo do capstone. Para ela o padrão é o bearer token: o cliente obtém um token de um emissor e o envia em todo pedido:

GET /pedidos/42
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbmEi...

"Bearer" quer dizer "portador": quem tem o token, tem o acesso. Por isso HTTPS não é opcional, e o token nunca vai em query string, onde acabaria em logs de proxy e no histórico do navegador.

Anatomia de um JWT

O JSON Web Token (RFC 7519) é o formato de token mais usado. São três partes em Base64URL separadas por pontos:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9      ← cabeçalho
.eyJzdWIiOiJhbmEiLCJyb2xlIjoiYWRtaW4i...  ← carga (claims)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ  ← assinatura

Decodificados, cabeçalho e carga são JSON comum:

{ "alg": "HS256", "typ": "JWT" }
{
  "iss": "https://auth.livraria.exemplo",
  "aud": "livraria-api",
  "sub": "ana",
  "role": "admin",
  "iat": 1790000000,
  "nbf": 1790000000,
  "exp": 1790000900
}

As claims registradas têm significado padronizado: iss (emissor), aud (a quem o token se destina), sub (o sujeito, isto é, o id do usuário), exp (expiração), nbf (não antes de), iat (emitido em) e jti (id único do token). Os tempos são segundos desde 1º de janeiro de 1970 em UTC, o chamado Unix time.

A assinatura garante integridade e autoria: qualquer alteração em um byte do cabeçalho ou da carga invalida o token. Há duas famílias de algoritmos:

  • Simétricos (HS256): uma chave secreta assina e verifica. É simples, mas quem verifica também pode emitir, então só serve quando emissor e API são o mesmo sistema.
  • Assimétricos (RS256, ES256): a chave privada assina e a pública verifica. O provedor de identidade guarda a privada e publica as públicas num endereço JWKS. É o padrão em produção, porque uma API comprometida não consegue forjar tokens.

O ponto que mais confunde: um JWT assinado não é criptografado. Qualquer um que capture o token lê a carga com um decodificador de Base64. Nunca coloque ali senha, CPF ou qualquer dado que o portador não deva ver.

A outra consequência importante é que o JWT é autossuficiente. A API valida o token com a chave e o relógio, sem consultar banco nenhum, e isso é ótimo para escalar. O preço é que um token emitido vale até expirar, e não há como "deslogar" um token em circulação sem reintroduzir uma consulta. Por isso access tokens vivem pouco, de 5 a 15 minutos, e são renovados com um refresh token, que é opaco, guardado no servidor, revogável e trocado a cada uso.

Quem emite os tokens

Emitir tokens significa guardar senhas com hash lento e sal, aplicar bloqueio contra força bruta, recuperação de conta, segundo fator, rotação de chaves e revogação. Não é algo para improvisar dentro da API de uma livraria. Em produção, a emissão fica com um provedor de identidade que fala OpenID Connect: Microsoft Entra ID, Auth0, Keycloak ou, dentro do próprio ecossistema .NET, Duende IdentityServer e OpenIddict. A API só valida.

O ASP.NET Core Identity tem, desde o .NET 8, os endpoints prontos MapIdentityApi<TUser>(), com registro, login e renovação. Os tokens que ele emite são opacos e proprietários, não JWT, e servem bem para uma SPA e sua própria API, mas não para ecossistemas com vários serviços.

Para desenvolvimento, o SDK traz a ferramenta dotnet user-jwts, que emite tokens de teste e configura a API para aceitá-los:

cd src/Livraria.Api
dotnet user-jwts create --name ana --role admin
dotnet user-jwts create --name bruno

O comando gera a chave de assinatura nos user secrets (fora do repositório), acrescenta emissor e audiência válidos ao appsettings.Development.json e imprime o token pronto para colar no cabeçalho. dotnet user-jwts print <id> --show-all mostra a carga decodificada.

Mesmo sem emitir tokens em produção, vale saber o que um emissor faz. No próximo artigo, os testes de integração vão gerar os próprios tokens exatamente assim:

using System.Security.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

public static class EmissorDeTeste
{
    public static string Emitir(SecurityKey chave, string emissor, string audiencia,
                                string usuario, params string[] papeis)
    {
        var claims = new List<Claim> { new("sub", usuario) };
        claims.AddRange(papeis.Select(p => new Claim("role", p)));

        var descritor = new SecurityTokenDescriptor
        {
            Issuer = emissor,
            Audience = audiencia,
            Subject = new ClaimsIdentity(claims),
            Expires = DateTime.UtcNow.AddMinutes(15),
            SigningCredentials = new SigningCredentials(chave, SecurityAlgorithms.HmacSha256)
        };

        return new JsonWebTokenHandler().CreateToken(descritor);
    }
}

Validando JWT na API

dotnet add src/Livraria.Api package Microsoft.AspNetCore.Authentication.JwtBearer
builder.Services.AddAuthentication().AddJwtBearer();

Desde o .NET 7, o esquema lê os parâmetros da seção Authentication:Schemes:Bearer da configuração, que é justamente onde o user-jwts escreveu:

"Authentication": {
  "Schemes": {
    "Bearer": {
      "ValidAudiences": [ "http://localhost:5080" ],
      "ValidIssuer": "dotnet-user-jwts"
    }
  }
}

Em produção a mesma seção recebe o Authority do provedor de identidade (por exemplo "Authority": "https://auth.livraria.exemplo"), e o middleware baixa sozinho, e mantém atualizadas, as chaves públicas publicadas no documento de descoberta do OpenID Connect.

Para cada requisição com Authorization: Bearer, o handler verifica:

  1. Se a assinatura confere com uma chave conhecida.
  2. Se iss é um emissor válido.
  3. Se aud inclui esta API. Sem essa checagem, um token emitido para outro serviço do mesmo provedor seria aceito aqui.
  4. Se exp e nbf estão dentro da janela, com uma tolerância (ClockSkew) de 5 minutos por padrão, que costuma ser reduzida para 30 segundos em produção.
  5. Se o algoritmo é permitido. O ataque clássico com "alg": "none" é rejeitado pelas bibliotecas modernas, mas é o motivo de não se escrever validação à mão.

Se tudo passar, as claims viram um ClaimsPrincipal em HttpContext.User. Há uma peculiaridade herdada: por padrão o handler renomeia claims curtas para as URIs longas do WS-Federation. sub vira ClaimTypes.NameIdentifier e role vira ClaimTypes.Role. Por isso usamos essas constantes no código. Muitos projetos desligam o mapeamento com MapInboundClaims = false para ver as claims como estão no token. Nesse caso é preciso indicar ao handler qual claim representa os papéis (TokenValidationParameters.RoleClaimType = "role"), senão IsInRole sempre devolve falso.

Políticas de autorização

A autorização no ASP.NET Core é baseada em políticas: regras com nome, registradas uma vez e aplicadas aos endpoints.

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("GerenciarCatalogo", p => p.RequireRole("admin"))
    .AddPolicy("Comprar", p => p.RequireAuthenticatedUser())
    .SetFallbackPolicy(new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build());

A política de fallback vale para todo endpoint que não declarou nada. Com ela a API passa a ser fechada por padrão: um endpoint novo esquecido sem anotação exige login em vez de ficar aberto ao mundo. As exceções são explícitas:

var grupo = rotas.MapGroup("/produtos").WithTags("Produtos");

grupo.MapGet("/", Listar).AllowAnonymous();
grupo.MapGet("/{id:int}", Obter).WithName("ObterProduto").AllowAnonymous();

grupo.MapPost("/livros", CriarLivro).RequireAuthorization("GerenciarCatalogo");
grupo.MapPut("/{id:int}", Atualizar).RequireAuthorization("GerenciarCatalogo");
grupo.MapDelete("/{id:int}", Remover).RequireAuthorization("GerenciarCatalogo");
var pedidos = rotas.MapGroup("/pedidos")
                   .WithTags("Pedidos")
                   .RequireAuthorization("Comprar");

No pipeline:

app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();

O WebApplication insere os dois middlewares sozinho quando os serviços estão registrados, mas escrevê-los deixa a ordem explícita, e a ordem importa assim que surgirem middlewares que dependem do usuário.

Uma política pode combinar requisitos: p.RequireRole("admin").RequireClaim("mfa", "true") exige papel e segundo fator. Prefira nomes que descrevam a capacidade (GerenciarCatalogo) aos que descrevem o cargo (SomenteAdmin). Quando o negócio decidir que o papel "editor" também pode gerenciar o catálogo, a mudança fica numa linha do registro, e nenhum endpoint precisa mudar.

Quem comprou: a identidade chega ao domínio

Com login obrigatório, o pedido passa a ter dono. Pedido ganha a propriedade ClienteId (string, o sub do token), e o ServicoDeVendas.RegistrarAsync recebe o identificador como parâmetro. Não é o cliente quem informa esse valor no corpo da requisição, porque ele poderia comprar em nome de outra pessoa. O valor vem do token:

static async Task<Created<PedidoResposta>> Registrar(
    NovoPedido dados,
    ClaimsPrincipal usuario,
    ServicoDeVendas vendas,
    CancellationToken ct)
{
    var clienteId = usuario.FindFirstValue(ClaimTypes.NameIdentifier)
        ?? throw new InvalidOperationException("Token autenticado sem 'sub'.");

    var pedido = await vendas.RegistrarAsync(
        clienteId,
        dados.Itens.Select(i => (i.ProdutoId, i.Quantidade)).ToList(),
        ct);

    return TypedResults.Created($"/pedidos/{pedido.Numero}", pedido.ParaResposta());
}

ClaimsPrincipal é um dos tipos especiais do binding e chega preenchido pelo middleware de autenticação. A camada de aplicação continua sem saber o que é HTTP ou JWT: recebe uma string. Se amanhã os pedidos vierem de uma fila, o id do cliente virá da mensagem.

Também mudam os três repositórios de pedidos: uma coluna ClienteId com índice nos bancos relacionais (uma migration por provedor, como em "EF Core: Relacionamentos e Migrations") e um campo no documento do Mongo.

Autorização baseada em recurso

"Só o dono vê o próprio pedido" não cabe numa política estática, porque a decisão depende do pedido, que só é conhecido depois de carregado. Para isso existe a autorização baseada em recurso:

public sealed class RequisitoDeDono : IAuthorizationRequirement { }

public sealed class DonoDoPedidoHandler : AuthorizationHandler<RequisitoDeDono, Pedido>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext contexto, RequisitoDeDono requisito, Pedido pedido)
    {
        var usuarioId = contexto.User.FindFirstValue(ClaimTypes.NameIdentifier);

        if (contexto.User.IsInRole("admin") || usuarioId == pedido.ClienteId)
            contexto.Succeed(requisito);

        return Task.CompletedTask;
    }
}

// registro
builder.Services.AddSingleton<IAuthorizationHandler, DonoDoPedidoHandler>();
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("DonoDoPedido", p => p.AddRequirements(new RequisitoDeDono()));
static async Task<Results<Ok<PedidoResposta>, NotFound>> ObterPedido(
    int id,
    ClaimsPrincipal usuario,
    IRepositorioPedidos repositorio,
    IAuthorizationService autorizacao,
    CancellationToken ct)
{
    var pedido = await repositorio.ObterAsync(id, ct);
    if (pedido is null)
        return TypedResults.NotFound();

    var resultado = await autorizacao.AuthorizeAsync(usuario, pedido, "DonoDoPedido");
    if (!resultado.Succeeded)
        return TypedResults.NotFound();         // não revela que o pedido existe

    return TypedResults.Ok(pedido.ParaResposta());
}

A escolha de responder 404 em vez de 403 é deliberada. Um 403 confirmaria que o pedido 42 existe e é de outra pessoa, e ids sequenciais tornariam fácil contar quantos pedidos a loja teve. Essa falha, acessar objetos alheios trocando o id na URL, tem nome próprio: BOLA/IDOR. É o primeiro item do OWASP API Security Top 10, e nenhuma política declarativa a evita. Só o código que compara o dono com o usuário resolve.

O handler é singleton e sem estado, e pode haver vários handlers para o mesmo requisito. Basta um deles chamar Succeed para a autorização passar (a menos que outro chame Fail).

O token no OpenAPI e no Scalar

Para que a interface de documentação permita colar um token, o documento declara o esquema de segurança com um document transformer:

builder.Services.AddOpenApi(opcoes =>
    opcoes.AddDocumentTransformer((documento, contexto, ct) =>
    {
        documento.Components ??= new OpenApiComponents();
        documento.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
        documento.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "bearer",
            BearerFormat = "JWT"
        };
        return Task.CompletedTask;
    }));

Os tipos exatos desse modelo mudaram entre a versão 1 da biblioteca Microsoft.OpenApi (usada até o .NET 9) e a versão 2 (usada no .NET 10). Se o compilador reclamar de IOpenApiSecurityScheme, a versão 1 usa OpenApiSecurityScheme direto no dicionário. O conceito é o mesmo.

Onde o cliente guarda o token

Um app móvel guarda o token no armazenamento seguro do sistema (Keychain, Keystore). Num navegador a resposta é menos simples. localStorage é legível por qualquer script da página, então uma única falha de XSS entrega o token. Por isso a recomendação atual para aplicações web é o padrão BFF (Backend for Frontend): o navegador conversa com o próprio servidor do site usando um cookie HttpOnly, Secure, SameSite, e esse servidor guarda os tokens e chama a API. É o desenho que o site da Livraria vai seguir na Fase 7.

Resumo de segurança

  • HTTPS em todo lugar, com HSTS em produção.
  • Chave HS256 com pelo menos 256 bits aleatórios, fora do repositório. Em produção, prefira assinatura assimétrica e um provedor de identidade.
  • Valide sempre iss, aud e exp. Os padrões do handler já fazem isso, e não os desligue "para testar".
  • Access tokens curtos, refresh tokens rotativos e revogáveis.
  • Nada sensível na carga, porque ela é legível.
  • API fechada por padrão (fallback policy), exceções anônimas explícitas.
  • Autorização por recurso em todo endpoint que recebe um id de objeto que pertence a alguém.

A Livraria agora sabe quem está do outro lado: visitantes anônimos navegam pelo catálogo, clientes autenticados compram e veem só os próprios pedidos, e administradores editam o catálogo. Tudo isso foi escrito sem nenhum teste automatizado que prove que o 401, o 403 e o 404 de pedido alheio acontecem de fato. Uma política esquecida num endpoint é o tipo de regressão que só aparece em produção.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Para cada situação, diga se a resposta correta é 401, 403 ou 404: (a) DELETE /produtos/3 sem cabeçalho Authorization; (b) o mesmo DELETE com token válido de um cliente sem papel admin; (c) GET /pedidos/42 por um cliente cujo pedido 42 não é dele; (d) POST /pedidos com token expirado há 20 minutos.

Ver resposta

✓ Resposta: (a) 401, não há credencial, e a resposta traz WWW-Authenticate: Bearer. (b) 403, a identidade é conhecida e a política GerenciarCatalogo falha. (c) 404, por decisão de projeto: a autorização por recurso falha, mas responder 403 confirmaria a existência do pedido. (d) 401, o token expirado equivale a nenhuma credencial válida, e o cabeçalho WWW-Authenticate indica error="invalid_token" para o cliente saber que deve renovar o token.

Exercício 2

Decodifique mentalmente este cenário: um atacante captura um JWT HS256 de um cliente, muda "role": "cliente" para "role": "admin" na carga, recodifica em Base64URL e envia. O que acontece? E se a API usasse a mesma chave HS256 compartilhada com três outros microsserviços?

Ver resposta

✓ Resposta: A assinatura foi calculada sobre a carga original. Com a carga alterada, o HMAC não confere, o handler rejeita o token e a resposta é 401. O atacante só conseguiria assinar a carga nova se tivesse a chave. No cenário da chave compartilhada, qualquer um dos quatro serviços que fosse comprometido teria a chave e poderia emitir tokens de admin aceitos por todos os outros, porque em HS256 verificar e emitir exigem a mesma chave. É o argumento para assinatura assimétrica: os serviços têm só a chave pública, que verifica mas não emite.

Exercício 3

Um colega desligou o mapeamento de claims com MapInboundClaims = false e agora todas as chamadas de admin recebem 403, embora o token tenha "role": "admin". Explique a causa e as duas formas de corrigir.

Ver resposta

✓ Resposta: Sem o mapeamento, a claim continua com o nome role. O ClaimsIdentity criado pelo handler, porém, ainda considera ClaimTypes.Role (a URI longa) como tipo de papel, então IsInRole("admin") e RequireRole("admin") procuram uma claim que não existe. Correção 1: configurar o.TokenValidationParameters.RoleClaimType = "role", e também NameClaimType = "sub" se o código usa User.Identity.Name. Correção 2: voltar ao mapeamento padrão. Em qualquer caso, o código que usava ClaimTypes.NameIdentifier passa a usar "sub" com o mapeamento desligado. Um teste de integração com token de admin teria pegado o problema antes do deploy.

Exercício 4

Por que a política de fallback RequireAuthenticatedUser é considerada mais segura do que marcar cada endpoint protegido com RequireAuthorization? Existe algum endpoint da Livraria que ela poderia quebrar sem que se perceba de imediato?

Ver resposta

✓ Resposta: Com fallback, o estado padrão é "fechado". Um endpoint novo sem anotação exige login, e o erro, se houver, aparece como um 401 inesperado, que é visível e inofensivo. Sem fallback, o esquecimento deixa o endpoint aberto em silêncio, e ninguém reclama de ter acesso demais. Ela pode quebrar endpoints que precisam ser anônimos e não foram marcados: /openapi/v1.json e /scalar em desenvolvimento, e principalmente endpoints de saúde (/saude) consultados pelo orquestrador de contêineres, que não tem token. O sintoma, contêineres reiniciados por "falha" de health check, pode demorar a ser associado à mudança. Esses endpoints precisam de .AllowAnonymous() explícito.

Exercício 5

Esboce o fluxo de refresh token com rotação: o que o servidor guarda, o que acontece na renovação normal e o que fazer quando um refresh token já usado é apresentado de novo.

Ver resposta

✓ Resposta: No login, o servidor emite um access token JWT de curta duração e um refresh token opaco (valor aleatório de 256 bits). Guarda no banco apenas o hash do refresh token, com usuário, expiração (dias), uma "família" (id da cadeia de renovações) e um estado ativo ou usado. Na renovação, o cliente envia o refresh token. O servidor busca pelo hash, verifica se está ativo e não expirado, marca-o como usado, emite um novo par e grava o novo refresh na mesma família. Se chegar um refresh token já marcado como usado, é sinal de roubo: o atacante ou o cliente legítimo está usando uma cópia antiga. A resposta recomendada é revogar a família inteira, forçando novo login, e registrar o evento. Os access tokens em circulação continuam válidos até expirar, e é por isso que eles duram poucos minutos.

Comentários

Mais em Linguagem C#

Span<T> e Memory<T>: Olhar Sem Copiar, Processar Sem Alocar
Span<T> e Memory<T>: Olhar Sem Copiar, Processar Sem Alocar

Span e Memory no C#: janelas sobre arrays e strings que recortam sem copiar…

O site consumindo a API: clientes resilientes, cache condicional e um BFF com YARP
O site consumindo a API: clientes resilientes, cache condicional e um BFF com YARP

O site passa a conversar com a API de um jeito só: resiliência padrão com…

Interfaces: Contratos Que Qualquer Tipo Pode Assinar
Interfaces: Contratos Que Qualquer Tipo Pode Assinar

Interfaces em C#: contratos que tipos sem parentesco assinam, várias…