Pular para o conteúdo

    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

    1. O cliente envia username e password para /token, como formulário (o padrão OAuth2).
    2. O servidor confere a senha contra o hash e devolve um access_token com token_type: "bearer".
    3. Nas requisições seguintes, o cliente envia Authorization: Bearer <token>.
    4. 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].

    intermediario/cap20_oauth2_rotas.pylinhas 10 a 32
    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.

    intermediario/cap20_oauth2_rotas.pylinhas 37 a 58
    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):

    intermediario/cap20_oauth2_rotas.pylinhas 63 a 84
    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":

    intermediario/cap20_oauth2_rotas.pylinhas 89 a 100
    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:

    intermediario/cap20_oauth2_rotas.pylinhas 105 a 116
    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}"}
    
    Saída
    200 bearer 2
    401 True
    
    intermediario/cap20_oauth2_rotas.pylinhas 118 a 127
    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)
    
    Saída
    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.

    intermediario/cap20_oauth2_rotas.pylinhas 132 a 133
    esquemas = app.openapi()["components"]["securitySchemes"]
    print(list(esquemas), esquemas["OAuth2PasswordBearer"]["flows"]["password"]["tokenUrl"])
    
    Saída
    ['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.