Pular para o conteúdo

    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:

    Terminal
    uv add "pwdlib[argon2]" pyjwt
    
    intermediario/cap19_senhas_jwt.pylinhas 10 a 17
    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)
    
    Saída
    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] e python-jose. Em uma instalação nova, com o bcrypt atual, o passlib falha: ele procura um atributo que o bcrypt removeu e depois recusa até a senha "1234". A documentação atual do FastAPI migrou para pwdlib (hash) e PyJWT (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:

    reproducao_passlib.py
    from passlib.context import CryptContext
    
    contexto = CryptContext(schemes=["bcrypt"])
    contexto.hash("1234")
    
    Saída
    (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:

    intermediario/cap19_senhas_jwt.pylinhas 22 a 42
    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"])
    
    Saída
    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:

    intermediario/cap19_senhas_jwt.pylinhas 47 a 62
    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"))
    
    Saída
    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:

    intermediario/cap19_senhas_jwt.pylinhas 67 a 76
    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__)
    
    Saída
    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:

    intermediario/cap19_senhas_jwt.pylinhas 81 a 86
    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])
    
    Saída
    ['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ãoO que eu faço
    Guardar a senhaHash Argon2 com sal (pwdlib), nunca texto puro
    O que vai no tokensub e exp, nada sensível
    ValidadeCurta (15 a 30 minutos)
    Algoritmo no decodeSempre uma lista explícita
    Chave32 bytes aleatórios, em variável de ambiente
    TransporteSempre 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.