🧩 Exercícios: Autenticação e Autorização Ponta a Ponta: JWT e RBAC
Aplicações Full Stack • Trilha Progressiva em 4 Níveis
Os exercícios usam Python só com a biblioteca padrão para deixar o mecanismo à vista. Em produção, use uma biblioteca mantida (PyJWT, jjwt,
jose): ninguém deve escrever a própria criptografia.
🎯 Nível 1: Fundamentos
Problema 02.1 — Lendo um JWT sem a chave
Contexto: Durante uma revisão de segurança, você encontra este token no localStorage de um navegador:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5vbWUiOiJBbmEiLCJyb2xlIjoiVVNFUiIsImlhdCI6MTg5MzQ1MjQwMCwiZXhwIjoxODkzNDU2MDAwfQ.R5dzWcpBXOgUWUktAeCi2St9vTJrmWwwFjUout-_4eI
Requisitos de Execução:
- Sem conhecer a chave secreta, escreva um script que separe as três partes e decodifique o header e o payload (Base64URL).
- Converta
iateexppara data e hora (UTC) e calcule a validade do token em minutos. - Responda: o que um atacante que copiou esse token consegue ler? E o que ele não consegue fazer?
Resultado Esperado
header : {'alg': 'HS256', 'typ': 'JWT'}
payload: {'sub': '42', 'nome': 'Ana', 'role': 'USER', 'iat': 1893452400, 'exp': 1893456000}
emitido: 2029-12-31 23:00:00+00:00
expira : 2030-01-01 00:00:00+00:00
validade: 60 minutos
📤 Instruções de Entrega (Microsoft Teams)
- Salve o arquivo como:
Atividade_02_1_SeuNome.py - Envie na tarefa:
Atividade Cap 02 - Autenticação JWT e RBAC
🔑 Gabarito de Código & Solução Comentada
"""ler_jwt.py — decodifica header e payload de um JWT (não valida nada!)."""
import base64
import json
from datetime import datetime, timezone
TOKEN = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiI0MiIsIm5vbWUiOiJBbmEiLCJyb2xlIjoiVVNFUiIsImlhdCI6MTg5MzQ1MjQwMCwiZXhwIjoxODkzNDU2MDAwfQ."
"R5dzWcpBXOgUWUktAeCi2St9vTJrmWwwFjUout-_4eI")
def b64url_decode(texto: str) -> bytes:
# Base64URL tira o "=" de preenchimento; é preciso devolvê-lo antes de decodificar
return base64.urlsafe_b64decode(texto + "=" * (-len(texto) % 4))
header_b64, payload_b64, _assinatura = TOKEN.split(".")
header = json.loads(b64url_decode(header_b64))
payload = json.loads(b64url_decode(payload_b64))
emitido = datetime.fromtimestamp(payload["iat"], tz=timezone.utc)
expira = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
print("header :", header)
print("payload:", payload)
print("emitido:", emitido)
print("expira :", expira)
print("validade:", int((expira - emitido).total_seconds() // 60), "minutos")
O que o atacante consegue: ler tudo o que está no payload (id, nome, papel), porque Base64URL é só uma codificação, não criptografia. Por isso, nunca coloque senha, CPF ou dado sensível num JWT. E, principalmente, ele consegue usar o token, enviando-o no Authorization, até ele expirar. É por isso que o access token deve durar pouco (aqui, 60 minutos).
O que ele não consegue: alterar o payload, por exemplo trocar "role": "USER" por "ADMIN", sem invalidar a assinatura. Para gerar uma assinatura válida, ele precisaria da chave secreta do servidor (Problema 02.2).
🔍 Nível 2: Prática
Problema 02.2 — Emitir e validar um JWT HS256
Contexto: Para entender o que o filtro JWT do backend faz a cada requisição, implemente a emissão e a validação de um token HS256.
Requisitos de Execução:
emitir(payload, segredo): montaheader.payload.assinatura, com assinatura HMAC-SHA256 sobreheader.payload.validar(token, segredo): recusa, nesta ordem, formato inválido, algoritmo diferente deHS256, assinatura inválida e token expirado. Devolve o payload quando estiver tudo certo.- Compare as assinaturas com
hmac.compare_digest, não com==. - Teste três ataques: payload alterado (
roletrocado paraADMIN), token com"alg": "none"e token expirado.
Resultado Esperado
válido: 42
role trocada para ADMIN: recusado -> assinatura inválida
alg none: recusado -> algoritmo recusado
expirado: recusado -> token expirado
📤 Instruções de Entrega (Microsoft Teams)
- Salve o arquivo como:
Atividade_02_2_SeuNome.py - Envie na tarefa:
Atividade Cap 02 - Autenticação JWT e RBAC
🔑 Gabarito de Código & Solução Comentada
"""jwt_hs256.py — emitir e validar um JWT HS256 só com a biblioteca padrão (para estudo)."""
import base64
import hashlib
import hmac
import json
import time
def b64url(dados: bytes) -> str:
return base64.urlsafe_b64encode(dados).rstrip(b"=").decode()
def b64url_decode(texto: str) -> bytes:
return base64.urlsafe_b64decode(texto + "=" * (-len(texto) % 4))
def emitir(payload: dict, segredo: bytes) -> str:
cabecalho = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
corpo = b64url(json.dumps(payload, separators=(",", ":")).encode())
assinatura = hmac.new(segredo, f"{cabecalho}.{corpo}".encode(), hashlib.sha256).digest()
return f"{cabecalho}.{corpo}.{b64url(assinatura)}"
def validar(token: str, segredo: bytes, agora: float | None = None) -> dict:
try:
cabecalho, corpo, assinatura = token.split(".")
except ValueError:
raise ValueError("formato inválido: esperado header.payload.signature")
if json.loads(b64url_decode(cabecalho)).get("alg") != "HS256":
raise ValueError("algoritmo recusado") # bloqueia alg=none e trocas de algoritmo
esperada = hmac.new(segredo, f"{cabecalho}.{corpo}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(esperada, b64url_decode(assinatura)):
raise ValueError("assinatura inválida") # payload alterado ou segredo errado
payload = json.loads(b64url_decode(corpo))
if payload.get("exp", 0) < (agora or time.time()):
raise ValueError("token expirado")
return payload
if __name__ == "__main__":
SEGREDO = b"segredo-de-estudo-com-32-bytes!!" # em produção: variável de ambiente
token = emitir({"sub": "42", "role": "USER", "exp": int(time.time()) + 900}, SEGREDO)
print("válido:", validar(token, SEGREDO)["sub"])
h, p, s = token.split(".")
adulterado = b64url(json.dumps({"sub": "42", "role": "ADMIN", "exp": 9999999999}).encode())
for nome, t in [("role trocada para ADMIN", f"{h}.{adulterado}.{s}"),
("alg none", b64url(b'{"alg":"none"}') + f".{p}."),
("expirado", emitir({"sub": "42", "exp": 1}, SEGREDO))]:
try:
validar(t, SEGREDO)
print(f"{nome}: ACEITO (falha de segurança!)")
except ValueError as erro:
print(f"{nome}: recusado -> {erro}")
Comentários:
- A ordem importa: o algoritmo é conferido antes da assinatura. Bibliotecas antigas aceitavam
"alg": "none"e pulavam a verificação. Esse é um ataque real e documentado. compare_digestleva o mesmo tempo para qualquer entrada. Com==, a comparação para no primeiro byte diferente, e medir esse tempo ajuda a descobrir a assinatura byte a byte (timing attack).- O
JwtTokenProvider.javado projetofullstack_estoque_02_spring_authfaz o mesmo com a biblioteca jjwt. Compare os dois.
⚡ Nível 3: Integração
Problema 02.3 — Refresh token com rotação e detecção de reuso
Contexto: No fullstack_estoque_02_spring_auth, o token dura 1 hora e não há refresh token: depois disso, o usuário precisa logar de novo. A equipe quer um access token curto sem obrigar o login a cada 15 minutos, e quer perceber quando um refresh token for roubado.
Requisitos de Execução:
- Crie
SessaoServicecomlogin(usuario), que devolve um par{access, refresh}, erenovar(refresh), que devolve um par novo. - Rotação: cada refresh token vale uma única vez. Depois de usado, fica marcado.
- Detecção de reuso: se um refresh já usado aparecer de novo, alguém o copiou. Revogue toda a família de tokens daquele login, inclusive o refresh atual do usuário legítimo.
- Gere os refresh tokens com
secrets, nunca comrandom.
Resultado Esperado
1ª renovação: ok, refresh trocado: True
atacante reusa o refresh antigo: 401: reuso detectado, sessão revogada
Ana usa o refresh atual: 401: refresh token inválido ou revogado
📤 Instruções de Entrega (Microsoft Teams)
- Salve o arquivo como:
Atividade_02_3_SeuNome.py - Envie na tarefa:
Atividade Cap 02 - Autenticação JWT e RBAC
🔑 Gabarito de Código & Solução Comentada
"""refresh_rotacao.py — refresh token com rotação e detecção de reuso."""
import secrets
from dataclasses import dataclass
@dataclass
class RegistroRefresh:
usuario: str
familia: str # todos os refresh tokens de um mesmo login
usado: bool = False
class SessaoService:
def __init__(self) -> None:
self.refresh: dict[str, RegistroRefresh] = {}
self.familias_revogadas: set[str] = set()
def login(self, usuario: str) -> dict:
return self._emitir_par(usuario, familia=secrets.token_hex(8))
def renovar(self, refresh_token: str) -> dict:
reg = self.refresh.get(refresh_token)
if reg is None or reg.familia in self.familias_revogadas:
raise PermissionError("401: refresh token inválido ou revogado")
if reg.usado:
# o mesmo refresh apareceu duas vezes: alguém o copiou. Derruba o login inteiro.
self.familias_revogadas.add(reg.familia)
raise PermissionError("401: reuso detectado, sessão revogada")
reg.usado = True # rotação: cada refresh vale uma vez
return self._emitir_par(reg.usuario, reg.familia)
def _emitir_par(self, usuario: str, familia: str) -> dict:
novo = secrets.token_urlsafe(32)
self.refresh[novo] = RegistroRefresh(usuario, familia)
return {"access": f"jwt-curto-de-{usuario}", "refresh": novo}
if __name__ == "__main__":
svc = SessaoService()
par1 = svc.login("ana")
par2 = svc.renovar(par1["refresh"])
print("1ª renovação: ok, refresh trocado:", par1["refresh"] != par2["refresh"])
for rotulo, token in [("atacante reusa o refresh antigo", par1["refresh"]),
("Ana usa o refresh atual", par2["refresh"])]:
try:
svc.renovar(token)
print(f"{rotulo}: aceito")
except PermissionError as erro:
print(f"{rotulo}: {erro}")
Comentários:
- O servidor não sabe quem reusou o token, o atacante ou a Ana. Por isso revoga a família inteira: a Ana faz login de novo, e o atacante perde o acesso.
- O refresh token é opaco (texto aleatório guardado no servidor), não um JWT. Assim ele pode ser revogado, o que um JWT sozinho não permite.
- No navegador, o refresh vai num cookie
HttpOnly; Secure; SameSite=StrictcomPath=/api/auth/refresh: o JavaScript não lê o cookie, e ele só é enviado para a rota de renovação. - No projeto real,
self.refreshvira uma tabela (refresh_tokens) ou uma chave no Redis com expiração.
🏆 Nível 4: Desafio Corporativo
Problema 02.4 — RBAC: 401 ou 403?
Contexto: O frontend de um sistema de estoque trata qualquer erro da API como “sessão expirada” e manda o usuário para o login. Um usuário comum que tenta excluir um produto é deslogado, tenta de novo e é deslogado outra vez. O problema começa no backend, que devolve o mesmo status para dois casos diferentes.
Requisitos de Execução:
- Implemente o decorador
@requer_papel(minimo)com a hierarquiaUSER < GERENTE < ADMIN. - Sem usuário autenticado, responda 401. Com usuário autenticado e papel insuficiente, responda 403. Um papel desconhecido nunca pode passar.
- Proteja
listar_produtos(exigeUSER) eexcluir_produto(exigeADMIN) e teste os cinco casos do resultado esperado. - Escreva em 3 linhas como o frontend deve reagir a cada status.
Resultado Esperado
sem token lista -> 401 Unauthorized: token ausente ou inválido
USER lista -> 200 ['Monitor', 'Teclado']
USER exclui -> 403 Forbidden: exige ADMIN
ADMIN exclui -> 204 produto 7 excluído por rui
papel desconhecido -> 403 Forbidden: exige USER
📤 Instruções de Entrega (Microsoft Teams)
- Salve o arquivo como:
Atividade_02_4_SeuNome.py - Envie na tarefa:
Atividade Cap 02 - Autenticação JWT e RBAC
🔑 Gabarito de Código & Solução Comentada
"""rbac.py — 401 (quem é você?) x 403 (sei quem é, mas não pode)."""
from functools import wraps
HIERARQUIA = {"USER": 1, "GERENTE": 2, "ADMIN": 3}
class Resposta(Exception):
def __init__(self, status: int, motivo: str):
super().__init__(f"{status} {motivo}")
self.status = status
def requer_papel(minimo: str):
def decorador(rota):
@wraps(rota)
def protegida(usuario: dict | None, *args, **kwargs):
if usuario is None:
raise Resposta(401, "Unauthorized: token ausente ou inválido")
if HIERARQUIA.get(usuario.get("role"), 0) < HIERARQUIA[minimo]:
raise Resposta(403, f"Forbidden: exige {minimo}")
return rota(usuario, *args, **kwargs)
return protegida
return decorador
@requer_papel("USER")
def listar_produtos(usuario):
return 200, ["Monitor", "Teclado"]
@requer_papel("ADMIN")
def excluir_produto(usuario, produto_id: int):
return 204, f"produto {produto_id} excluído por {usuario['sub']}"
if __name__ == "__main__":
casos = [
("sem token lista", listar_produtos, None, ()),
("USER lista", listar_produtos, {"sub": "ana", "role": "USER"}, ()),
("USER exclui", excluir_produto, {"sub": "ana", "role": "USER"}, (7,)),
("ADMIN exclui", excluir_produto, {"sub": "rui", "role": "ADMIN"}, (7,)),
("papel desconhecido", listar_produtos, {"sub": "x", "role": "ROOT"}, ()),
]
for nome, rota, usuario, args in casos:
try:
status, corpo = rota(usuario, *args)
print(f"{nome:<20} -> {status} {corpo}")
except Resposta as r:
print(f"{nome:<20} -> {r}")
Reação do frontend:
- 401 → tenta renovar com o refresh token (Problema 02.3). Se falhar, aí sim manda para o login.
- 403 → mantém o usuário logado e mostra “Você não tem permissão para esta ação”. Melhor ainda: nem exibe o botão “Excluir” para quem não é
ADMIN. - Esconder o botão é experiência do usuário, não segurança: o backend continua obrigado a devolver 403.
Comentários:
HIERARQUIA.get(..., 0)faz um papel desconhecido valer 0: na dúvida, nega (fail closed).- No Spring, o decorador equivale a
@PreAuthorize("hasRole('ADMIN')"), como noCategoriaController.javadofullstack_estoque_02_spring_auth. - Para investigar no mesmo projeto: numa API sem
httpBasicnemformLogin, o Spring Security 6 responde 403 também para quem chega sem token, justamente o defeito deste problema. OSecurityConfig.javaevita isso com uma linha:.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))). Falta um segundo cuidado: token expirado ou malformado faz o parser JWT lançar exceção dentro do filtro. OJwtAuthenticationFilter.javaa captura e segue sem autenticar, para que a resposta também seja 401, e não 500. OSecurityStatusIntegrationTest.javaconfere os cinco casos (sem token, malformado, expirado, papel insuficiente e válido) com a cadeia de filtros ligada. Os testes de controller usamaddFilters = falsee por isso não enxergariam o defeito.