Capítulo 19, Intermediário
Senhas e tokens JWT
Autenticar é provar quem você é. Isso envolve duas coisas que se confundem e são diferentes: guardar a senha de forma que um vazamento não a revele, e provar a identidade nas requisições seguintes sem mandar a senha de novo.
Nunca guarde a senha, guarde um hash
Se o banco de dados vazar com as senhas em texto puro, todos os usuários foram comprometidos de uma vez. A solução é guardar um hash: o resultado de uma função de mão única. Dá para calcular o hash de uma senha, mas não dá para voltar do hash à senha. Para conferir um login, você calcula o hash da senha digitada e compara.
Nem todo hash serve. Funções rápidas como SHA-256 foram feitas para ser velozes, e isso é um defeito aqui: um atacante testa bilhões de senhas por segundo. Para senhas, usam-se funções deliberadamente lentas e com sal (um valor aleatório diferente para cada senha). A recomendação atual da documentação do FastAPI é o Argon2, pela biblioteca pwdlib:
uv add "pwdlib[argon2]" pyjwt
from pwdlib import PasswordHash
hasher = PasswordHash.recommended()
senha_hash = hasher.hash("1234")
print(senha_hash.startswith("$argon2id$"))
print(hasher.verify("1234", senha_hash), hasher.verify("errada", senha_hash))
print(hasher.hash("1234") != senha_hash)
True
True False
True
O último resultado mostra o sal em ação: a mesma senha gera hashes diferentes a cada vez, porque cada hash carrega o seu sal. Dois usuários com a mesma senha não têm o mesmo hash guardado, e uma tabela de hashes pré-calculados não funciona.
O curso usa `passlib` e `python-jose`, e isso quebrou
Muito material (inclusive o curso que originou este curso) usa
passlib[bcrypt]epython-jose. Em uma instalação nova, com obcryptatual, opasslibfalha: ele procura um atributo que obcryptremoveu e depois recusa até a senha"1234". A documentação atual do FastAPI migrou parapwdlib(hash) ePyJWT(token), e é isso que este curso usa.
Eu reproduzi o problema em um ambiente isolado, com passlib 1.7.4 (a última versão publicada) e bcrypt 5.0.0. Este é o código e a saída real:
from passlib.context import CryptContext
contexto = CryptContext(schemes=["bcrypt"])
contexto.hash("1234")
(trapped) error reading bcrypt version
AttributeError: module 'bcrypt' has no attribute '__about__'
ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
Se você herdar um projeto com passlib, o pwdlib também lê hashes bcrypt antigos, o que permite migrar aos poucos.
O que é um JWT
Depois do login, o servidor entrega um token, e o cliente o manda em cada requisição seguinte, em vez de reenviar a senha. O formato mais usado é o JWT (JSON Web Token): três partes em Base64 separadas por pontos, o cabeçalho (o algoritmo), o corpo (as informações, chamadas de claims) e a assinatura:
import base64
import json
from datetime import datetime, timedelta, timezone
import jwt
CHAVE = "uma-chave-secreta-com-no-minimo-32-bytes-0123"
ALGORITMO = "HS256"
payload = {"sub": "ana", "exp": datetime.now(timezone.utc) + timedelta(minutes=30)}
token = jwt.encode(payload, CHAVE, algorithm=ALGORITMO)
print(type(token).__name__, token.count("."))
def decodificar_parte(parte: str) -> dict:
return json.loads(base64.urlsafe_b64decode(parte + "=" * (-len(parte) % 4)))
cabecalho, corpo, assinatura = token.split(".")
print(decodificar_parte(cabecalho))
print(decodificar_parte(corpo)["sub"])
str 2
{'alg': 'HS256', 'typ': 'JWT'}
ana
O ponto mais importante: o corpo do JWT não é criptografado. Eu li o sub apenas decodificando Base64, sem a chave. A assinatura garante que o conteúdo não foi alterado, mas qualquer um que veja o token consegue ler. Por isso nunca coloque nele uma senha, um documento ou qualquer dado sensível. Guarde só o necessário: o identificador do usuário (sub) e a expiração (exp).
Verificar o token
O jwt.decode confere a assinatura e a expiração. Cada falha tem a sua exceção, e todas herdam de InvalidTokenError:
def conferir(texto: str, chave: str = CHAVE) -> str:
try:
return jwt.decode(texto, chave, algorithms=[ALGORITMO])["sub"]
except jwt.ExpiredSignatureError:
return "expirado"
except jwt.InvalidTokenError:
return "inválido"
expirado = jwt.encode(
{"sub": "ana", "exp": datetime.now(timezone.utc) - timedelta(seconds=1)}, CHAVE, algorithm=ALGORITMO
)
de_outro_servidor = jwt.encode({"sub": "admin"}, "outra-chave-também-bem-comprida-0123456", algorithm=ALGORITMO)
print(conferir(token), conferir(expirado), conferir(de_outro_servidor))
print(conferir(token, chave="chave-errada-bem-comprida-0123456789012"))
ana expirado inválido
inválido
`algorithms=[...]` é obrigatório por segurança
O decode exige que você diga quais algoritmos aceita. Há um ataque clássico em que o atacante fabrica um token com o algoritmo none (sem assinatura) e espera que o servidor o aceite. Ao fixar a lista, o servidor recusa:
sem_assinatura = jwt.encode({"sub": "admin"}, None, algorithm="none")
try:
jwt.decode(sem_assinatura, CHAVE, algorithms=[ALGORITMO])
except jwt.InvalidTokenError as erro:
print("token sem assinatura recusado:", type(erro).__name__)
try:
jwt.decode(token, CHAVE)
except jwt.PyJWTError as erro:
print("sem a lista de algoritmos:", type(erro).__name__)
token sem assinatura recusado: InvalidAlgorithmError
sem a lista de algoritmos: DecodeError
A chave secreta
A segurança de um JWT com HS256 é a segurança da chave. Quem a tem consegue forjar tokens de qualquer usuário. O curso usa "my_secret", e o PyJWT atual reclama disso:
import warnings
with warnings.catch_warnings(record=True) as avisos:
warnings.simplefilter("always")
jwt.encode({"a": 1}, "my_secret", algorithm="HS256")
print([a.category.__name__ for a in avisos])
['InsecureKeyLengthWarning']
A chave precisa ter pelo menos 32 bytes e ser aleatória. Gere uma com openssl rand -hex 32 (ou secrets.token_urlsafe(32)) e a guarde em uma variável de ambiente, nunca no código nem no Git (capítulo 23).
| Decisão | O que eu faço |
|---|---|
| Guardar a senha | Hash Argon2 com sal (pwdlib), nunca texto puro |
| O que vai no token | sub e exp, nada sensível |
| Validade | Curta (15 a 30 minutos) |
Algoritmo no decode | Sempre uma lista explícita |
| Chave | 32 bytes aleatórios, em variável de ambiente |
| Transporte | Sempre HTTPS, no cabeçalho Authorization: Bearer |
Exercício 1
Um token de acesso com validade
Escreva criar_token(usuario, minutos), que devolva um JWT com sub e exp, e ler_token(token), que devolva o sub ou None se o token for inválido ou expirado. Confira um token válido, um com validade negativa e um adulterado.