Pular para conteúdo

Tornando o sistema de autenticação robusto


Objetivos da Aula:

  • Testar os casos de autenticação de forma correta
  • Implementar o refresh do token
  • Introduzir testes que param o tempo com freezegun
  • Introduzir geração de modelos automática com factory-boy
Caso prefira ver a aula em vídeo

Esse aula ainda não está disponível em formato de vídeo, somente em texto!

Aula Slides Código


Em nossas aulas anteriores, abordamos os fundamentos de um sistema de autenticação, mas existem diversas maneiras de aprimorá-lo para que ele se torne mais robusto e seguro. Nessa aula, enfrentaremos perguntas importantes, como: Como lidar com falhas e erros? Como garantir a segurança do sistema mesmo em cenários desafiadores? Estas são algumas das questões-chave que exploraremos.

Começaremos com uma análise detalhada dos testes de autenticação. Até agora, concentramo-nos em cenários ideais, onde o usuário existe e as condições são favoráveis. Contudo, é essencial testar também as situações adversas e compreender como o sistema responde a falhas. Vamos, portanto, aprender a realizar testes eficazes para esses casos negativos.

Depois, passaremos para a implementação de um elemento crucial em qualquer sistema de autenticação: a renovação do token. Esse mecanismo é imprescindível para manter a sessão do usuário ativa e segura, mesmo quando o token original expira.

Testes para autenticação

Antes de mergulharmos nos testes, conversaremos um pouco sobre por que eles são tão importantes. Na programação, é fácil cair na armadilha de pensar que, se algo funciona na maioria das vezes, então está tudo bem. Mas a verdade é que é nos casos marginais que os bugs mais difíceis de encontrar e corrigir costumam se esconder.

Por exemplo, o que acontece se tentarmos autenticar um usuário que não existe? Ou se tentarmos autenticar com as credenciais erradas? Se não testarmos esses cenários, podemos acabar com um sistema que parece funcionar na superfície, mas que, na verdade, está cheio de falhas de segurança.

descrição

No código apresentado, se observarmos atentamente, vemos que o erro HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail='Not enough permissions') em users.py na rota /{user_id} não está sendo coberto por nossos testes. Essa exceção é lançada quando um usuário não autenticado ou um usuário sem permissões adequadas tenta acessar, ou alterar um recurso que não deveria.

Essa lacuna em nossos testes representa um risco potencial, pois não estamos verificando como nosso sistema se comporta quando alguém tenta, por exemplo, alterar os detalhes de um usuário sem ter permissões adequadas. Embora possamos assumir que nosso sistema se comportará corretamente, a falta de testes nos deixa sem uma confirmação concreta.

Testando a alteração de um usuário não autorizado

Agora, escreveremos alguns testes para esses casos. Vamos começar com um cenário simples: o que acontece quando um usuário tenta alterar as informações de outro usuário?

Para testar isso, criaremos um novo teste chamado test_update_user_with_wrong_user.

tests/test_users.py
def test_update_user_with_wrong_user(client, user, token):
    response = client.put(
        f'/users/{user.id}',
        headers={'Authorization': f'Bearer {token}'},
        json={
            'username': 'bob',
            'email': 'bob@example.com',
            'password': 'mynewpassword',
        },
    )
    assert response.status_code == HTTPStatus.BAD_REQUEST
    assert response.json() == {'detail': 'Not enough permissions'}
Este teste vai simular um usuário tentando alterar as informações de outro usuário. Se nosso sistema estiver funcionando corretamente, ele deverá rejeitar essa tentativa e retornar um erro.

Criando modelos por demanda com factory-boy

Embora o teste que escrevemos esteja tecnicamente correto, ele ainda não funcionará adequadamente porque, atualmente, só temos um usuário em nosso banco de dados de testes. Precisamos de uma maneira de criar múltiplos usuários de teste facilmente, e é aí que entra o factory-boy.

O factory-boy é uma biblioteca que nos permite criar objetos de modelo de teste de forma rápida e fácil. Com ele, podemos criar uma "fábrica" de usuários que produzirá novos objetos de usuário sempre que precisarmos. Isso nos permite criar múltiplos usuários de teste com facilidade, o que é perfeito para nosso cenário atual.

Para começar, precisamos instalar o factory-boy em nosso ambiente de desenvolvimento:

$ Execução no terminal!
poetry add --group dev factory-boy

Após instalar o factory-boy, podemos criar uma UserFactory. Esta fábrica será responsável por criar novos objetos de usuário sempre que precisarmos de um para nossos testes. A estrutura da fábrica será a seguinte:

tests/conftest.py
import factory

# ...

class UserFactory(factory.Factory):
    class Meta:
        model = User

    username = factory.Sequence(lambda n: f'test{n}')
    email = factory.LazyAttribute(lambda obj: f'{obj.username}@test.com')
    password = factory.LazyAttribute(lambda obj: f'{obj.username}@example.com')

Explicando linha a linha, esse código faz o seguinte:

  • class UserFactory(factory.Factory): define uma fábrica para o modelo User, herdando de factory.Factory.
  • class Meta: uma classe interna Meta é usada para configurar a fábrica.
  • model = User: define o modelo para o qual a fábrica está construindo instâncias. No caso, estamos referenciando a classe User, que deve ser um modelo de banco de dados, como o SQLAlchemy.
  • username = factory.Sequence(lambda n: f'test{n}'): define um campo username que recebe uma sequência. A cada chamada da fábrica, o valor n é incrementado, então cada instância gerada terá um username único. Usando a string 'test{n}'.
  • email = factory.LazyAttribute(lambda obj: f'{obj.username}@test.com'): define o campo email gerado a partir do username.
  • password = factory.LazyAttribute(lambda obj: f'{obj.username}@example.com'): define o campo password similar ao campo email.

Essa fábrica pode ser usada em testes para criar instâncias de User com dados predefinidos, facilitando a escrita de testes que requerem a presença de usuários no banco de dados. Isso é extremamente útil ao escrever testes que requerem o estado pré-configurado do banco de dados e ajuda a tornar os testes mais legíveis e manuteníveis.

A seguir, podemos usar essa nova fábrica para criar múltiplos usuários de teste. Para fazer isso, modificamos nossa fixture de usuário existente para usar a UserFactory. Assim, sempre que executarmos nossos testes, teremos usuários diferentes disponíveis.

tests/conftest.py
@pytest.fixture()
def user(session):
    password = 'testtest'
    user = UserFactory(password=get_password_hash(password))

    session.add(user)
    session.commit()
    session.refresh(user)

    user.clean_password = 'testtest'

    return user


@pytest.fixture()
def other_user(session):
    password = 'testtest'
    user = UserFactory(password=get_password_hash(password))

    session.add(user)
    session.commit()
    session.refresh(user)

    user.clean_password = 'testtest'

    return user

A criação de outra fixture chamada other_user é crucial para simular o cenário de um usuário tentando acessar ou modificar as informações de outro usuário no sistema. Ao criar duas fixtures diferentes, user e other_user, podemos efetivamente simular dois usuários diferentes em nossos testes. Isso nos permite avaliar como nosso sistema reage quando um usuário tenta realizar uma ação não autorizada, como alterar as informações de outro usuário.

Um aspecto interessante no uso das fábricas é que, sempre que forem chamadas, elas retornarão um novo User, pois estamos fixando apenas a senha. Dessa forma, cada chamada a essa fábrica de usuários retornará um User diferente, com base nos atributos "lazy" que usamos.

Com essa nova configuração, podemos finalmente testar o cenário de um usuário tentando alterar as informações de outro usuário. E como você pode ver, nossos testes passaram com sucesso, o que indica que nosso sistema está lidando corretamente com essa situação.

tests/test_users.py
def test_update_user_with_wrong_user(client, other_user, token):
    response = client.put(
        f'/users/{other_user.id}',
        headers={'Authorization': f'Bearer {token}'},
        json={
            'username': 'bob',
            'email': 'bob@example.com',
            'password': 'mynewpassword',
        },
    )
    assert response.status_code == HTTPStatus.BAD_REQUEST
    assert response.json() == {'detail': 'Not enough permissions'}

Neste caso, não estamos usando a fixture user porque queremos simular um cenário onde o usuário associado ao token (autenticado) está tentando realizar uma ação sobre outro usuário, representado pela fixture other_user. Ao usar a other_user, garantimos que o id do usuário que estamos tentando modificar ou deletar não seja o mesmo do usuário associado ao token, mas que ainda assim exista no banco de dados.

Para enfatizar, a fixture user está sendo usada para representar o usuário que está autenticado através do token. Se usássemos a mesma fixture user neste teste, o sistema consideraria que a ação está sendo realizada pelo próprio usuário, o que não corresponderia ao cenário que queremos testar. Além disso, é importante entender que o escopo das fixtures implica que, quando chamadas no mesmo teste, elas devem retornar o mesmo valor. Portanto, usar a user e other_user permite uma simulação mais precisa do comportamento desejado.

Com o teste implementado, vamos executá-lo para ver se nosso sistema está protegido contra essa ação indevida:

$ Execução no terminal!
task test

# ...

tests/test_app.py::test_root_deve_retornar_ok_e_ola_mundo PASSED
tests/test_auth.py::test_get_token PASSED
tests/test_db.py::test_create_user PASSED
tests/test_users.py::test_create_user PASSED
tests/test_users.py::test_read_users PASSED
tests/test_users.py::test_read_users_with_users PASSED
tests/test_users.py::test_update_user PASSED
tests/test_users.py::test_update_user_with_wrong_user PASSED
tests/test_users.py::test_delete_user PASSED

Se todos os testes passaram com sucesso, isso indica que nosso sistema está se comportando como esperado, inclusive no caso de tentativas indevidas de deletar um usuário.

Testando o DELETE com o usuário errado

Continuando nossos testes, agora testaremos o que acontece quando tentamos deletar um usuário com um usuário errado.

Talvez você esteja se perguntando, por que precisamos fazer isso? Bem, lembre-se de que a segurança é uma parte crucial de qualquer sistema de autenticação. Precisamos garantir que um usuário não possa deletar a conta de outro usuário - apenas a própria conta. Portanto, é importante que testemos esse cenário para garantir que nosso sistema está seguro.

Aqui está o teste que usaremos:

tests/test_users.py
def test_delete_user_wrong_user(client, other_user, token):
    response = client.delete(
        f'/users/{other_user.id}',
        headers={'Authorization': f'Bearer {token}'},
    )
    assert response.status_code == HTTPStatus.BAD_REQUEST
    assert response.json() == {'detail': 'Not enough permissions'}

Como você pode ver, esse teste tenta deletar o user de um id diferente usando o token do user. Se nosso sistema estiver funcionando corretamente, ele deverá rejeitar essa tentativa e retornar um status 400 com uma mensagem de erro indicando que o usuário não tem permissões suficientes para realizar essa ação.

Vamos executar esse teste agora e ver o que acontece:

$ Execução no terminal!
task test

# ...

tests/test_users.py::test_delete_user_wrong_user PASSED

Ótimo, nosso teste passou! Isso significa que nosso sistema está corretamente impedindo um usuário de deletar a conta de outro usuário.

Agora que terminamos de testar a autorização, passaremos para o próximo desafio: testar tokens expirados. Lembre-se, em um sistema de autenticação robusto, um token deve expirar após um certo período de tempo por motivos de segurança. Portanto, é importante que testemos o que acontece quando tentamos usar um token expirado. Veremos isso na próxima seção.

Testando a expiração do token

Continuando com nossos testes de autenticação, a próxima coisa que precisamos testar é a expiração do token. Tokens de autenticação são normalmente projetados para expirar após um certo período de tempo por motivos de segurança. Isso evita que alguém que tenha obtido um token possa usá-lo indefinidamente se ele for roubado ou perdido. Portanto, é importante que verifiquemos que nosso sistema esteja tratando corretamente a expiração dos tokens.

Para realizar esse teste, usaremos uma biblioteca chamada freezegun. freezeguné uma biblioteca Python que nos permite "congelar" o tempo em um ponto específico ou avançá-lo conforme necessário durante os testes. Isso é especialmente útil para testar funcionalidades sensíveis ao tempo, como a expiração de tokens, sem ter que esperar em tempo real.

Primeiro, precisamos instalar a biblioteca:

poetry add --group dev freezegun

Agora criaremos nosso teste. Começaremos pegando um token para um usuário, congelando o tempo, esperando pelo tempo de expiração do token e, em seguida, tentando usar o token para acessar um endpoint que requer autenticação.

Ao elaborarmos o teste, usaremos a funcionalidade de congelamento de tempo do freezegun. O objetivo é simular a criação de um token às 12:00 e, em seguida, verificar sua expiração às 12:31. Neste cenário, estamos utilizando o conceito de "viajar no tempo" para além do período de validade do token, garantindo que a tentativa subsequente de utilizá-lo resultará em um erro de autenticação.

tests/test_auth.py
from freezegun import freeze_time

# ...

def test_token_expired_after_time(client, user):
    with freeze_time('2023-07-14 12:00:00'):
        response = client.post(
            '/auth/token',
            data={'username': user.email, 'password': user.clean_password},
        )
        assert response.status_code == HTTPStatus.OK
        token = response.json()['access_token']

    with freeze_time('2023-07-14 12:31:00'):
        response = client.put(
            f'/users/{user.id}',
            headers={'Authorization': f'Bearer {token}'},
            json={
                'username': 'wrongwrong',
                'email': 'wrong@wrong.com',
                'password': 'wrong',
            },
        )
        assert response.status_code == HTTPStatus.UNAUTHORIZED
        assert response.json() == {'detail': 'Could not validate credentials'}

Lembre-se de que configuramos nosso token para expirar após 30 minutos. Portanto, nós avançamos o tempo em 31 minutos para garantir que o token tenha expirado.

Agora, executaremos nosso teste e ver o que acontece:

$ Execução no terminal!
task test

# ...

tests/test_auth.py::test_token_expired_after_time FAILED

O motivo deste teste falhar é que não temos uma condição que faça a captura deste cenário. Em fast_zero/security.py, o único caso de erro esperado é em relação à dados inválidos durante o decode (DecodeError). Precisamos adicionar uma exception para quando o token estiver no formato correto, mas não estiver no prazo de validade. Quando seu tempo de uso já está expirado.

Para isso, podemos importar do pyjwt o objeto ExpiredSignatureError que é a exceção levantada para a decodificação de um código expirado:

fast_zero/security.py
from datetime import datetime, timedelta
from http import HTTPStatus

from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jwt import DecodeError, ExpiredSignatureError, decode, encode
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.orm import Session

# ...

def get_current_user(
    session: Session = Depends(get_session),
    token: str = Depends(oauth2_scheme),
):
    credentials_exception = HTTPException(
        status_code=HTTPStatus.UNAUTHORIZED,
        detail='Could not validate credentials',
        headers={'WWW-Authenticate': 'Bearer'},
    )

    try:
        payload = decode(
            token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
        )
        username: str = payload.get('sub')
        if not username:
            raise credentials_exception
        token_data = TokenData(username=username)
    except DecodeError:
        raise credentials_exception
    except ExpiredSignatureError:
        raise credentials_exception

    user = session.scalar(
        select(User).where(User.email == token_data.username)
    )

    if user is None:
        raise credentials_exception

    return user

Com essa simples alteração, temos uma saída de erro para quando o token estiver inválido. Por questões de segurança, vamos manter a mesma mensagem usada antes. Dizendo que não conseguimos validar as credenciais.

Assim, vamos executar o teste novamente:

$ Execução no terminal!
task test

# ...

tests/test_auth.py::test_token_expired_after_time PASSED

Temos a demonstração de sucesso.

No entanto, ainda há uma coisa que precisamos implementar: a atualização de tokens. Atualmente, quando um token expira, o usuário teria que fazer login novamente para obter um novo token. Isso não é uma ótima experiência para o usuário. Em vez disso, gostaríamos de oferecer a possibilidade de o usuário atualizar seu token quando ele estiver prestes a expirar. Veremos como fazer isso na próxima seção.

Testando o usuário não existente e senha incorreta

Na construção de qualquer sistema de autenticação, é crucial garantir que os casos de erro sejam tratados corretamente. Isso não só previne possíveis falhas de segurança, mas também permite fornecer feedback útil aos usuários.

Em nossa implementação atual, temos duas situações específicas que devem retornar um erro: quando um usuário inexistente tenta fazer login e quando uma senha incorreta é fornecida. Abordaremos esses casos de erro em nossos próximos testes.

Embora possa parecer redundante testar esses casos já que ambos resultam no mesmo erro, é importante verificar que ambos os cenários estão corretamente tratados. Isso nos permitirá manter a robustez do nosso sistema conforme ele evolui e muda ao longo do tempo.

Testando a exceção para um usuário inexistente

Para este cenário, precisamos enviar um request para o endpoint de token com um e-mail que não existe no banco de dados. A resposta esperada é um HTTP 400 com a mensagem de detalhe 'Incorrect email or password'.

tests/test_auth.py
def test_token_inexistent_user(client):
    response = client.post(
        '/auth/token',
        data={'username': 'no_user@no_domain.com', 'password': 'testtest'},
    )
    assert response.status_code == HTTPStatus.BAD_REQUEST
    assert response.json() == {'detail': 'Incorrect email or password'}

Testando a exceção para uma senha incorreta

Aqui, precisamos enviar um request para o endpoint de token com uma senha incorreta para um usuário existente. A resposta esperada é um HTTP 400 com a mensagem de detalhe 'Incorrect email or password'.

tests/test_auth.py
def test_token_wrong_password(client, user):
    response = client.post(
        '/auth/token',
        data={'username': user.email, 'password': 'wrong_password'}
    )
    assert response.status_code == HTTPStatus.BAD_REQUEST
    assert response.json() == {'detail': 'Incorrect email or password'}

Com esses testes, garantimos que nossas exceções estão sendo lançadas corretamente. Essa é uma parte importante da construção de um sistema de autenticação robusto, pois nos permite ter confiança de que estamos tratando corretamente os casos de erro.

Implementando o refresh do token

O processo de renovação de token é uma parte essencial na implementação de autenticação JWT. Em muitos sistemas, por razões de segurança, os tokens de acesso têm um tempo de vida relativamente curto. Isso significa que eles expiram após um determinado período de tempo, e quando isso acontece, o cliente precisa obter um novo token para continuar acessando os recursos do servidor. Aqui é onde o processo de renovação de token entra: permite que um cliente obtenha um novo token de acesso sem a necessidade de autenticação completa (por exemplo, sem ter que fornecer novamente o nome de usuário e senha).

Agora implementaremos a função de renovação de token em nosso código.

fast_zero/routes/auth.py
from fast_zero.security import (
    create_access_token,
    get_current_user,
    verify_password,
)

# ...

@router.post('/refresh_token', response_model=Token)
def refresh_access_token(
    user: User = Depends(get_current_user),
):
    new_access_token = create_access_token(data={'sub': user.email})

    return {'access_token': new_access_token, 'token_type': 'bearer'}

Implementaremos também um teste para verificar se a função de renovação de token está funcionando corretamente.

tests/test_auth.py
def test_refresh_token(client, user, token):
    response = client.post(
        '/auth/refresh_token',
        headers={'Authorization': f'Bearer {token}'},
    )

    data = response.json()

    assert response.status_code == HTTPStatus.OK
    assert 'access_token' in data
    assert 'token_type' in data
    assert data['token_type'] == 'bearer'

Ainda é importante garantir que nosso sistema trate corretamente os tokens expirados. Para isso, adicionaremos um teste que verifica se um token expirado não pode ser usado para renovar um token.

tests/test_auth.py
def test_token_expired_dont_refresh(client, user):
    with freeze_time('2023-07-14 12:00:00'):
        response = client.post(
            '/auth/token',
            data={'username': user.email, 'password': user.clean_password},
        )
        assert response.status_code == HTTPStatus.OK
        token = response.json()['access_token']

    with freeze_time('2023-07-14 12:31:00'):
        response = client.post(
            '/auth/refresh_token',
            headers={'Authorization': f'Bearer {token}'},
        )
        assert response.status_code == HTTPStatus.UNAUTHORIZED
        assert response.json() == {'detail': 'Could not validate credentials'}

Agora, se executarmos nossos testes, todos eles devem passar, incluindo os novos testes que acabamos de adicionar.

$ Execução no terminal!
task test

# ...

tests/test_app.py::test_root_deve_retornar_ok_e_ola_mundo PASSED
tests/test_auth.py::test_get_token PASSED
tests/test_auth.py::test_token_inexistent_user PASSED
tests/test_auth.py::test_token_wrong_password PASSED
tests/test_auth.py::test_refresh_token PASSED
tests/test_auth.py::test_token_expired_after_time PASSED
tests/test_db.py::test_create_user PASSED
tests/test_users.py::test_create_user PASSED
tests/test_users.py::test_read_users PASSED
tests/test_users.py::test_read_users_with_users PASSED
tests/test_users.py::test_update_user PASSED
tests/test_users.py::test_update_user_with_wrong_user PASSED
tests/test_users.py::test_delete_user PASSED
tests/test_users.py::test_delete_user_wrong_user PASSED
tests/test_users.py::test_token_expired_dont_refresh PASSED

Com esses testes, podemos ter certeza de que cobrimos alguns casos importantes relacionados à autenticação de usuários em nossa API.

Commit

Agora, faremos um commit com as alterações que fizemos.

$ Execução no terminal!
git add .
git commit -m "Implement refresh token and add relevant tests"

Conclusão

Nesta aula, abordamos uma grande quantidade de tópicos cruciais para a construção de uma aplicação web segura e robusta. Começamos com a implementação da funcionalidade de renovação do token JWT, uma peça fundamental na arquitetura de autenticação baseada em token. Este processo garante que os usuários possam continuar acessando a aplicação, mesmo após o token inicial ter expirado, sem a necessidade de fornecer suas credenciais novamente.

Porém, a implementação do código foi apenas a primeira parte do que fizemos. Uma parte significativa da nossa aula foi dedicada a testar de maneira exaustiva a nossa aplicação. Escrevemos testes para verificar o comportamento básico das nossas rotas de autenticação, mas não paramos por aí. Também consideramos vários casos de borda que podem surgir durante a autenticação de um usuário.

Testamos, por exemplo, o que acontece quando se tenta obter um token com credenciais incorretas. Verificamos o comportamento da nossa aplicação quando um token expirado é utilizado. Esses testes nos ajudam a garantir que nossa aplicação se comporte de maneira adequada não apenas nas situações mais comuns, mas também quando algo sai do esperado.

Além disso, ao implementar esses testes, nós garantimos que futuras alterações no nosso código não irão quebrar funcionalidades já existentes. Testes automatizados são uma parte fundamental de qualquer aplicação de alta qualidade, e o que fizemos nesta aula vai além do básico, mostrando como lidar com cenários complexos e realistas. Nos aproximando do ambiente de produção.

Na próxima aula, utilizaremos a infraestrutura de autenticação que criamos aqui para permitir que os usuários criem, leiam, atualizem e deletem suas próprias listas de tarefas. Isso vai nos permitir explorar ainda mais as funcionalidades do FastAPI e do SQLAlchemy, além de continuar a expandir a nossa suíte de testes.