Nesta aula a API ganha autenticação e autorização. As senhas são guardadas como hash lento e com sal, o login emite um JWT assinado e o Spring Security confere o token e os papéis a cada requisição. Antes, você vai escrever e atacar um JWT à mão.
A API da aula anterior tem um problema óbvio: ela confia em todo mundo. Segurança em uma aplicação web responde a duas perguntas diferentes, e confundi-las é a origem de metade dos furos. Autenticação pergunta quem é você? e se prova com senha, token ou certificado. Autorização pergunta o que você pode fazer? e se decide com papéis e regras. A resposta a "quem" vem primeiro. Sem ela, não existe o "o quê".
Nas Trilhas da Ilha as regras são estas:
- Ler trilhas e relatos é público.
- Qualquer usuário logado publica relatos, sempre em nome dele mesmo.
- Editores cadastram e alteram trilhas.
- Só administradores apagam.
Senhas: o que nunca guardar
A tabela de usuários será roubada. Não é pessimismo: é o cenário para o qual se projeta, como os cintos de segurança são projetados para o acidente. A pergunta certa é "o que o ladrão consegue fazer com ela?", e as opções, da pior para a melhor, são:
- A senha em texto puro. O ladrão entra como qualquer usuário aqui e, como as pessoas repetem senhas, também no e-mail delas.
- O hash da senha (SHA-256). Parece seguro, porque um hash não se desfaz. Mas o ladrão não precisa desfazê-lo: ele calcula o hash de bilhões de senhas prováveis e compara. SHA-256 foi feito para ser rápido, e é exatamente isso que o torna ruim aqui. Pior ainda: senhas iguais geram hashes iguais, e um único cálculo quebra todos os usuários que usam "123456".
- Hash com sal. Um valor aleatório diferente por usuário entra no cálculo. Senhas iguais passam a ter registros diferentes, e o ataque precisa ser refeito usuário por usuário. Ainda assim, cada tentativa continua barata.
- Hash com sal e deliberadamente lento. É o caso de bcrypt, PBKDF2, scrypt e Argon2. Cada tentativa custa, digamos, 100 ms em vez de 200 ns. O usuário legítimo paga esse custo uma vez por login e nem percebe. O atacante paga em cada um dos bilhões de palpites.
O JDK traz o PBKDF2, e vale vê-lo funcionando, em Senhas.java:
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
final int ITERACOES = 600_000; // recomendação atual da OWASP para PBKDF2-HMAC-SHA256
final SecureRandom ALEATORIO = new SecureRandom();
/** Gera "pbkdf2-sha256$iterações$sal$hash": tudo que é preciso para conferir depois. */
String codificar(char[] senha) {
byte[] sal = new byte[16];
ALEATORIO.nextBytes(sal); // sal novo a cada senha: hashes iguais nunca se repetem
byte[] hash = pbkdf2(senha, sal, ITERACOES);
var b64 = Base64.getEncoder().withoutPadding();
return "pbkdf2-sha256$" + ITERACOES + "$" + b64.encodeToString(sal) + "$" + b64.encodeToString(hash);
}
boolean confere(char[] senha, String guardado) {
String[] p = guardado.split("\\$");
int iteracoes = Integer.parseInt(p[1]); // lê do registro: permite subir o custo sem invalidar os antigos
byte[] sal = Base64.getDecoder().decode(p[2]);
byte[] esperado = Base64.getDecoder().decode(p[3]);
return MessageDigest.isEqual(esperado, pbkdf2(senha, sal, iteracoes));
}
byte[] pbkdf2(char[] senha, byte[] sal, int iteracoes) {
var spec = new PBEKeySpec(senha, sal, iteracoes, 256);
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
} finally {
spec.clearPassword();
}
}
void main() throws Exception {
char[] senha = "farol-da-barra-2026".toCharArray();
String a = codificar(senha), b = codificar(senha);
IO.println(a);
IO.println(b);
IO.println("mesma senha, registros iguais? " + a.equals(b));
IO.println("confere com a certa? " + confere(senha, a));
IO.println("confere com a errada? " + confere("farol-da-barra-2025".toCharArray(), a));
// Por que "lento" é uma qualidade: compare com um SHA-256 puro
long t0 = System.nanoTime();
for (int i = 0; i < 5; i++) codificar(senha);
double msPbkdf2 = (System.nanoTime() - t0) / 5 / 1e6;
MessageDigest sha = MessageDigest.getInstance("SHA-256");
byte[] bytes = "farol-da-barra-2026".getBytes(StandardCharsets.UTF_8);
t0 = System.nanoTime();
int n = 1_000_000;
for (int i = 0; i < n; i++) bytes[0] = sha.digest(bytes)[0];
double nsSha = (double) (System.nanoTime() - t0) / n;
IO.println("PBKDF2 (600 mil iterações): %.0f ms por senha".formatted(msPbkdf2));
IO.println("SHA-256 puro: %.0f ns por senha → %.0f milhões de tentativas/s num só núcleo"
.formatted(nsSha, 1000 / nsSha));
}
Medido no JDK 27, num processador de 16 núcleos (o programa usa um só; os números variam de máquina para máquina, e os registros mudam a cada execução por causa do sal):
pbkdf2-sha256$600000$xwfn1NLYo6TfyxZ86VwZZw$FEzB0hu3Tg/Gynrc5jjFxkujVZDLEf0epa2CMoPN6CQ
pbkdf2-sha256$600000$2Pzk8iie42xswLRQ4k3ybQ$dAD8CJhBpYafzvg3/M75JEJSobgIm1Hmufwt2UgikMQ
mesma senha, registros iguais? false
confere com a certa? true
confere com a errada? false
PBKDF2 (600 mil iterações): 170 ms por senha
SHA-256 puro: 94 ns por senha → 11 milhões de tentativas/s num só núcleo
A diferença é de quase dois milhões de vezes por tentativa, e placas de vídeo ampliam ainda mais o lado do SHA puro. Os dois registros da mesma senha são diferentes por causa do sal. O registro carrega o algoritmo e o número de iterações, então dá para subir o custo no futuro: os hashes antigos continuam conferindo e são refeitos no próximo login.
Duas escolhas no código importam além do algoritmo. MessageDigest.isEqual compara em tempo constante. Um Arrays.equals comum para no primeiro byte diferente, e um atacante paciente pode medir essa diferença de tempo. E a senha é um char[], que pode ser zerado depois do uso (clearPassword), ao contrário de uma String imutável que fica na heap até o coletor passar.
No Spring você não escreve nada disso. O PasswordEncoderFactories.createDelegatingPasswordEncoder() usa bcrypt por padrão e grava o algoritmo como prefixo ({bcrypt}$2a$10$...). Na mesma ideia do nosso registro, isso permite migrar para Argon2 amanhã sem quebrar ninguém.
Sessão ou token
Depois do login, o servidor precisa reconhecer o usuário nas requisições seguintes. Há dois caminhos clássicos.
- Sessão. O servidor guarda "a sessão X pertence à Ana" e entrega ao navegador um cookie com o identificador X. É simples, fácil de revogar (basta apagar a sessão) e o navegador envia o cookie sozinho. Por isso mesmo exige proteção contra CSRF. É o que o nosso site vai usar em "Extensão — O Site Completo: htmx, Login, Área Administrativa e Upload".
- Token autocontido. O servidor entrega um documento assinado dizendo "esta é a Ana, papel EDITOR, válido até 14h15". O cliente o envia em cada requisição, no cabeçalho
Authorization: Bearer ..., e o servidor confere a assinatura sem consultar nada. Não há estado no servidor, e qualquer instância verifica o token. É o modelo natural para APIs consumidas por apps e scripts.
O formato padrão desse token é o JWT (JSON Web Token, RFC 7519).
Um JWT feito à mão, e atacado
Um JWT são três partes em Base64URL separadas por pontos: cabeçalho.payload.assinatura. O cabeçalho diz o algoritmo. O payload traz as claims: iss (quem emitiu), sub (sobre quem), exp (validade), iat (quando foi emitido) e as nossas. A assinatura HS256 é um HMAC-SHA256 de cabeçalho.payload com uma chave secreta.
O ponto que muita gente entende errado: o payload não é secreto. Base64 é só uma codificação, e qualquer pessoa lê o conteúdo do token. A assinatura garante integridade, não sigilo: ninguém sem a chave consegue alterar o conteúdo sem ser descoberto. Nunca coloque no payload o que não poderia aparecer num log.
Vamos implementar os dois lados e depois tentar enganar o nosso próprio verificador, em MiniJwt.java:
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Um JWT HS256 mínimo: só para ver as engrenagens. Em produção, use a biblioteca. */
static final class MiniJwt {
static final class TokenInvalido extends RuntimeException {
TokenInvalido(String motivo) { super(motivo); }
}
private static final Base64.Encoder CODIFICA = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder DECODIFICA = Base64.getUrlDecoder();
private static final String CABECALHO = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
private final byte[] chave;
MiniJwt(byte[] chave) {
if (chave.length < 32) throw new IllegalArgumentException("HS256 pede chave de pelo menos 256 bits");
this.chave = chave.clone();
}
String assinar(String payloadJson) {
String dados = b64(CABECALHO) + "." + b64(payloadJson);
return dados + "." + CODIFICA.encodeToString(hmac(dados));
}
/** Devolve o payload se (e só se) o token for íntegro, HS256 e não estiver vencido. */
String verificar(String token, Instant agora) {
String[] partes = token.split("\\.", -1);
if (partes.length != 3) throw new TokenInvalido("formato inválido");
// 1. Algoritmo: NÓS decidimos qual aceitamos. Nunca deixe o token escolher.
String alg = campoTexto(texto(partes[0]), "alg");
if (!"HS256".equals(alg)) throw new TokenInvalido("algoritmo não aceito: " + alg);
// 2. Assinatura: recalcula e compara em tempo constante
byte[] esperada = hmac(partes[0] + "." + partes[1]);
byte[] recebida;
try {
recebida = DECODIFICA.decode(partes[2]);
} catch (IllegalArgumentException e) {
throw new TokenInvalido("assinatura mal codificada");
}
if (!MessageDigest.isEqual(esperada, recebida)) throw new TokenInvalido("assinatura não confere");
// 3. Validade: só depois de saber que o conteúdo é confiável
String payload = texto(partes[1]);
long exp = campoNumero(payload, "exp");
if (agora.getEpochSecond() >= exp) throw new TokenInvalido("token vencido");
return payload;
}
private byte[] hmac(String dados) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(chave, "HmacSHA256"));
return mac.doFinal(dados.getBytes(StandardCharsets.US_ASCII));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
private static String b64(String s) { return CODIFICA.encodeToString(s.getBytes(StandardCharsets.UTF_8)); }
private static String texto(String parte) {
try {
return new String(DECODIFICA.decode(parte), StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
throw new TokenInvalido("Base64URL inválido");
}
}
// Leitura de JSON "de brinquedo": suficiente para a miniatura, não para produção
private static String campoTexto(String json, String nome) {
Matcher m = Pattern.compile("\"" + nome + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
return m.find() ? m.group(1) : null;
}
private static long campoNumero(String json, String nome) {
Matcher m = Pattern.compile("\"" + nome + "\"\\s*:\\s*(\\d+)").matcher(json);
if (!m.find()) throw new TokenInvalido("sem o campo " + nome);
return Long.parseLong(m.group(1));
}
}
void main(String[] args) {
byte[] chave = Base64.getDecoder().decode(System.getenv("SEGREDO"));
var jwt = new MiniJwt(chave);
Instant agora = Instant.ofEpochSecond(1_790_000_000L); // um "agora" fixo, para a saída ser reproduzível
if (args.length == 1) { // modo "verificar um token vindo de fora"
try {
IO.println("aceito: " + jwt.verificar(args[0], agora));
} catch (MiniJwt.TokenInvalido e) {
IO.println("recusado: " + e.getMessage());
}
return;
}
String payload = """
{"iss":"trilhas-da-ilha","sub":"ana","papeis":["EDITOR"],"iat":%d,"exp":%d}"""
.formatted(agora.getEpochSecond(), agora.plusSeconds(900).getEpochSecond());
String token = jwt.assinar(payload);
IO.println(token);
IO.println("verificado: " + jwt.verificar(token, agora));
// Ataque 1: trocar o payload por um que diz ADMIN, mantendo a assinatura antiga
String[] p = token.split("\\.");
String adulterado = Base64.getUrlEncoder().withoutPadding().encodeToString(
payload.replace("EDITOR", "ADMIN").getBytes(StandardCharsets.UTF_8));
tentar(jwt, p[0] + "." + adulterado + "." + p[2], agora);
// Ataque 2: "alg":"none" e assinatura vazia
String semAlg = Base64.getUrlEncoder().withoutPadding().encodeToString(
"{\"alg\":\"none\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8));
tentar(jwt, semAlg + "." + adulterado + ".", agora);
// Token legítimo, mas 16 minutos depois
tentar(jwt, token, agora.plusSeconds(960));
}
void tentar(MiniJwt jwt, String token, Instant agora) {
try {
jwt.verificar(token, agora);
IO.println("ACEITO (isso seria um furo!)");
} catch (MiniJwt.TokenInvalido e) {
IO.println("recusado: " + e.getMessage());
}
}
$ export SEGREDO=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= # 32 bytes só para o exemplo
$ java MiniJwt.java
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0cmlsaGFzLWRhLWlsaGEiLCJzdWIiOiJhbmEiLCJwYXBlaXMiOlsiRURJVE9SIl0sImlhdCI6MTc5MDAwMDAwMCwiZXhwIjoxNzkwMDAwOTAwfQ.5LoYQFMWoBJ96JDMjvSxumXxWxqJDdZC_vYt1GPnOfI
verificado: {"iss":"trilhas-da-ilha","sub":"ana","papeis":["EDITOR"],"iat":1790000000,"exp":1790000900}
recusado: assinatura não confere
recusado: algoritmo não aceito: none
recusado: token vencido
Cada ataque recusado corresponde a uma falha real, já vista em bibliotecas usadas em produção.
- Adulteração. O atacante lê o payload, troca
EDITORporADMINe reaproveita a assinatura. Como ela foi calculada sobre o payload original, não confere. Sem a chave, não há como gerar uma assinatura nova. alg: none. A RFC prevê tokens "não protegidos", sem assinatura. Algumas bibliotecas antigas liam oalgdo próprio token e, ao vernone, pulavam a verificação. A defesa está no passo 1: o verificador decide qual algoritmo aceita, e o token não tem voz nisso. A mesma regra evita a "confusão de algoritmo", em que um token HS256 é assinado usando a chave pública RSA de um servidor que esperava RS256.- Expiração. O
expsó é lido depois de a assinatura ser confirmada. Antes disso, o conteúdo é só texto controlado pelo atacante.
Para ter certeza de que a miniatura fala o padrão, vale a conferência cruzada com uma biblioteca independente, o PyJWT:
import base64, os, jwt # pip install pyjwt
chave = base64.b64decode(os.environ["SEGREDO"])
token_java = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3Mi...5LoYQFMWoBJ96JDMjvSxumXxWxqJDdZC_vYt1GPnOfI"
print(jwt.decode(token_java, chave, algorithms=["HS256"], options={"verify_exp": False}))
print(jwt.encode({"iss": "trilhas-da-ilha", "sub": "bruno", "papeis": ["ADMIN"],
"iat": 1790000000, "exp": 1790000600}, chave, algorithm="HS256"))
O PyJWT aceita o token gerado em Java. Na direção contrária, o token gerado pelo PyJWT, passado como argumento ao MiniJwt.java, sai como aceito: {"iss":"trilhas-da-ilha","sub":"bruno",...}. Repare que até o PyJWT exige o parâmetro algorithms=["HS256"]: é a mesma lição do passo 1, transformada em API obrigatória.
A miniatura lê JSON com expressões regulares, o que basta para o experimento e é inaceitável em produção. Na aplicação usaremos a Nimbus JOSE + JWT, que o Spring Security usa por baixo.
Como o Spring Security funciona
O Spring Security é uma cadeia de filtros de servlet que roda antes do DispatcherServlet, ou seja, antes de qualquer controller. É a mesma ideia do pipeline do Farol, em que cada etapa podia responder e interromper a requisição. A cadeia tem dezenas de filtros; para uma API com JWT, importam três:
BearerTokenAuthenticationFilterlê oAuthorization: Bearer ..., entrega o token aoJwtDecoder(que confere assinatura,expeiss) e, se tudo estiver certo, coloca umaAuthenticationnoSecurityContextda thread.ExceptionTranslationFilterconverte falhas em respostas HTTP. Sem autenticação válida, a resposta é 401 comWWW-Authenticate: Bearer. Autenticado, mas sem permissão, é 403.AuthorizationFilteraplica as regras deauthorizeHttpRequestsusando aAuthenticationjá montada.
Os códigos 401 e 403 são o espelho de autenticação e autorização: 401 quer dizer "não sei quem você é"; 403 quer dizer "sei quem você é, e a resposta é não".
A configuração
As dependências:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server-test</artifactId>
<scope>test</scope>
</dependency>
Os usuários ficam no PostgreSQL. A tabela chega por uma nova migração do Flyway, com o próximo número livre da pasta db/migration/postgresql, aqui V5__usuarios.sql:
CREATE TABLE usuario (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
login TEXT NOT NULL UNIQUE CHECK (login ~ '^[a-z0-9._-]{3,40}$'),
senha_hash TEXT NOT NULL, -- "{bcrypt}$2a$10$...": nunca a senha
papeis TEXT[] NOT NULL DEFAULT '{}',
ativo BOOLEAN NOT NULL DEFAULT true
);
Repare que a migração não cria usuário nenhum. Um INSERT com um hash de senha dentro do repositório Git significa que todo mundo com acesso ao código conhece o hash, e com ele pode montar um ataque de dicionário sem pressa. O primeiro administrador nasce na inicialização, com a senha vinda de uma variável de ambiente. O tratamento completo de segredos fica para "Extensão — Do Laptop ao Servidor: Docker, nginx e Deploy".
As propriedades do token vão para um record de @ConfigurationProperties, como em "Extensão — Spring Boot pela Primeira Vez: Injeção de Dependência":
package br.com.exemplo.trilhas.seguranca;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import java.time.Duration;
@ConfigurationProperties("trilhas.jwt")
public record PropriedadesJwt(
String segredo, // Base64; vem de TRILHAS_JWT_SEGREDO
@DefaultValue("PT15M") Duration validade,
@DefaultValue("trilhas-da-ilha") String emissor) {}
# application.properties: só a referência; o valor vem do ambiente
trilhas.jwt.segredo=${TRILHAS_JWT_SEGREDO}
Gere o segredo com openssl rand -base64 32 e nunca o grave no repositório. Quem tem a chave de um HS256 consegue emitir tokens de administrador.
A configuração central fica assim:
package br.com.exemplo.trilhas.seguranca;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@EnableConfigurationProperties(PropriedadesJwt.class)
public class ConfiguracaoDeSeguranca {
/** A cadeia da API: sem sessão, sem CSRF, autenticada por JWT. A ORDEM das regras importa. */
@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.authorizeHttpRequests(regras -> regras
.requestMatchers(HttpMethod.POST, "/api/v1/auth/token").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/trilhas/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/trilhas/*/relatos").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/v1/trilhas/**").hasRole("ADMIN")
.requestMatchers("/api/v1/trilhas/**").hasAnyRole("EDITOR", "ADMIN")
.anyRequest().denyAll()) // o que não foi previsto é proibido
.csrf(csrf -> csrf.disable()) // veja o texto: seguro SÓ porque não há cookie
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));
return http.build();
}
/** Todo o resto: por enquanto só a documentação; o site de "Páginas Dinâmicas" abrirá mais. */
@Bean
@Order(2)
SecurityFilterChain resto(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(regras -> regras
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.anyRequest().denyAll());
return http.build();
}
@Bean
SecretKey chaveJwt(PropriedadesJwt props) {
byte[] bytes = Base64.getDecoder().decode(props.segredo());
if (bytes.length < 32) throw new IllegalStateException("trilhas.jwt.segredo precisa de 256 bits ou mais");
return new SecretKeySpec(bytes, "HmacSHA256");
}
@Bean
JwtEncoder jwtEncoder(SecretKey chave) {
return NimbusJwtEncoder.withSecretKey(chave).build();
}
@Bean
JwtDecoder jwtDecoder(SecretKey chave, PropriedadesJwt props) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(chave)
.macAlgorithm(MacAlgorithm.HS256) // o passo 1 da miniatura: nós escolhemos
.build();
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(props.emissor())); // exp, nbf e iss
return decoder;
}
/** A claim "papeis": ["EDITOR"] vira a autoridade ROLE_EDITOR, que hasRole("EDITOR") entende. */
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var papeis = new JwtGrantedAuthoritiesConverter();
papeis.setAuthoritiesClaimName("papeis");
papeis.setAuthorityPrefix("ROLE_");
var conversor = new JwtAuthenticationConverter();
conversor.setJwtGrantedAuthoritiesConverter(papeis);
return conversor;
}
}
As decisões dessa configuração, uma a uma:
- Duas cadeias. A primeira só olha
/api/**(securityMatcher). A segunda pega o resto. Assim o site, com sessão e CSRF, poderá ter regras próprias sem se misturar com a API. - A ordem das regras. Elas são avaliadas de cima para baixo, e a primeira que casa decide. Se a regra
"/api/v1/trilhas/**"comhasAnyRoleviesse antes da regra dos relatos, um caminhante sem papel nenhum não conseguiria publicar relato. Se oGETpúblico viesse depois dohasAnyRole, a leitura exigiria login. Na dúvida, escreva primeiro as regras mais específicas. anyRequest().denyAll(): negar por padrão. Um endpoint novo esquecido na configuração fica fechado, e não aberto. É o mesmo princípio do firewall bem configurado.- CSRF desligado, e por quê. CSRF explora o fato de o navegador anexar cookies automaticamente a requisições para o site. Um token num cabeçalho
Authorizationnunca é anexado automaticamente: um site malicioso não tem como fazer o navegador enviá-lo. Sem cookie de autenticação, não há CSRF. No site, com cookie de sessão, a proteção volta a ser obrigatória. STATELESS: o Spring não cria sessão HTTP nem a consulta. Cada requisição se prova sozinha.- O decodificador fixa o algoritmo e o emissor. O
createDefaultWithIssuerconfereexpenbf, com uma tolerância de 60 segundos para relógios levemente dessincronizados, e exigeissigual ao nosso. Quando oJwtDecoderé um bean nosso, a autoconfiguração do Spring Boot sai de cena. - O conversor de papéis é encontrado automaticamente pelo
.jwt(...)por ser um bean do tipoJwtAuthenticationConverter. Sem ele, o Spring procuraria a claimscopee criaria autoridadesSCOPE_....
Os usuários e o gerente de autenticação ficam em uma configuração separada. Veremos nos testes por que essa separação ajuda:
package br.com.exemplo.trilhas.seguranca;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class ConfiguracaoDeUsuarios {
private static final Logger log = LoggerFactory.getLogger(ConfiguracaoDeUsuarios.class);
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // bcrypt, com prefixo {bcrypt}
}
@Bean
UserDetailsService usuarios(JdbcClient jdbc) {
return login -> jdbc.sql("SELECT login, senha_hash, papeis, ativo FROM usuario WHERE login = ?")
.param(login)
.query((rs, _) -> User.withUsername(rs.getString("login"))
.password(rs.getString("senha_hash"))
.roles((String[]) rs.getArray("papeis").getArray()) // TEXT[] → String[]
.disabled(!rs.getBoolean("ativo"))
.build())
.optional()
.orElseThrow(() -> new UsernameNotFoundException(login));
}
@Bean
AuthenticationManager authenticationManager(UserDetailsService usuarios, PasswordEncoder encoder) {
var provedor = new DaoAuthenticationProvider(usuarios);
provedor.setPasswordEncoder(encoder);
return new ProviderManager(provedor);
}
/** O primeiro administrador nasce da variável TRILHAS_ADMIN_SENHA; sem ela, nada acontece. */
@Bean
ApplicationRunner criarAdministrador(JdbcClient jdbc, PasswordEncoder encoder,
@Value("${TRILHAS_ADMIN_SENHA:}") String senha) {
return _ -> {
if (senha.isBlank()) return;
int criados = jdbc.sql("""
INSERT INTO usuario (login, senha_hash, papeis) VALUES ('admin', ?, '{ADMIN}')
ON CONFLICT (login) DO NOTHING""")
.param(encoder.encode(senha))
.update();
if (criados == 1) log.info("Usuário 'admin' criado.");
};
}
}
O DaoAuthenticationProvider cuida de um detalhe sutil. Se o login não existe, ele ainda assim calcula um hash de uma senha qualquer antes de recusar. Sem isso, "usuário inexistente" responderia em 1 ms e "senha errada" em 100 ms, e um atacante descobriria quais logins existem só medindo o tempo. Por padrão, ele também transforma UsernameNotFoundException em BadCredentialsException, e as duas situações chegam ao cliente como a mesma mensagem.
Emitindo o token
package br.com.exemplo.trilhas.seguranca;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/auth")
public class AutenticacaoController {
public record Credenciais(@NotBlank String usuario, @NotBlank String senha) {}
/** Nomes do padrão OAuth 2 (RFC 6749): clientes e ferramentas já os conhecem. */
public record Token(@JsonProperty("access_token") String accessToken,
@JsonProperty("token_type") String tokenType,
@JsonProperty("expires_in") long expiresIn) {}
private final AuthenticationManager autenticador;
private final JwtEncoder codificador;
private final PropriedadesJwt props;
public AutenticacaoController(AuthenticationManager autenticador, JwtEncoder codificador, PropriedadesJwt props) {
this.autenticador = autenticador;
this.codificador = codificador;
this.props = props;
}
@PostMapping("/token")
public Token token(@Valid @RequestBody Credenciais c) {
// Senha errada ou usuário inexistente → BadCredentialsException (tratada no TratadorDeErros)
var quem = autenticador.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(c.usuario(), c.senha()));
List<String> papeis = quem.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.filter(a -> a.startsWith("ROLE_"))
.map(a -> a.substring("ROLE_".length()))
.toList();
Instant agora = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(props.emissor())
.subject(quem.getName())
.issuedAt(agora)
.expiresAt(agora.plus(props.validade()))
.id(UUID.randomUUID().toString()) // jti: identifica ESTE token (útil para revogar)
.claim("papeis", papeis)
.build();
var cabecalho = JwsHeader.with(MacAlgorithm.HS256).build();
String jwt = codificador.encode(JwtEncoderParameters.from(cabecalho, claims)).getTokenValue();
return new Token(jwt, "Bearer", props.validade().toSeconds());
}
}
O @JsonProperty continua no pacote com.fasterxml.jackson.annotation. No Jackson 3 o resto da biblioteca mudou para tools.jackson, mas as anotações ficaram onde estavam, justamente para não quebrar código como este.
O erro de login entra no TratadorDeErros da aula anterior, com uma mensagem que não diz qual das duas coisas estava errada:
// Acrescentado ao TratadorDeErros
@ExceptionHandler(org.springframework.security.core.AuthenticationException.class)
ProblemDetail credenciaisInvalidas(org.springframework.security.core.AuthenticationException e) {
return problema(HttpStatus.UNAUTHORIZED, "credenciais-invalidas", "Credenciais inválidas",
"Usuário ou senha incorretos.");
}
Quem está pedindo: o relato é de quem está logado
A regra "o relato é publicado em nome de quem está logado" mostra o erro de segurança mais comum em APIs. Se o corpo trouxesse {"autorId": "ana", ...}, qualquer usuário logado publicaria em nome da Ana: autenticado, sim, mas fingindo ser outra pessoa. A identidade vem do token, nunca do corpo:
// No TrilhasController
@PostMapping("/{id}/relatos")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Publica um relato em nome do usuário do token")
public RelatoResposta publicarRelato(@PathVariable long id, @Valid @RequestBody NovoRelato novo,
@AuthenticationPrincipal Jwt token) {
return servico.publicarRelato(id, token.getSubject(), novo); // o autor é o "sub" do JWT
}
NovoRelato tem nota, texto e data, e não tem autor. Como defesa em profundidade, o serviço também pode repetir a regra mais sensível, para o dia em que alguém chamar o método por outro caminho, como um job ou outro controller:
// No ServicoDeTrilhas
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public void remover(long id) { ... }
O @PreAuthorize funciona por proxy, o mesmo mecanismo do @Transactional visto em "Extensão — Spring Boot pela Primeira Vez: Injeção de Dependência", com a mesma limitação: uma chamada de dentro da própria classe não passa pelo proxy.
Testando as regras
Regras de segurança são código e, como todo código, quebram em refatorações. Por isso merecem testes que provem cada linha da tabela de permissões. O jwt() do Spring Security Test injeta uma autenticação JWT falsa, sem precisar da chave:
package br.com.exemplo.trilhas.seguranca;
import br.com.exemplo.trilhas.ServicoDeTrilhas;
import br.com.exemplo.trilhas.Dificuldade;
import br.com.exemplo.trilhas.api.TrilhaResposta;
import br.com.exemplo.trilhas.api.TrilhasController;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
@WebMvcTest(controllers = TrilhasController.class,
properties = "trilhas.jwt.segredo=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
@Import(ConfiguracaoDeSeguranca.class) // só a segurança; usuários e banco não entram no teste
class RegrasDeAcessoTest {
@Autowired MockMvcTester mvc;
@MockitoBean ServicoDeTrilhas servico;
static final String TRILHA = """
{"nome":"X","regiao":"Sul","distanciaKm":1,"dificuldade":"FACIL","duracaoMin":30}""";
@Test
void lerEPublico() {
assertThat(mvc.get().uri("/api/v1/trilhas")).hasStatusOk();
}
@Test
void criarSemTokenE401ComDesafioBearer() {
assertThat(mvc.post().uri("/api/v1/trilhas").contentType(MediaType.APPLICATION_JSON).content(TRILHA))
.hasStatus(HttpStatus.UNAUTHORIZED)
.headers().hasValue("WWW-Authenticate", "Bearer");
}
@Test
void editorCriaMasNaoApaga() {
var editor = jwt().authorities(new SimpleGrantedAuthority("ROLE_EDITOR"));
// sem isto o dublê devolve null, e o controller quebra ao montar o Location com criada.id()
given(servico.criar(any())).willReturn(new TrilhaResposta(1, "X", "Sul", 1, Dificuldade.FACIL, 30, List.of()));
assertThat(mvc.post().uri("/api/v1/trilhas").with(editor)
.contentType(MediaType.APPLICATION_JSON).content(TRILHA)).hasStatus(HttpStatus.CREATED);
assertThat(mvc.delete().uri("/api/v1/trilhas/1").with(editor)).hasStatus(HttpStatus.FORBIDDEN);
}
@Test
void adminApaga() {
assertThat(mvc.delete().uri("/api/v1/trilhas/1")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.hasStatus(HttpStatus.NO_CONTENT);
}
@Test
void caminhanteSemPapelPublicaRelato() {
assertThat(mvc.post().uri("/api/v1/trilhas/1/relatos").with(jwt().jwt(j -> j.subject("bruno")))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"nota":4,"texto":"Bonita.","data":"2026-09-20"}"""))
.hasStatus(HttpStatus.CREATED);
}
}
Separar ConfiguracaoDeSeguranca de ConfiguracaoDeUsuarios é o que permite este teste. O @WebMvcTest não tem JdbcClient nem banco. Se o UserDetailsService morasse na mesma classe, o contexto nem subiria.
Usando de verdade
Com a aplicação rodando e TRILHAS_ADMIN_SENHA definida na primeira subida, o diálogo esperado é:
$ curl -s -X POST localhost:8080/api/v1/auth/token -H 'Content-Type: application/json' \
-d '{"usuario":"admin","senha":"'"$TRILHAS_ADMIN_SENHA"'"}'
{"access_token":"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOi...","token_type":"Bearer","expires_in":900}
$ TOKEN=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOi...
$ curl -i -X DELETE localhost:8080/api/v1/trilhas/5 # sem token
HTTP/1.1 401
WWW-Authenticate: Bearer
$ curl -i -X DELETE localhost:8080/api/v1/trilhas/5 -H "Authorization: Bearer $TOKEN" # com token
HTTP/1.1 204
O que o JWT não resolve
O token autocontido tem limitações honestas, e é bom conhecê-las antes de colocá-lo em produção.
- Revogação. Se o token vale 15 minutos, um usuário desativado agora continua entrando por até 15 minutos, porque ninguém consulta o banco. As saídas são validade curta; uma lista de
jtirevogados, consultada a cada requisição (o que devolve parte do estado que o JWT prometia eliminar); ou refresh tokens, que vivem mais, são guardados no servidor e trocados por novos tokens curtos. Os fluxos completos de OAuth 2 com um servidor de autorização próprio (Keycloak, Spring Authorization Server) resolvem isso de forma padronizada. Eles valem o estudo quando houver mais de uma aplicação consumindo a mesma identidade. - HS256 é um segredo compartilhado. Quem verifica também pode emitir. Com um serviço só, isso não é problema. Se outros serviços precisarem verificar os tokens, use RS256 ou ES256: a chave privada assina, a pública verifica, e só o emissor guarda a privada.
- Onde guardar no navegador. Token em
localStoragefica exposto a qualquer script injetado na página (XSS). Para aplicações web no navegador, sessão com cookieHttpOnlycontinua sendo a opção mais segura. Por isso o nosso site usará sessão, e o JWT fica para apps e scripts. - Força bruta no login. Nada nesta aula impede alguém de tentar mil senhas por minuto no
/auth/token. O bcrypt torna cada tentativa cara para nós também. Em produção, esse endpoint precisa de limite de taxa, no nginx ou em um filtro, e de bloqueio progressivo.
Temos agora uma API com regras de acesso e testes para cada uma delas. Mas todos os testes até aqui usaram dublês, e nenhum tocou o PostgreSQL ou o MongoDB de verdade. É ali que moram as surpresas que dublês não pegam: a migração que falha no PostgreSQL real, a collation que ordena diferente, a constraint que o H2 aceitaria e o PostgreSQL recusa.
Fontes e leituras recomendadas
- RFC 7519 — JSON Web Token: a definição do formato e das claims registradas.
- RFC 8725 — JWT Best Current Practices: os ataques de
alg: none, a confusão de algoritmos e como evitá-los, escritos por quem padronizou o JWT. - OWASP — Password Storage Cheat Sheet: algoritmos e parâmetros recomendados para hash de senhas.
- Spring Security — Servlet Architecture: a cadeia de filtros, o
SecurityContexte oExceptionTranslationFilter. - Spring Security — OAuth 2.0 Resource Server JWT:
JwtDecoder, validadores e conversão de claims em autoridades. - RFC 6750 — Bearer Token Usage: o cabeçalho
Authorization: Bearere o desafioWWW-Authenticate.
Exercícios
Exercício 1
Decodifique à mão (com base64 -d ou com Base64.getUrlDecoder() no jshell) o payload do token que o MiniJwt gerou. O que isso prova sobre o que pode ir num JWT?
Ver resposta
✓ Resposta: O payload eyJpc3Mi... decodifica para {"iss":"trilhas-da-ilha","sub":"ana","papeis":["EDITOR"],"iat":1790000000,"exp":1790000900} sem chave nenhuma (no base64 -d do terminal pode ser preciso completar com = no fim, porque o Base64URL do JWT não tem padding). Isso prova que o JWT assinado é legível por qualquer um: a assinatura protege a integridade, não o sigilo. CPF, endereço, dados de saúde ou qualquer coisa que não possa aparecer num log não vão no payload. Se o conteúdo precisar ser secreto, existe o JWE, o JWT cifrado, mas quase sempre a solução é simplesmente não colocar o dado ali.
Exercício 2
Na miniatura, troque MessageDigest.isEqual(esperada, recebida) por Arrays.equals(esperada, recebida). O programa continua passando em todos os testes. Por que isso seria um problema mesmo assim?
Ver resposta
✓ Resposta: Arrays.equals retorna no primeiro byte diferente, e por isso comparar assinaturas que começam iguais leva um pouco mais de tempo. Em teoria, um atacante que mede tempos com precisão descobre a assinatura correta byte a byte: tenta 256 valores para o primeiro byte, fica com o que demora mais, e segue para o próximo. Os testes funcionais não pegam isso, porque o resultado é o mesmo; só o tempo muda. MessageDigest.isEqual compara todos os bytes sempre, e é a escolha certa para qualquer segredo, seja assinatura, hash de senha ou token.
Exercício 3
Um colega reordenou as regras e pôs .requestMatchers("/api/v1/trilhas/**").hasAnyRole("EDITOR", "ADMIN") como primeira linha. Quais testes de RegrasDeAcessoTest falham, e por quê?
Ver resposta
✓ Resposta: Falham lerEPublico (um GET sem token passa a exigir papel e dá 401), caminhanteSemPapelPublicaRelato (o usuário autenticado, mas sem papel, recebe 403) e editorCriaMasNaoApaga. Este último revela o furo mais grave: o DELETE do EDITOR casa com a regra genérica antes de chegar à do hasRole("ADMIN"), e a resposta é 204 em vez de 403, ou seja, um editor passa a apagar trilhas. O adminApaga continua passando, porque o ADMIN também tem um dos papéis da regra genérica: um teste que só confere quem pode não pega esse tipo de erro. As regras são avaliadas em ordem e a primeira que casa decide, então a genérica "engoliu" as específicas. É por isso que regras mais específicas vêm primeiro, e por isso cada linha da tabela de permissões merece um teste.
Exercício 4
Implemente a revogação imediata: quando um administrador desativa um usuário, os tokens dele devem parar de funcionar na hora, não em 15 minutos. Descreva a solução e o custo dela.
Ver resposta
✓ Resposta: Acrescente ao JwtDecoder um validador próprio (OAuth2TokenValidator<Jwt>), combinado com os padrões via new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefaultWithIssuer(...), meuValidador). Esse validador consulta se o sub está ativo, ou se o jti está numa tabela de revogados, e devolve OAuth2TokenValidatorResult.failure(...) se não estiver. O custo é uma consulta ao banco (ou a um cache) a cada requisição, justamente o que o token autocontido evitava. Um meio-termo comum é manter a validade curta (5 a 15 minutos) e consultar apenas uma lista pequena de revogados, mantida em memória ou no Redis.
Exercício 5
Por que a cadeia da API pode desligar o CSRF e a do site, que faremos em breve, não pode? Responda em termos do que o navegador envia automaticamente.
Ver resposta
✓ Resposta: O ataque CSRF funciona porque o navegador anexa cookies do site a qualquer requisição feita para ele, inclusive a que parte de um formulário escondido em um site malicioso. Se a autenticação é um cookie de sessão, essa requisição forjada chega autenticada. A API autentica pelo cabeçalho Authorization: Bearer, que o navegador nunca anexa sozinho: só um código que já tenha o token consegue enviá-lo, e um site de outra origem não tem acesso a ele. O site usará cookie de sessão, e por isso precisa do token CSRF, que é um valor que o site malicioso não consegue ler nem adivinhar.