Capítulo 32, Projetos
Blog API: usuários e autenticação
Primeiro quem é quem. Os modelos das duas tabelas, os contratos de entrada e saída, e a segurança: hash de senha, token e a dependência que entrega o usuário logado a qualquer rota.
As tabelas
Dois modelos, ligados por uma chave estrangeira: um Usuario tem vários Post. O e-mail é único e indexado (a busca no login é por ele). O ondelete="CASCADE" apaga os posts de um usuário removido, e o server_default=func.now() faz o banco preencher a data de criação:
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Usuario(Base):
__tablename__ = "usuarios"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
nome: Mapped[str] = mapped_column(String(100))
senha_hash: Mapped[str] = mapped_column(String(255))
criado_em: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
posts: Mapped[list["Post"]] = relationship(back_populates="autor", cascade="all, delete-orphan")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
titulo: Mapped[str] = mapped_column(String(200))
conteudo: Mapped[str] = mapped_column(Text)
autor_id: Mapped[int] = mapped_column(ForeignKey("usuarios.id", ondelete="CASCADE"), index=True)
criado_em: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
atualizado_em: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=func.now(), default=None
)
autor: Mapped[Usuario] = relationship(back_populates="posts")
Os contratos
O modelo do banco e o modelo da API são coisas diferentes (capítulo 17). Aqui está a separação na prática: UsuarioCriar recebe a senha, e UsuarioSaida não tem campo de senha nem de hash, de modo que não há como vazá-los por descuido. O EmailStr valida o formato do e-mail, e a senha tem tamanho mínimo e máximo (o máximo evita que alguém mande um texto enorme só para sobrecarregar o hash). O Pagina[T] é o modelo genérico do capítulo 27:
from datetime import datetime
from typing import Generic, TypeVar
from pydantic import BaseModel, ConfigDict, EmailStr, Field
T = TypeVar("T")
class UsuarioCriar(BaseModel):
email: EmailStr
nome: str = Field(min_length=1, max_length=100)
senha: str = Field(min_length=8, max_length=128)
class UsuarioSaida(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
nome: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class PostCriar(BaseModel):
titulo: str = Field(min_length=1, max_length=200)
conteudo: str = Field(min_length=1)
class PostSaida(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
titulo: str
conteudo: str
autor_id: int
criado_em: datetime
atualizado_em: datetime | None
class Pagina(BaseModel, Generic[T]):
pagina: int
limite: int
total: int
paginas: int
dados: list[T]
Hash e token
O security.py junta o que vimos nos capítulos 19 e 20. Uma decisão a observar: o sub do token guarda o id do usuário (um identificador que não muda), e não o e-mail (que o usuário pode trocar). A função ler_token devolve o id ou None, escondendo da rota a diferença entre "expirado", "adulterado" e "malformado":
from datetime import datetime, timedelta, timezone
import jwt
from pwdlib import PasswordHash
from app.config import Configuracoes
hasher = PasswordHash.recommended()
# Hash de uma senha que ninguém usa: serve para gastar o mesmo tempo quando o e-mail não existe.
SENHA_FALSA = hasher.hash("senha-que-ninguem-usa")
def hash_senha(senha: str) -> str:
return hasher.hash(senha)
def verificar_senha(senha: str, senha_hash: str) -> bool:
return hasher.verify(senha, senha_hash)
def criar_token(config: Configuracoes, usuario_id: int, minutos: int | None = None) -> str:
duracao = config.expira_em_minutos if minutos is None else minutos
expira = datetime.now(timezone.utc) + timedelta(minutes=duracao)
carga = {"sub": str(usuario_id), "exp": expira}
return jwt.encode(carga, config.chave_secreta.get_secret_value(), algorithm=config.algoritmo)
def ler_token(config: Configuracoes, token: str) -> int | None:
"""Devolve o id do usuário, ou None se o token for inválido, adulterado ou vencido."""
try:
carga = jwt.decode(
token, config.chave_secreta.get_secret_value(), algorithms=[config.algoritmo]
)
return int(carga["sub"])
except (jwt.InvalidTokenError, KeyError, ValueError):
return None
As dependências
O deps.py reúne as três peças que as rotas pedem. A sessão vem da fábrica guardada em app.state (assim cada aplicação, inclusive a de teste, tem o seu banco). A configuração vem do mesmo lugar. E o usuário atual lê o token, busca o usuário no banco e devolve o objeto. Buscar no banco, em vez de confiar só no token, garante que um usuário apagado perde o acesso mesmo com um token ainda válido:
from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.config import Configuracoes
from app.models import Usuario
from app.security import ler_token
oauth2 = OAuth2PasswordBearer(tokenUrl="auth/token")
def obter_config(request: Request) -> Configuracoes:
config: Configuracoes = request.app.state.config
return config
def obter_sessao(request: Request) -> Iterator[Session]:
with request.app.state.fabrica_sessao() as sessao:
yield sessao
Config = Annotated[Configuracoes, Depends(obter_config)]
Sessao = Annotated[Session, Depends(obter_sessao)]
def usuario_atual(
token: Annotated[str, Depends(oauth2)], sessao: Sessao, config: Config
) -> Usuario:
erro = HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Token inválido ou expirado",
headers={"WWW-Authenticate": "Bearer"},
)
usuario_id = ler_token(config, token)
if usuario_id is None:
raise erro
usuario = sessao.get(Usuario, usuario_id)
if usuario is None:
raise erro
return usuario
UsuarioAtual = Annotated[Usuario, Depends(usuario_atual)]
As rotas de autenticação
Três rotas: cadastro, login e "quem sou eu". O cadastro normaliza o e-mail para minúsculas, e trata o IntegrityError do banco (a restrição de unicidade) como 409, o que também resolve a corrida de dois cadastros simultâneos do mesmo e-mail (uma verificação "existe?" antes de inserir não resolveria). O login usa as duas defesas do capítulo 20: mensagem igual para e-mail inexistente e senha errada, e hash calculado mesmo quando o usuário não existe:
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.deps import Config, Sessao, UsuarioAtual
from app.models import Usuario
from app.schemas import Token, UsuarioCriar, UsuarioSaida
from app.security import SENHA_FALSA, criar_token, hash_senha, verificar_senha
router = APIRouter(tags=["Autenticação"])
@router.post("/usuarios", response_model=UsuarioSaida, status_code=status.HTTP_201_CREATED)
def registrar(dados: UsuarioCriar, sessao: Sessao) -> Usuario:
usuario = Usuario(
email=dados.email.lower(), nome=dados.nome, senha_hash=hash_senha(dados.senha)
)
sessao.add(usuario)
try:
sessao.commit()
except IntegrityError:
sessao.rollback()
raise HTTPException(status.HTTP_409_CONFLICT, "E-mail já cadastrado") from None
return usuario
@router.post("/auth/token", response_model=Token)
def entrar(
formulario: Annotated[OAuth2PasswordRequestForm, Depends()], sessao: Sessao, config: Config
) -> Token:
usuario = sessao.scalar(select(Usuario).where(Usuario.email == formulario.username.lower()))
senha_hash = usuario.senha_hash if usuario else SENHA_FALSA
senha_confere = verificar_senha(formulario.password, senha_hash)
if usuario is None or not senha_confere:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"E-mail ou senha inválidos",
headers={"WWW-Authenticate": "Bearer"},
)
return Token(access_token=criar_token(config, usuario.id))
@router.get("/usuarios/eu", response_model=UsuarioSaida)
def meus_dados(usuario: UsuarioAtual) -> Usuario:
return usuario
`raise ... from None`
Dentro de um
except, levantar outra exceção semfromfaz o Python encadear as duas na mensagem de erro ("durante o tratamento da exceção acima, outra ocorreu"). Ofrom Nonedescarta a original, que aqui é um detalhe interno do banco. Oruffaponta o esquecimento (regra B904), e foi ele que me fez corrigir.