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:

  1. Sem conhecer a chave secreta, escreva um script que separe as três partes e decodifique o header e o payload (Base64URL).
  2. Converta iat e exp para data e hora (UTC) e calcule a validade do token em minutos.
  3. 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)

  1. Salve o arquivo como: Atividade_02_1_SeuNome.py
  2. 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:

  1. emitir(payload, segredo): monta header.payload.assinatura, com assinatura HMAC-SHA256 sobre header.payload.
  2. validar(token, segredo): recusa, nesta ordem, formato inválido, algoritmo diferente de HS256, assinatura inválida e token expirado. Devolve o payload quando estiver tudo certo.
  3. Compare as assinaturas com hmac.compare_digest, não com ==.
  4. Teste três ataques: payload alterado (role trocado para ADMIN), 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)

  1. Salve o arquivo como: Atividade_02_2_SeuNome.py
  2. 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_digest leva 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.java do projeto fullstack_estoque_02_spring_auth faz 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:

  1. Crie SessaoService com login(usuario), que devolve um par {access, refresh}, e renovar(refresh), que devolve um par novo.
  2. Rotação: cada refresh token vale uma única vez. Depois de usado, fica marcado.
  3. 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.
  4. Gere os refresh tokens com secrets, nunca com random.

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)

  1. Salve o arquivo como: Atividade_02_3_SeuNome.py
  2. 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=Strict com Path=/api/auth/refresh: o JavaScript não lê o cookie, e ele só é enviado para a rota de renovação.
  • No projeto real, self.refresh vira 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:

  1. Implemente o decorador @requer_papel(minimo) com a hierarquia USER < GERENTE < ADMIN.
  2. Sem usuário autenticado, responda 401. Com usuário autenticado e papel insuficiente, responda 403. Um papel desconhecido nunca pode passar.
  3. Proteja listar_produtos (exige USER) e excluir_produto (exige ADMIN) e teste os cinco casos do resultado esperado.
  4. 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)

  1. Salve o arquivo como: Atividade_02_4_SeuNome.py
  2. 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 no CategoriaController.java do fullstack_estoque_02_spring_auth.
  • Para investigar no mesmo projeto: numa API sem httpBasic nem formLogin, o Spring Security 6 responde 403 também para quem chega sem token, justamente o defeito deste problema. O SecurityConfig.java evita 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. O JwtAuthenticationFilter.java a captura e segue sem autenticar, para que a resposta também seja 401, e não 500. O SecurityStatusIntegrationTest.java confere os cinco casos (sem token, malformado, expirado, papel insuficiente e válido) com a cadeia de filtros ligada. Os testes de controller usam addFilters = false e por isso não enxergariam o defeito.