🔒 Cap 02: Autenticação e Autorização Ponta a Ponta: JWT, Refresh Tokens, Cookies HTTP-Only e RBAC
🗺️ Mapa Arquitetural do Tópico
flowchart LR
BROWSER["🌐 Navegador"] -->|"1. POST /login (credenciais)"| API["🏛️ Backend Auth Service"]
API -->|"2. Retorna Access Token (Memória) + Refresh Token (Cookie HTTP-Only)"| BROWSER
BROWSER -->|"3. Requisição Protegida (Header Bearer JWT)"| API
API -->|"4. Valida Roles (RBAC: ADMIN / USER)"| DB["PostgreSQL"]
style BROWSER fill:#e1f5fe,stroke:#03a9f4,stroke-width:1px
style API fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
style DB fill:#fff3e0,stroke:#ff9800,stroke-width:1px
🧩 1. O problema: HTTP não lembra de ninguém
Cada requisição HTTP chega ao servidor sem saber das anteriores. Para o servidor “lembrar” quem fez login, a aplicação precisa mandar uma prova de identidade em toda requisição. Existem duas famílias de solução:
| Sessão no servidor | Token (JWT) | |
|---|---|---|
| O que o cliente guarda | Só um ID aleatório (JSESSIONID) num cookie |
O próprio token, com os dados do usuário dentro |
| O que o servidor guarda | A sessão inteira, em memória ou no Redis | Nada: ele só confere a assinatura |
| Logout imediato | Fácil: apaga a sessão | Difícil: o token vale até expirar |
| Vários servidores / microsserviços | Precisam compartilhar o armazenamento de sessões | Qualquer serviço que tenha a chave valida sozinho |
| Clientes mobile e de outros domínios | Cookies dão trabalho | Header Authorization funciona em qualquer cliente |
A sessão continua sendo uma ótima escolha para uma aplicação web tradicional num só domínio. O JWT ganha quando há vários clientes (web, mobile, desktop) e vários serviços, que é o cenário típico deste curso. Pense na série Lista de Tarefas: uma API e nove clientes diferentes.
🔑 2. Anatomia de um JWT
Um JWT (JSON Web Token) são três partes em Base64URL separadas por ponto: cabeçalho.payload.assinatura.
- Cabeçalho: o algoritmo de assinatura, por exemplo
{"alg":"HS256"}. - Payload: as claims, que são as afirmações sobre o usuário. As padronizadas são
sub(quem é),iat(emitido em),exp(expira em). As da aplicação, comoroles, também vão aqui. - Assinatura: um HMAC (
HS256, com chave secreta) ou uma assinatura RSA/ECDSA (RS256/ES256, com par de chaves) calculada sobre as duas primeiras partes.
O ponto mais mal entendido: um JWT não é criptografado, só assinado. Qualquer pessoa lê o payload:
// Um JWT não é criptografado: qualquer um lê o payload. Só a assinatura impede a alteração.
export function lerPayload(token: string): Record<string, unknown> {
const [, payload] = token.split(".");
if (!payload) throw new Error("token malformado");
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
}
// lerPayload(token) → { sub: 'ana', roles: [ 'ADMIN' ], exp: 1767225600 }
Por isso nunca coloque no payload nada que não possa ser lido: senha, CPF, dados de cartão. O que a assinatura garante é que ninguém altera o payload. Se o atacante trocar "roles": ["USER"] por ["ADMIN"], a assinatura deixa de bater e o servidor rejeita o token.
HS256 ou RS256? Com HS256, a mesma chave assina e verifica, então todo serviço que verifica também consegue emitir tokens. Com RS256, só o serviço de autenticação tem a chave privada; os outros recebem a pública e só verificam. Para um único backend, HS256 basta. Com vários serviços, prefira RS256.
🔄 3. Access token e refresh token
Um token que vale muito tempo é perigoso: se vazar, o atacante tem acesso até ele expirar. Um token que vale pouco obriga o usuário a logar toda hora. A solução é usar dois tokens:
| Access token | Refresh token | |
|---|---|---|
| Vida útil | Curta (5 a 15 minutos) | Longa (dias) |
| Para que serve | Autorizar cada requisição à API | Pedir um novo access token |
| Onde fica no navegador | Em memória (uma variável do JavaScript) | Cookie HttpOnly |
| Vai em | Header Authorization: Bearer ... |
Só para a rota /api/auth/refresh |
O cookie do refresh token é montado com quatro atributos, cada um contra um ataque:
// Refresh token em cookie: o JavaScript da página não consegue lê-lo (HttpOnly)
export function cookieRefresh(token: string, diasValidade: number): string {
return [
`refresh_token=${token}`,
"HttpOnly", // invisível para document.cookie → protege contra XSS
"Secure", // só trafega em HTTPS
"SameSite=Strict", // não é enviado em requisições de outros sites → mitiga CSRF
"Path=/api/auth/refresh", // só vai para a rota que renova o access token
`Max-Age=${diasValidade * 24 * 60 * 60}`,
].join("; ");
}
// → refresh_token=abc123; HttpOnly; Secure; SameSite=Strict; Path=/api/auth/refresh; Max-Age=604800
Ao recarregar a página, o access token some da memória. O frontend chama /api/auth/refresh, o navegador envia o cookie sozinho, e a API devolve um access token novo. Com rotação, cada uso do refresh token gera também um refresh token novo e invalida o anterior. Assim, um refresh token roubado e reaproveitado é detectado.
🛡️ 4. Validando no backend e autorizando por papel (RBAC)
No Spring Boot, a validação fica num filtro que roda antes de qualquer controller:
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(request, response); // sem token: segue como anônimo
return;
}
String jwt = header.substring(7);
String usuario = tokenProvider.extractUsername(jwt); // também verifica a assinatura
if (usuario != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails detalhes = userDetailsService.loadUserByUsername(usuario);
if (tokenProvider.isTokenValid(jwt, detalhes)) { // usuário bate e não expirou
var auth = new UsernamePasswordAuthenticationToken(detalhes, null, detalhes.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(request, response);
}
}
Autenticar responde “quem é você?”. Autorizar responde “o que você pode fazer?”. O RBAC (Role-Based Access Control) responde à segunda pergunta por papéis:
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')") // só quem tem a authority ROLE_ADMIN
public ResponseEntity<Void> excluir(@PathVariable Long id) { ... }
A configuração diz o que é público e deixa o restante protegido:
http.csrf(AbstractHttpConfigurer::disable) // ok: a API não usa cookie de sessão
.authorizeHttpRequests(req -> req
.requestMatchers("/api/auth/**", "/swagger-ui/**").permitAll()
.anyRequest().authenticated())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
No frontend, esconder o botão “Excluir” de quem não é administrador melhora a experiência, mas não é segurança. A regra que vale é a do backend: qualquer um pode chamar a API direto, sem passar pela tela.
⚖️ 5. Comparação: onde guardar o token no navegador
| Local | XSS (script injetado lê o token?) | CSRF (outro site usa o token?) | Sobrevive a recarregar a página? |
|---|---|---|---|
localStorage |
❌ Sim, lê | ✅ Não (não vai sozinho) | ✅ Sim |
sessionStorage |
❌ Sim, lê | ✅ Não | Só na mesma aba |
| Memória (variável JS) | 🟡 Mais difícil (some ao recarregar) | ✅ Não | ❌ Não, por isso existe o refresh |
Cookie HttpOnly + SameSite |
✅ Não, o JS não lê | 🟡 Mitigado pelo SameSite |
✅ Sim |
A combinação recomendada, access em memória + refresh em cookie HttpOnly, junta o melhor das duas últimas linhas. O localStorage é o mais usado em tutoriais por ser simples. Funciona, mas qualquer falha de XSS expõe o token.
⚠️ 6. Armadilhas comuns
| Sintoma ou risco | Causa | Correção |
|---|---|---|
| Token forjado aceito | Biblioteca configurada para aceitar alg: none, ou o algoritmo vem do próprio token |
Fixe o algoritmo esperado na validação |
| Qualquer pessoa com o código-fonte emite tokens | jwt.secret escrito no application.properties versionado |
Segredo em variável de ambiente ou cofre (Mod 12, cap. 07) |
| Usuário demitido continua acessando | Token de vida longa e sem revogação | Access curto + refresh com rotação e lista de revogação |
hasRole('ADMIN') nega acesso ao administrador |
A authority foi salva como ADMIN, e o hasRole procura ROLE_ADMIN |
Salve com o prefixo ROLE_ ou use hasAuthority('ADMIN') |
| Dados sensíveis expostos | CPF ou e-mail no payload “porque é prático” | Payload só com sub, papéis e datas |
| API aceita token de outro sistema | Mesma chave em vários ambientes, sem checar iss e aud |
Chave por ambiente; valide emissor e público |
🗂️ 7. No Catálogo de Projetos
fullstack_estoque_02_spring_authimplementa o fluxo deste capítulo em Spring Boot. Arquivos para estudar, emgestaodeestoques/src/main/java/br/com/aula/gestaodeestoques/:config/security/JwtTokenProvider.java: gera e valida o token com HS256 e expiração de 1 hora (jwt.expiration-in-ms=3600000).config/security/JwtAuthenticationFilter.java: o filtro da Seção 4, quase linha a linha.config/SecurityConfig.java: APISTATELESS, CSRF desligado e a lista de rotas públicas.controller/CategoriaController.java:@PreAuthorize("hasRole('ADMIN')")nas operações de escrita. Os papéis são criados comoROLE_ADMINeROLE_USERemconfig/DataSeeder.java, o jeito certo para ohasRole.
- Para analisar criticamente no mesmo projeto:
- o frontend (
resources/static/js/auth.js) guarda o token emlocalStorage(veja a Seção 5); - não há refresh token: depois de 1 hora, o usuário loga de novo;
- o
jwt.secretestá noapplication.propertiesversionado.
Corrigir as três é um bom exercício extra: mover o access token para a memória, criar a rota
/api/auth/refreshcom o refresh token em cookieHttpOnlye ler o segredo de uma variável de ambiente. - o frontend (
fullstack_liston_01_spring_angulartraz a mesma ideia com um cliente Angular e Ionic (backend/.../config/SecurityConfig.java).