O que estou pensando agora: Interfaces adaptativas em sistemas web complexos e mudanças em tempo real com LLMs

← Todos os escritos

Escritos

Autenticação com sessão e JWT usando Spring Security

Neste artigo

Quando uma requisição chega a uma API, precisamos identificar quem está chamando e quais operações essa identidade pode executar. Aceitar um usuário autenticado não significa permitir que ele acesse qualquer recurso.

Sessão e JWT são duas formas de transportar informações usadas nessa identificação. A escolha altera onde o estado fica, como o cliente envia a credencial e o que acontece quando o usuário sai da aplicação.

O exemplo deste texto usa Java 21, Spring Boot 3.5.16 e Spring Security. O projeto completo, incluindo os testes, está disponível para download.

Autenticação e autorização

Autenticação verifica a identidade. No login com senha, precisamos verificar se a credencial corresponde ao usuário. Em uma requisição com token, precisamos verificar se aquele token é aceitável para a API.

Autorização decide o que a identidade pode fazer. Um usuário pode estar autenticado e receber 403 ao tentar uma operação que exige uma permissão que ele não possui.

O código a seguir exige autenticação, mas não verifica propriedade de dados:

.requestMatchers("/pedidos/**").authenticated()

Se /pedidos/123 retorna o pedido de outra pessoa sem uma verificação adicional, a configuração acima não impede esse acesso. O serviço precisa aplicar a regra do recurso: proprietário, organização, papel ou outra condição definida pelo domínio.

Sessão

No fluxo com formulário do exemplo, o Spring Security verifica usuário e senha. Depois do login, o contexto de segurança fica associado à sessão HTTP. O navegador envia o identificador da sessão em um cookie nas próximas requisições.

O servidor mantém o estado da sessão. O cliente guarda um identificador, que também precisa ser protegido. Quem conseguir usar uma sessão válida pode agir com aquela identidade.

@Bean @Order(2)
SecurityFilterChain navegador(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(a -> a
            .requestMatchers("/login", "/error").permitAll()
            .requestMatchers("/web/**").authenticated()
            .anyRequest().denyAll())
        .formLogin(Customizer.withDefaults())
        .logout(Customizer.withDefaults());
    return http.build();
}

/web/** usa o login por formulário. Uma chamada sem autenticação, nesse fluxo, pode receber um redirecionamento para a página de login, em vez do 401 esperado por um cliente de API.

O exemplo mantém um usuário em memória para executar esse fluxo. A senha é definida por variável de ambiente e transformada por um PasswordEncoder. Não há cadastro ou banco de usuários nesse projeto.

var encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
return new InMemoryUserDetailsManager(
    User.withUsername("neto")
        .password(encoder.encode(senha))
        .roles("USER")
        .build()
);

Um sistema com persistência deve armazenar a representação produzida pelo encoder, não a senha original. Hash de senha e criptografia reversível têm finalidades diferentes.

O navegador pode anexar cookies automaticamente. Por isso, uma requisição iniciada por outra página pode carregar a sessão do usuário. Uma operação que altera dados precisa tratar CSRF.

A proteção CSRF permanece habilitada na cadeia /web/**. Um POST autenticado sem token CSRF é rejeitado. O formulário padrão de login já inclui o token; o frontend precisa enviá-lo nas outras operações protegidas.

HttpOnly limita o acesso ao cookie por JavaScript. Secure restringe o envio a HTTPS. SameSite controla o envio do cookie em contextos entre sites. São controles diferentes e precisam ser compatíveis com o fluxo da aplicação.

O exemplo usa HTTP apenas em localhost. Em uma implantação com HTTPS, SESSION_COOKIE_SECURE deve ser true. O teste com MockMvc não verifica o comportamento real do navegador nem o cabeçalho final de um servidor em produção.

JWT

JWT é um formato de token. No caso de um JWT assinado, validar a assinatura permite verificar a integridade e a relação com a chave esperada. O conteúdo de um token apenas assinado continua legível; a assinatura não cifra os dados.

Também precisamos avaliar as claims. Um token pode ter assinatura válida e ainda estar expirado, ter sido emitido por outro provedor ou ser destinado a outra API.

ClaimO que informa
subO sujeito identificado pelo emissor
issQuem emitiu o token
audPara quem o token foi emitido
expQuando termina a validade
nbfA partir de quando pode ser aceito

As regras de aceitação são parte do contrato entre API e emissor. No exemplo, usamos um access token RS256 com emissor e audience definidos. O código não emite tokens e não implementa um servidor de autorização.

A cadeia da API

As rotas /api/** recebem um bearer token no cabeçalho Authorization. Essa cadeia tem prioridade sobre a configuração do navegador:

@Bean @Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
    http.securityMatcher("/api/**")
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .requestCache(c -> c.disable())
        .csrf(c -> c.disable())
        .authorizeHttpRequests(a -> a
            .requestMatchers("/api/conta").hasAuthority("SCOPE_conta:ler")
            .anyRequest().denyAll())
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
    return http.build();
}

O Spring Security escolhe a primeira cadeia cujo matcher atende à requisição. Uma cadeia sem securityMatcher específico atende ao restante. Se alguma rota ficar fora de todas as cadeias, ela fica fora da proteção configurada por elas. Neste exemplo, a segunda cadeia cobre o restante e nega as rotas que não foram declaradas.

STATELESS impede o uso da sessão para obter ou persistir o contexto de segurança dessa cadeia. Uma sessão válida em /web/conta não autentica /api/conta.

Desabilitamos CSRF apenas na cadeia que aceita bearer token enviado explicitamente no cabeçalho. Se o token for colocado em um cookie usado automaticamente pelo navegador, essa justificativa muda. A existência de um JWT não elimina CSRF. Também não existe uma regra de CORS capaz de substituir autenticação ou autorização.

Validar o token

O projeto configura o endereço das chaves públicas do emissor e acrescenta a validação da audience:

NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
    JwtValidators.createDefaultWithIssuer(issuer),
    new JwtClaimValidator<java.time.Instant>("exp", exp -> exp != null),
    new JwtClaimValidator<String>("sub", sub -> sub != null && !sub.isBlank()),
    new JwtClaimValidator<java.util.List<String>>(
        "aud", aud -> aud != null && aud.contains(audience)
    )
));

issuer, jwks e audience são configurações do servidor. Não devem ser escolhidos a partir de valores que um token recebido fornece. No projeto, vêm de variáveis de ambiente.

O decoder verifica a assinatura com a chave esperada e aplica os validadores. Neste contrato, exp precisa existir e sub não pode ser vazio. Validar a data de uma claim quando ela está presente não equivale a exigir sua presença.

As verificações temporais têm uma tolerância para diferença de relógio. Por isso, no teste de expiração, o token venceu há cinco minutos, evitando uma falha que dependa de poucos segundos.

A configuração de Resource Server converte scopes em authorities com o prefixo SCOPE_. Assim, a claim scope com valor conta:ler atende à exigência de SCOPE_conta:ler na rota.

Decodificar Base64 e ler sub não executa essas verificações. Também não precisamos criar um filtro próprio que faz parsing do token para repetir o trabalho do Resource Server.

Logout e validade

No fluxo com sessão, o logout do exemplo invalida a sessão. No fluxo com JWT, apagar o token do cliente não torna inválidas as cópias que ainda existem. Elas podem continuar sendo aceitas até a expiração, conforme a política da API.

Se o produto exige revogação imediata, esse requisito precisa entrar no desenho: estado de revogação, consulta ao provedor, versão de credenciais ou outro mecanismo verificável. Isso também altera o argumento de que “JWT não precisa de estado”.

Tokens de acesso e refresh tokens têm funções diferentes. Esta API recebe access tokens. A obtenção e a renovação ficam sob responsabilidade do cliente e do provedor escolhido.

Testes que exercitam o filtro

O projeto usa MockMvc e tokens realmente assinados com chaves RSA geradas durante o teste. Um decoder local substitui o acesso ao provedor externo, mantendo a validação criptográfica e as regras de claims.

O teste de audience, por exemplo, mantém a assinatura válida e muda o destinatário:

@Test void audienceDiferenteRetorna401() throws Exception {
    String jwt = token(CHAVE, "https://id.example.invalid", "outra-api",
        Instant.now().plusSeconds(300), "conta:ler");
    mvc.perform(get("/api/conta")
            .header("Authorization", "Bearer " + jwt))
        .andExpect(status().isUnauthorized());
}

Para testar CSRF, primeiro autenticamos pelo formulário e reutilizamos a sessão criada:

mvc.perform(post("/web/preferencias").session(sessao))
    .andExpect(status().isForbidden());

mvc.perform(post("/web/preferencias").session(sessao).with(csrf()))
    .andExpect(status().isNoContent());

O projeto também verifica token ausente, assinatura com outra chave, token expirado, ausência de expiração, sujeito vazio, emissor incorreto, scope insuficiente, login inválido, troca do ID da sessão após login e invalidação no logout.

jwt() do módulo de testes é útil para cenários de autorização, mas fornece uma autenticação preparada para a requisição. Ele não prova que uma string de token passou pelo decoder e teve sua assinatura validada. Os testes de token deste exemplo enviam o cabeçalho real justamente para percorrer esse caminho.

Depois de extrair o projeto, execute:

mvn test

São doze testes. Eles verificam os limites da configuração do exemplo, não a integração com um provedor real, persistência de usuários ou autorização de recursos de um domínio. Essas verificações precisam existir quando esses componentes forem adicionados.

Referências