Capítulo 20, Intermediário
OAuth2 e rotas protegidas
Com o hash e o token prontos, falta o fluxo: a rota de login, a dependência que exige o token e o controle de quem pode o quê. O FastAPI já traz o padrão OAuth2, e o Swagger o entende.
O fluxo de senha com token bearer
- O cliente envia
usernameepasswordpara/token, como formulário (o padrão OAuth2). - O servidor confere a senha contra o hash e devolve um
access_tokencomtoken_type: "bearer". - Nas requisições seguintes, o cliente envia
Authorization: Bearer <token>. - Uma dependência valida o token e entrega o usuário à rota, ou recusa com
401.
O OAuth2PasswordRequestForm lê o formulário, e o OAuth2PasswordBearer lê o cabeçalho e avisa ao Swagger onde fica o login (é isso que habilita o botão Authorize). Os dois dependem do python-multipart, que vem no fastapi[standard].
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.testclient import TestClient
from pwdlib import PasswordHash
CHAVE = "uma-chave-secreta-com-no-minimo-32-bytes-0123"
ALGORITMO = "HS256"
EXPIRA_EM_MINUTOS = 30
hasher = PasswordHash.recommended()
SENHA_FALSA = hasher.hash("senha-que-ninguem-usa")
usuarios = {
"ana": {"nome": "ana", "senha_hash": hasher.hash("1234"), "papel": "admin"},
"bia": {"nome": "bia", "senha_hash": hasher.hash("abcd"), "papel": "usuario"},
}
oauth2 = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()
O login
Duas decisões merecem atenção. A primeira: a mensagem de erro é a mesma para "usuário não existe" e "senha errada", para não revelar quais usuários existem. A segunda: quando o usuário não existe, eu ainda calculo um hash (contra uma senha falsa). O Argon2 é lento de propósito, e se só os usuários existentes gastassem esse tempo, um atacante mediria a resposta para descobrir quais nomes são válidos.
def autenticar(nome: str, senha: str) -> dict | None:
usuario = usuarios.get(nome)
senha_hash = usuario["senha_hash"] if usuario else SENHA_FALSA
senha_confere = hasher.verify(senha, senha_hash)
return usuario if usuario and senha_confere else None
def criar_token(nome: str, minutos: int = EXPIRA_EM_MINUTOS) -> str:
exp = datetime.now(timezone.utc) + timedelta(minutes=minutos)
return jwt.encode({"sub": nome, "exp": exp}, CHAVE, algorithm=ALGORITMO)
@app.post("/token")
def login(formulario: Annotated[OAuth2PasswordRequestForm, Depends()]):
usuario = autenticar(formulario.username, formulario.password)
if usuario is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Usuário ou senha inválidos",
headers={"WWW-Authenticate": "Bearer"},
)
return {"access_token": criar_token(usuario["nome"]), "token_type": "bearer"}
Proteger rotas com uma dependência
A dependência usuario_atual lê o token, valida e devolve o usuário. Qualquer rota que a peça fica protegida. Repare que ela captura InvalidTokenError (e não um except: solto, que esconderia até os bugs seus):
def usuario_atual(token: Annotated[str, Depends(oauth2)]) -> dict:
erro = HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Token inválido ou expirado",
headers={"WWW-Authenticate": "Bearer"},
)
try:
carga = jwt.decode(token, CHAVE, algorithms=[ALGORITMO])
except jwt.InvalidTokenError:
raise erro
usuario = usuarios.get(carga.get("sub", ""))
if usuario is None:
raise erro
return usuario
UsuarioAtual = Annotated[dict, Depends(usuario_atual)]
@app.get("/eu")
def eu(usuario: UsuarioAtual):
return {"nome": usuario["nome"], "papel": usuario["papel"]}
Quem pode o quê: papéis
Estar autenticado não basta para tudo. Uma fábrica de dependências cria a verificação de papel, e o 403 (e não o 401) diz "eu sei quem você é, mas você não pode":
def requer_papel(papel: str):
def verificador(usuario: UsuarioAtual) -> dict:
if usuario["papel"] != papel:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Sem permissão para esta ação")
return usuario
return verificador
@app.get("/admin/segredos")
def segredos(usuario: Annotated[dict, Depends(requer_papel("admin"))]):
return {"segredo": "só para administradores", "visto_por": usuario["nome"]}
O fluxo na prática
O login envia formulário (data=), e não JSON. O token vai no cabeçalho Authorization:
cliente = TestClient(app)
certo = cliente.post("/token", data={"username": "ana", "password": "1234"})
print(certo.status_code, certo.json()["token_type"], certo.json()["access_token"].count("."))
errado = cliente.post("/token", data={"username": "ana", "password": "errada"})
inexistente = cliente.post("/token", data={"username": "zeca", "password": "1234"})
print(errado.status_code, errado.json() == inexistente.json())
def cabecalho(nome: str, senha: str) -> dict:
token = cliente.post("/token", data={"username": nome, "password": senha}).json()["access_token"]
return {"Authorization": f"Bearer {token}"}
200 bearer 2
401 True
sem_token = cliente.get("/eu")
print(sem_token.status_code, sem_token.headers["www-authenticate"])
print(cliente.get("/eu", headers=cabecalho("ana", "1234")).json())
print(cliente.get("/eu", headers={"Authorization": "Bearer lixo"}).status_code)
vencido = criar_token("ana", minutos=-1)
print(cliente.get("/eu", headers={"Authorization": f"Bearer {vencido}"}).status_code)
print(cliente.get("/admin/segredos", headers=cabecalho("ana", "1234")).status_code)
print(cliente.get("/admin/segredos", headers=cabecalho("bia", "abcd")).status_code)
401 Bearer
{'nome': 'ana', 'papel': 'admin'}
401
401
200
403
O que o Swagger ganha
Como a rota usa OAuth2PasswordBearer, o documento OpenAPI declara o esquema de segurança, e a página /docs ganha o botão Authorize: você entra uma vez, e todas as rotas protegidas passam a enviar o token sozinhas.
esquemas = app.openapi()["components"]["securitySchemes"]
print(list(esquemas), esquemas["OAuth2PasswordBearer"]["flows"]["password"]["tokenUrl"])
['OAuth2PasswordBearer'] token
O que um JWT não faz
Um JWT é sem estado: o servidor não guarda nada, e por isso não consegue cancelar um token antes de ele expirar (um "logout" de verdade, ou a revogação de um token roubado). Por isso a validade é curta (minutos). Para sessões longas, o padrão é um segundo token de renovação (refresh token), de vida longa, guardado no servidor e que pode ser revogado. E, sempre, só em HTTPS: um token em HTTP puro é um token roubado.
Exercício 1
Um token vencido recusa o acesso
Confira que um token de bia com validade negativa é recusado em /eu, e que o token válido dela mostra o papel usuario.