Autenticação e Segurança em APIs Python

Autenticação e Segurança em APIs Python

Senhas com bcrypt, API Keys, JWT e OAuth2 no FastAPI, e as brechas que o caminho feliz esconde: o passlib que quebra com o bcrypt 5, o refresh token que abre rotas protegidas, o tempo de login que revela quem tem cadastro e o rate limit que nunca é aplicado.
Python

• • 22 min de leitura

Uma API sem autenticação é uma porta aberta. Segurança não é um recurso opcional que se adiciona no final — é uma responsabilidade que permeia toda a arquitetura do sistema. Neste artigo veremos os principais mecanismos de autenticação usados em APIs Python modernas: API Keys, JWT, OAuth2, hashing de senhas e boas práticas de segurança que todo desenvolvedor precisa conhecer.

Hashing de Senhas

Nunca armazene senhas em texto puro. Use hashing com sal — bcrypt é o padrão da indústria:

pip install bcrypt
import bcrypt


def hash_senha(senha: str) -> str:
    """Gera hash seguro da senha."""
    dados = senha.encode("utf-8")
    if len(dados) > 72:   # limite do bcrypt, em BYTES: 37 letras "é" já passam
        raise ValueError("Senha longa demais (máximo de 72 bytes).")
    return bcrypt.hashpw(dados, bcrypt.gensalt()).decode()


def verificar_senha(senha_plana: str, hash_armazenado: str) -> bool:
    """Verifica se a senha corresponde ao hash."""
    return bcrypt.checkpw(senha_plana.encode("utf-8"), hash_armazenado.encode())


# Uso
hash1 = hash_senha("minha_senha_secreta")
hash2 = hash_senha("minha_senha_secreta")

print(hash1)                                  # $2b$12$... — diferente a cada vez
print(hash1 == hash2)                         # False — sal aleatório
print(verificar_senha("minha_senha_secreta", hash1))  # True
print(verificar_senha("senha_errada",        hash1))  # False

O bcrypt é intencionalmente lento — dificulta ataques de força bruta mesmo que o banco de hashes seja comprometido.

O exemplo usa a biblioteca bcrypt diretamente, e não o passlib, que ainda aparece em muito tutorial: ele está sem manutenção, e com o bcrypt 5 o primeiro hash() já falha com ValueError: password cannot be longer than 72 bytes, disparado pela autochecagem interna do próprio passlib. Esse limite de 72 bytes é real e vale para qualquer código com bcrypt: ele conta bytes, não caracteres, e 37 letras "é" já somam 74. Recuse a senha longa com uma mensagem clara, como no hash_senha acima. Para projeto novo, o argon2 (pacote argon2-cffi) não tem esse limite e é a recomendação atual da OWASP.

Autenticação por API Key

A forma mais simples — adequada para comunicação entre serviços:

from fastapi import FastAPI, Security, HTTPException, status
from fastapi.security import APIKeyHeader, APIKeyQuery
from typing import Annotated

app = FastAPI()

# API Key pode vir no header ou na query string — mas, na query, ela fica gravada
# nos logs de acesso do servidor e dos proxies; prefira o header
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
API_KEY_QUERY  = APIKeyQuery(name="api_key",    auto_error=False)

# Em produção, armazene no banco com hash
CHAVES_VALIDAS = {
    "chave-servico-a": {"nome": "Serviço A", "permissoes": ["leitura"]},
    "chave-servico-b": {"nome": "Serviço B", "permissoes": ["leitura", "escrita"]},
}


async def verificar_api_key(
    header_key: Annotated[str | None, Security(API_KEY_HEADER)] = None,
    query_key:  Annotated[str | None, Security(API_KEY_QUERY)]  = None,
) -> dict:
    chave = header_key or query_key
    if not chave or chave not in CHAVES_VALIDAS:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="API Key inválida ou ausente.",
            headers={"WWW-Authenticate": "ApiKey"}
        )
    return CHAVES_VALIDAS[chave]


@app.get("/dados", dependencies=[Security(verificar_api_key)])
async def dados_protegidos():
    return {"dados": "conteúdo protegido"}


@app.get("/dados-com-contexto")
async def dados_com_contexto(
    servico: Annotated[dict, Security(verificar_api_key)]
):
    return {
        "servico":    servico["nome"],
        "permissoes": servico["permissoes"],
        "dados":      "conteúdo personalizado"
    }

JWT: JSON Web Tokens

JWT é o padrão mais usado para autenticação stateless em APIs REST:

pip install python-jose[cryptography]

Estrutura de um JWT:

header.payload.signature
eyJhbGci...  .eyJzdWIi...  .SflKxwRJ...
from jose import JWTError, jwt
from datetime import datetime, timedelta, UTC
from typing import Optional
import os

# Configurações
SECRET_KEY        = os.getenv("SECRET_KEY", "dev-secret-nunca-use-em-producao")
ALGORITMO         = "HS256"
EXPIRACAO_MINUTOS = 30


def criar_token(dados: dict, expira_em: Optional[timedelta] = None) -> str:
    """Gera um JWT assinado."""
    payload  = dados.copy()
    agora     = datetime.now(UTC)
    expiracao = agora + (expira_em or timedelta(minutes=EXPIRACAO_MINUTOS))
    payload.update({"exp": expiracao, "iat": agora})
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITMO)


def decodificar_token(token: str) -> dict:
    """Decodifica e valida um JWT."""
    try:
        return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITMO])
    except JWTError as e:
        raise ValueError(f"Token inválido: {e}")


# Tokens de acesso e refresh
def criar_par_tokens(usuario_id: int, email: str) -> dict:
    access_token = criar_token(
        {"sub": str(usuario_id), "email": email, "tipo": "access"},
        expira_em=timedelta(minutes=30)
    )
    refresh_token = criar_token(
        {"sub": str(usuario_id), "tipo": "refresh"},
        expira_em=timedelta(days=7)
    )
    return {
        "access_token":  access_token,
        "refresh_token": refresh_token,
        "token_type":    "bearer",
        "expira_em":     1800
    }

OAuth2 com FastAPI

FastAPI tem suporte nativo ao fluxo OAuth2 com senha:

from fastapi import FastAPI, Body, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr
import bcrypt
from typing import Annotated
from datetime import datetime, timedelta, UTC
from jose import JWTError, jwt
import os

app        = FastAPI(title="API com OAuth2")
oauth2     = OAuth2PasswordBearer(tokenUrl="/auth/login")

SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret")
ALGORITMO  = "HS256"

def hash_senha(senha: str) -> str:
    return bcrypt.hashpw(senha.encode(), bcrypt.gensalt()).decode()

def verificar_senha(senha: str, hash_armazenado: str) -> bool:
    return bcrypt.checkpw(senha.encode(), hash_armazenado.encode())

# Hash de uma senha qualquer, para o login gastar o mesmo tempo
# quando o e-mail não existe (ver o texto após o bloco)
HASH_FICTICIO = hash_senha("senha-que-ninguem-usa")

# Modelos
class Usuario(BaseModel):
    id:     int
    nome:   str
    email:  str
    ativo:  bool = True
    admin:  bool = False

class UsuarioDB(Usuario):
    senha_hash: str

class TokenResposta(BaseModel):
    access_token:  str
    refresh_token: str
    token_type:    str
    expira_em:     int

class TokenPayload(BaseModel):
    sub:   str
    email: str
    admin: bool = False


# Banco em memória
_usuarios: dict = {
    1: UsuarioDB(
        id=1, nome="Ana Admin", email="ana@email.com",
        admin=True,
        senha_hash=hash_senha("senha123")
    ),
    2: UsuarioDB(
        id=2, nome="Bruno User", email="bruno@email.com",
        senha_hash=hash_senha("senha456")
    ),
}


# Funções auxiliares
def buscar_por_email(email: str) -> UsuarioDB | None:
    return next((u for u in _usuarios.values() if u.email == email), None)


def criar_access_token(usuario: UsuarioDB) -> str:
    payload = {
        "sub":   str(usuario.id),
        "email": usuario.email,
        "admin": usuario.admin,
        "tipo":  "access",
        "exp":   datetime.now(UTC) + timedelta(minutes=30)
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITMO)


def criar_refresh_token(usuario_id: int) -> str:
    payload = {
        "sub":  str(usuario_id),
        "tipo": "refresh",
        "exp":  datetime.now(UTC) + timedelta(days=7)
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITMO)


# Dependência de autenticação
async def usuario_atual(
    token: Annotated[str, Depends(oauth2)]
) -> UsuarioDB:
    excecao = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Token inválido ou expirado.",
        headers={"WWW-Authenticate": "Bearer"}
    )
    try:
        payload  = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITMO])
        # sem esta checagem, o refresh token (7 dias) abriria as rotas protegidas
        if payload.get("tipo") != "access":
            raise excecao
        user_id  = int(payload.get("sub", 0))
        usuario  = _usuarios.get(user_id)
        if not usuario or not usuario.ativo:
            raise excecao
        return usuario
    except JWTError:
        raise excecao


async def usuario_admin(
    usuario: Annotated[UsuarioDB, Depends(usuario_atual)]
) -> UsuarioDB:
    if not usuario.admin:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Requer privilégios de administrador."
        )
    return usuario


# Endpoints de autenticação
@app.post("/auth/login", response_model=TokenResposta, tags=["Auth"])
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
    usuario = buscar_por_email(form.username)
    # verifica um hash mesmo sem usuário: o tempo de resposta não revela quem existe
    hash_alvo  = usuario.senha_hash if usuario else HASH_FICTICIO
    senha_ok   = verificar_senha(form.password, hash_alvo)

    if not usuario or not senha_ok:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="E-mail ou senha incorretos.",
            headers={"WWW-Authenticate": "Bearer"}
        )
    if not usuario.ativo:
        raise HTTPException(status_code=400, detail="Conta desativada.")

    return TokenResposta(
        access_token  = criar_access_token(usuario),
        refresh_token = criar_refresh_token(usuario.id),
        token_type    = "bearer",
        expira_em     = 1800
    )


@app.post("/auth/refresh", tags=["Auth"])
async def renovar_token(refresh_token: Annotated[str, Body(embed=True)]):
    # no corpo, e não na query string, onde o token ficaria nos logs
    try:
        payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITMO])
        if payload.get("tipo") != "refresh":
            raise ValueError("Token não é do tipo refresh.")
        user_id = int(payload["sub"])
        usuario = _usuarios.get(user_id)
        if not usuario:
            raise ValueError("Usuário não encontrado.")
        return {
            "access_token": criar_access_token(usuario),
            "token_type":   "bearer",
            "expira_em":    1800
        }
    except (JWTError, ValueError) as e:
        raise HTTPException(status_code=401, detail=str(e))


# Endpoints protegidos
@app.get("/perfil", response_model=Usuario, tags=["Usuários"])
async def meu_perfil(usuario: Annotated[UsuarioDB, Depends(usuario_atual)]):
    return usuario


@app.get("/admin/usuarios", tags=["Admin"])
async def listar_usuarios(
    _: Annotated[UsuarioDB, Depends(usuario_admin)]
):
    return [
        {"id": u.id, "nome": u.nome, "email": u.email, "admin": u.admin}
        for u in _usuarios.values()
    ]

Três detalhes desse bloco fecham brechas que o fluxo básico deixa abertas, todos medidos com o TestClient. Sem o campo "tipo": "access" conferido em usuario_atual, o refresh token — válido por 7 dias — abria o /perfil normalmente, e o prazo curto do access token deixava de valer. Sem o HASH_FICTICIO, o login respondia em 3 ms para e-mail inexistente e em 221 ms para senha errada: a diferença dizia a qualquer um quais e-mails estão cadastrados; com ele, os dois casos levam 223 ms. E o refresh token vai no corpo da requisição (Body(embed=True)), não na query string, onde ficaria gravado nos logs de acesso.

Rate Limiting

Protege a API contra abuso e ataques de força bruta:

pip install slowapi
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from fastapi import Request

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)


# O limite vai na PRÓPRIA rota de login do bloco anterior. Declarar outra função
# com o mesmo caminho não funciona: o FastAPI atende pela primeira registrada, e a
# nova — com o limite — nunca é chamada.
@app.post("/auth/login", response_model=TokenResposta, tags=["Auth"])
@limiter.limit("5/minute")   # máximo 5 tentativas por minuto por IP
async def login(request: Request, form: Annotated[OAuth2PasswordRequestForm, Depends()]):
    ...   # mesmo corpo do login mostrado acima


@app.get("/dados")
@limiter.limit("100/minute")
async def dados_com_limite(request: Request):
    return {"dados": "conteúdo"}

O erro mais comum com o slowapi é declarar uma função nova para o mesmo caminho, como se o decorador "envolvesse" a rota existente. As duas ficam registradas, o FastAPI atende pela primeira, e o limite nunca é aplicado: oito tentativas seguidas de senha errada devolveram oito 401, nenhum 429. Com o @limiter.limit na própria rota de login, a sexta tentativa já recebeu 429. A função precisa ainda receber request: Request, que o slowapi usa para achar o IP — e, atrás de proxy, esse IP é o do proxy, o que faz todos os clientes dividirem o mesmo limite.

HTTPS e Cabeçalhos de Segurança

from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request


# Redireciona HTTP → HTTPS em produção
# app.add_middleware(HTTPSRedirectMiddleware)

# Rejeita hosts não autorizados
app.add_middleware(
    TrustedHostMiddleware,
    # "testserver" é o host do TestClient: sem ele, todo teste recebe 400
    allowed_hosts=["meusite.com", "api.meusite.com", "localhost", "testserver"]
)


class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    """Adiciona cabeçalhos de segurança HTTP essenciais."""

    async def dispatch(self, request: Request, call_next):
        resposta = await call_next(request)
        resposta.headers["X-Content-Type-Options"]    = "nosniff"
        resposta.headers["X-Frame-Options"]           = "DENY"
        resposta.headers["X-XSS-Protection"]          = "0"   # o filtro antigo criava falhas; quem protege é a CSP
        resposta.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
        # 'self' bloqueia o /docs, que carrega o Swagger de uma CDN
        if not request.url.path.startswith(("/docs", "/redoc")):
            resposta.headers["Content-Security-Policy"] = "default-src 'self'"
        resposta.headers["Referrer-Policy"]           = "strict-origin-when-cross-origin"
        return resposta


app.add_middleware(SecurityHeadersMiddleware)

Cada cabeçalho de segurança tem efeito colateral. O TrustedHostMiddleware recusa com 400 qualquer host fora da lista, inclusive o testserver do TestClient — sem ele na lista, todos os testes do artigo falhavam com Invalid host header. A CSP default-src 'self' impede o navegador de carregar o Swagger, que o /docs busca numa CDN; por isso ela fica de fora dessas duas rotas. E o X-XSS-Protection vale 0: o filtro que ele ligava foi removido dos navegadores atuais e, nos antigos, podia ser usado para vazar dados da página; a proteção contra XSS hoje é a própria CSP.

Variáveis de Ambiente e Configuração Segura

from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache


class Configuracoes(BaseSettings):
    # App
    nome_app:    str   = "API Escolar"
    versao:      str   = "1.0.0"
    debug:       bool  = False
    ambiente:    str   = "producao"

    # Segurança
    secret_key:       str
    algoritmo_jwt:    str   = "HS256"
    expiracao_access: int   = 30      # minutos
    expiracao_refresh: int  = 10080   # 7 dias em minutos

    # Banco
    database_url: str

    # CORS
    origens_permitidas: list[str] = ["http://localhost:3000"]

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")


@lru_cache
def get_configuracoes() -> Configuracoes:
    return Configuracoes()


# .env
# SECRET_KEY=sua-chave-super-secreta-com-mais-de-32-caracteres
# DATABASE_URL=postgresql://user:senha@localhost/escola
# DEBUG=false
# AMBIENTE=producao

Checklist de Segurança

"""
Checklist de segurança para APIs Python em produção:

✅ Senhas
   - Nunca armazene em texto puro
   - Use bcrypt, argon2 ou scrypt
   - Valide força mínima da senha

✅ Tokens JWT
   - Use SECRET_KEY longa e aleatória (mínimo 32 chars)
   - Defina expiração curta para access token (15-30 min)
   - Use refresh tokens de longa duração separadamente
   - Valide algoritmo explicitamente — nunca aceite "none"

✅ Autenticação
   - Limite tentativas de login (rate limiting)
   - Implemente bloqueio temporário após N falhas
   - Use HTTPS em produção — nunca HTTP

✅ Dados
   - Valide e sanitize toda entrada do usuário
   - Use parâmetros em consultas SQL — nunca concatene strings
   - Não exponha stack traces em produção

✅ Configuração
   - Segredos em variáveis de ambiente — nunca no código
   - Adicione .env ao .gitignore
   - Cabeçalhos de segurança HTTP em todas as respostas

✅ Dependências
   - Mantenha dependências atualizadas
   - Use pip audit para verificar vulnerabilidades
   - Revise permissões de cada pacote
"""

Exemplo Completo: Testando a Autenticação

from fastapi.testclient import TestClient

client = TestClient(app)


def test_login_sucesso():
    resposta = client.post("/auth/login", data={
        "username": "ana@email.com",
        "password": "senha123"
    })
    assert resposta.status_code == 200
    dados = resposta.json()
    assert "access_token"  in dados
    assert "refresh_token" in dados
    assert dados["token_type"] == "bearer"


def test_login_senha_errada():
    resposta = client.post("/auth/login", data={
        "username": "ana@email.com",
        "password": "errada"
    })
    assert resposta.status_code == 401


def test_rota_protegida_sem_token():
    resposta = client.get("/perfil")
    assert resposta.status_code == 401


def test_rota_protegida_com_token():
    login = client.post("/auth/login", data={
        "username": "ana@email.com",
        "password": "senha123"
    })
    token    = login.json()["access_token"]
    resposta = client.get(
        "/perfil",
        headers={"Authorization": f"Bearer {token}"}
    )
    assert resposta.status_code == 200
    assert resposta.json()["email"] == "ana@email.com"


def test_acesso_admin_sem_privilegio():
    login = client.post("/auth/login", data={
        "username": "bruno@email.com",
        "password": "senha456"
    })
    token    = login.json()["access_token"]
    resposta = client.get(
        "/admin/usuarios",
        headers={"Authorization": f"Bearer {token}"}
    )
    assert resposta.status_code == 403


def test_refresh_nao_serve_como_access():
    login = client.post("/auth/login", data={
        "username": "ana@email.com",
        "password": "senha123"
    })
    refresh  = login.json()["refresh_token"]
    resposta = client.get(
        "/perfil",
        headers={"Authorization": f"Bearer {refresh}"}
    )
    assert resposta.status_code == 401

Autenticar uma API é responder duas perguntas a cada requisição — quem está chamando, e se essa identidade ainda vale —, e cada mecanismo do artigo responde de um jeito. A API Key identifica um serviço e deve viajar no cabeçalho, nunca na URL. A senha nunca é guardada, só o hash dela, com um algoritmo lento de propósito como o bcrypt ou o argon2, e com o limite de 72 bytes do bcrypt tratado antes de virar erro. O JWT carrega a identidade assinada e dispensa sessão no servidor, o que obriga o próprio token a dizer para que serve: um refresh token que abre rotas protegidas transforma a expiração de 30 minutos em 7 dias.

Em volta da autenticação ficam as defesas que não aparecem no caminho feliz. O login precisa gastar o mesmo tempo para e-mail inexistente e senha errada, ou a latência revela quem está cadastrado. O rate limit só protege se estiver na rota que de fato atende, e conta IPs que, atrás de proxy, podem ser todos o mesmo. Os cabeçalhos de segurança endurecem o navegador, mas cada um cobra um ajuste — o host do cliente de testes, a CDN da documentação. E os segredos vêm do ambiente, carregados por uma classe de configuração que falha na inicialização se algum estiver faltando.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Uma API emite access tokens de 30 minutos e refresh tokens de 7 dias, os dois JWT assinados com a mesma chave. Um auditor pega o refresh token de um usuário, manda no cabeçalho Authorization: Bearer de uma rota protegida, e recebe os dados normalmente. A equipe argumenta que o token é legítimo, assinado pelo próprio servidor. Por que isso é uma falha, e como corrigir?

Ver resposta

✓ Resposta: A assinatura prova só que o servidor emitiu o token, não para que ele serve. Se a dependência de autenticação decodifica o JWT, lê o sub e carrega o usuário sem olhar mais nada, qualquer token válido serve para qualquer coisa — e o refresh, que dura 7 dias, passa a funcionar como um access token de 7 dias. Medido com o exemplo do artigo antes da correção: o /perfil respondeu 200 com o refresh token. O prazo curto do access token existe justamente para limitar o estrago de um token vazado; com a brecha, o vazamento de um refresh token dá uma semana de acesso. A correção é o token declarar o próprio propósito e cada ponto conferir: o access leva "tipo": "access", o refresh leva "tipo": "refresh", a dependência das rotas protegidas recusa com 401 tudo o que não for access, e o endpoint de renovação recusa tudo o que não for refresh. Outras camadas reforçam a separação — o claim padrão aud (audiência), chaves de assinatura diferentes para cada tipo, ou refresh tokens opacos guardados no banco, que podem ser revogados. E vale um teste automatizado exatamente para esse caso, como o test_refresh_nao_serve_como_access do artigo.

Exercício 2

Um pesquisador de segurança avisa que consegue descobrir quais e-mails estão cadastrados numa API sem nenhuma mensagem de erro diferente: as duas respostas são idênticas, "E-mail ou senha incorretos." com 401. Ele só mede o tempo de resposta. Explique como, e o que mudar no código de login.

Ver resposta

✓ Resposta: O código típico busca o usuário e, se não achar, responde na hora; se achar, verifica a senha com bcrypt — que é lento de propósito. O texto e o código de status são iguais, mas o tempo não: medido com o exemplo do artigo, o login com e-mail inexistente levou 3 ms, e com e-mail existente e senha errada, 221 ms. Com algumas tentativas por e-mail, a diferença é inconfundível mesmo pela internet, e a lista de e-mails válidos alimenta phishing e ataques de senha direcionados. A correção é fazer o caminho do usuário inexistente gastar o mesmo trabalho: guardar o hash de uma senha qualquer e verificá-lo quando o usuário não existe, descartando o resultado. Com isso, os dois casos passaram a levar 223 ms. A mesma preocupação vale para os outros pontos que revelam cadastro — "esqueci minha senha" deve responder sempre "se o e-mail existir, enviaremos um link", e o cadastro que acusa "e-mail já em uso" merece rate limit próprio. E o rate limit no login reduz o número de medições que o atacante consegue fazer, o que dificulta, mas não substitui, a correção do tempo.

Exercício 3

Depois de um ataque de força bruta, um desenvolvedor adiciona o slowapi e escreve, logo abaixo da rota de login existente, um @app.post("/auth/login") com @limiter.limit("5/minute") numa função nova. O deploy passa, os testes passam, e o ataque continua com milhares de tentativas por minuto. Por que o limite não funciona, e como confirmar que um rate limit está ativo?

Ver resposta

✓ Resposta: O decorador não envolve a rota existente; ele envolve a função nova, que vira uma segunda rota com o mesmo método e o mesmo caminho. O FastAPI não reclama da duplicata: registra as duas, e cada requisição é atendida pela primeira que casar — a antiga, sem limite. A nova nunca é chamada. Medido: com as duas rotas registradas, oito tentativas seguidas de senha errada devolveram oito 401 e nenhum 429. Com o @limiter.limit("5/minute") aplicado à própria função de login (que também precisa receber request: Request), a sexta tentativa recebeu 429. Os testes passavam porque nenhum testava o limite. A confirmação tem de ser por comportamento, não por leitura de código: um teste que faz seis tentativas e espera 429 na última, e, em produção, a métrica de respostas 429 da rota de login. Há ainda uma armadilha de infraestrutura: o get_remote_address usa o IP da conexão, que atrás de um proxy reverso é o do proxy — todos os usuários dividem o mesmo contador, e cinco erros de qualquer um bloqueiam todo mundo. Nesse caso, o IP real tem de vir de um X-Forwarded-For tratado com o número certo de proxies confiáveis.

Exercício 4

Um projeto antigo usa passlib[bcrypt] para as senhas. Numa atualização de dependências, o bcrypt sobe para a versão 5 e o login para de funcionar para todos com ValueError: password cannot be longer than 72 bytes — inclusive para senhas de oito caracteres. Explique o erro, e o que fazer tanto com o código quanto com os hashes já gravados.

Ver resposta

✓ Resposta: O erro não vem da senha do usuário. Ao carregar o backend, o passlib 1.7.4 roda uma autochecagem que calcula o hash de uma senha de teste com mais de 72 bytes, para detectar um bug antigo de algumas implementações. Até o bcrypt 4, senhas longas eram truncadas em silêncio; o bcrypt 5 passou a recusá-las com ValueError, e a autochecagem quebra — medido: o primeiro hash() do exemplo original falhou assim. O passlib está sem manutenção e não vai receber correção. Fixar o bcrypt numa versão antiga é remendo; a saída é trocar a camada. Os hashes gravados não precisam mudar: o formato $2b$12$... é padrão, e bcrypt.checkpw verifica diretamente os hashes que o passlib gerou. Basta substituir as chamadas por bcrypt.hashpw/bcrypt.checkpw e tratar o limite de 72 bytes explicitamente — ele conta bytes, e 37 letras acentuadas já passam dele. Para migrar para argon2, o caminho é gradual: no login bem-sucedido, com a senha em mãos, verifica-se o hash antigo e grava-se o novo, sem forçar ninguém a trocar a senha.

Exercício 5

Uma equipe adiciona à API o TrustedHostMiddleware com os domínios de produção e um middleware com Content-Security-Policy: default-src 'self'. Em seguida, a suíte de testes inteira falha com 400, e o /docs abre como uma página em branco. Explique as duas falhas e ajuste a configuração sem perder a proteção.

Ver resposta

✓ Resposta: O TrustedHostMiddleware compara o cabeçalho Host de cada requisição com a lista permitida e responde 400 Invalid host header a qualquer outro. O TestClient manda por padrão Host: testserver, que não está entre os domínios de produção — medido: a rota mais simples respondeu 400 nos testes. A correção é incluir testserver na lista, de preferência só na configuração de testes, ou criar o cliente com base_url de um host permitido. A página em branco vem da CSP: o /docs do FastAPI é um HTML que carrega o JavaScript e o CSS do Swagger UI de uma CDN (cdn.jsdelivr.net), e default-src 'self' manda o navegador bloquear tudo o que não vem da própria origem. Há duas saídas: não aplicar a CSP às rotas de documentação, como no exemplo do artigo, ou mantê-la e liberar a CDN nessas rotas com script-src e style-src próprios — ou servir os arquivos do Swagger localmente. Em produção, muitas APIs simplesmente desligam o /docs (docs_url=None), o que resolve a questão e reduz a superfície exposta.

Comentários

Mais em Python

Templates HTML com Jinja2 e Deploy com Docker
Templates HTML com Jinja2 e Deploy com Docker

Jinja2 no Flask e no FastAPI, e o deploy com Docker e Compose: os filtros que…

Algoritmos de Ordenação e Busca
Algoritmos de Ordenação e Busca

Os algoritmos clássicos de ordenação e busca, com o que cada um ensina a…

Recursão e Estruturas de Dados Avançadas: pilhas, filas e árvores
Recursão e Estruturas de Dados Avançadas: pilhas, filas e árvores

Recursão, pilhas, filas e árvores, com o que os materiais costumam omitir: por…