Skip to content

032. Sistema de Autenticação e Autorização

Tiago Carreira edited this page Feb 9, 2022 · 2 revisions

09/02/22

Acabei de fazer o merge do PR mostruoso #170 que implementa a primeira versão tanto do sistema de Autenticação, quanto Autorização e todo o fluxo de cadastro e ativação. Muita coisa em paralelo foi implementada, com novos padrões, e que vão refletir em outros PRs para que o projeto esteja sob o mesmo padrão. De qualquer forma, segue os destaques desse PR:

Autenticação

Essa parte até foi simples de desenrolar (mas isso não exclui o quão sensível é, pois é super sensível e deve a todo momento ser criticada), mas em resumo é uma abstração que precisa fazer o hash e comparar as senhas, injetar o user no contexto da request e injetar e renovar sessões.

Uma característica interessante é que em todo controller está sendo injetado um user, seja ele autenticado ou anônimo. Com essa padronização, ficou mais fácil trabalhar com a camada de Autorização, pois assim ela sempre espera um usuário (com suas features) e não se importa mais se ele está autenticado ou não. A camada da Autorização quer apenas saber se tal usuário possui tal feature.

Autorização

Na minha humilde opinião, ficou mais simples do que eu esperava que ficasse, mas ao mesmo tempo, foi o mais difícil para justamente simplificar. Mas em resumo, como comentado no ponto anterior, esse componente espera um usuário, e na verdade espera sempre 3 coisas:

  1. User: não importa se ele é anônimo ou não, desde que ele tenha features. Então é quem está querendo fazer a ação.
  2. Feature: a "habilidade" em questão. Então é o que está querendo ser feito por esse usuário.
  3. Resource: o recurso em questão. Então é contra o quê que esta ação está sendo feita por esse usuário.

Então na hora de implementar, por hora a cadeia dos controllers ficou assim:

  .use(authentication.injectAnonymousOrUser)
  .get(authorization.canRequest('read:session'), getHandler)
  .post(authorization.canRequest('create:session'), postHandler);
  1. Note que o .use da primeira linha possui um middleware da Autenticação e que vai injetar um usuário (anônimo ou não) dentro da request.context.user para todas as requests, independente do método http. Nessa injeção, você já ganha de graça a validação da sessão (se o usuário tiver um cookie de sessão) e também a renovação dessa sessão. Se a sessão estiver inválida, é retornado um erro e o cookie é limpado.
  2. Já na segunda linha irá cair tudo que for get e a Autorização entra em campo perguntando se dessa request, o user possui a feature de read:session. Se sim, a request continua e cai no getHandler, se não, um erro será retornado.
  3. Mesma coisa para o post, onde agora pede pela feature por create:session para criar novas sessões, ou seja, se logar no sistema. Então para por exemplo banir alguém, basta remover as features read:session e create:session.

Outro caso interessante de analizar é o ato de poder ler o token de ativação (e que pode ser lido uma única vez, independente de quantos tokens você conseguir gerar).

Então todo usuário é criado com a feature read:activation_token e isso faz com que ele consiga acessar a rota: GET /api/v1/activate/:token:

  .use(authentication.injectAnonymousOrUser)
  .get(authorization.canRequest('read:activation_token'), getHandler);

Mas depois de entrar nessa rota e ativar a conta de fato, é removido dele a feature read:activation_token (e também adicionado as features read:session e create:session para ele conseguir se logar). Ou seja, mesmo que de alguma forma ele consiga criar mais um token de ativação, ele nunca vai conseguir "reativar" a conta dele, pois ele não possui mais a feature que deixa ler tokens de ativação. Penso que coisas assim são importantes, porque "ativar" a conta significa ter as features relacionadas a sessão, e se a gente banir alguém (ao remover essas features), ele não pode ganhar elas novamente ao reativar sua conta. Então, uma vez ativada a sua conta, você perde a habilidade de ativar sua conta. Por fim, como reflexo de ativar a sua conta, você por hora ganha outras features, como as relacionadas a sessão e também create:post e create:comment (e agora pensando, deveria ter flag para read de post e comments?

E junto disso, veio também os filtros de input e output que filtra os dados que estão entrando e saindo conforme as features que o usuário tem. Um exemplo bem simples de entender isso é no filtro de saída, onde se eu consultar o meu usuário, eu vou receber o campo de email, mas se eu consultar outro usuário, esse campo não é retornado.

Então usado as explicações acima de user, feature e resource olha essa lógica no método filterOutput():

  if (feature === 'read:user' && can(user, feature, resource)) {

    // Aqui são campos que vão ser retornados para todos os usuários
    // Mas já daqui a gente evita retornar campos como "password"
    // ou qualquer outro campo novo que for adicionado, por exemplo
    // por uma migration e que manualmente precisa ser declarado aqui
    // para não vazar sem percebermos.

    filteredValues = {
      id: resource.id,
      username: resource.username,
      features: resource.features,
      created_at: resource.created_at,
      updated_at: resource.updated_at,
    };


    // Agora aqui ele verifica se os valores não são undefined, pois "undefined === undefined" é true 😂 
    // Mas o mais importante é a parte "user.id === resource.id"
    // Aqui o "resource" é um usuário também, então ele se certifica que os dois ids são iguais,
    // tanto do usuário que está tentando ler a informação, quanto o usuário (recurso) que
    // vai ser retornado na request mais além.

    if (user.id && resource.id && user.id === resource.id) {
      filteredValues.email = resource.email;
    }
  }

O que falta agora é fazer o "hardening" do restante da aplicação, pois existem rotas em verificação de features (por exemplo migrations) ou sem filtro de entrada e saída (por exemplo patch no /users/:username).

Abstração "controller"

Esta abstração se responsabiliza por ter os métodos de injeção de id na request, e também os middlewares que lidam com "no match handler" e "error handler". Foi legal centralizar isso, pois se qualquer erro estourar dentro da aplicação (e que passou pelos controllers), vai cair nessas funções centralizadas e serão tratadas de forma adequada, mesmo quando é um erro interno.

De Factory para Singleton

Ainda apaixonado pelo pattern Factory, fui obrigado a mudar tudo para Singletons, pois a gente não estava usando nada da Factory. Por exemplo: todos os nossos models são apenas um conjunto de funções puras... nenhum model guarda estado. Se guardasse, seria importante voltar com o pattern de Factory, para não confundir o estado de cada instância. Mas ao longo do código, ficou natural ir e voltar com objetos puros passando por essas funções puras.

Sessões

Quando alguém cria uma nova sessão (que é o ato de fazer um "login"), isso é controlado pelo model session que guarda seu estado numa tabela sessions.

Tokens de ativação

Quando alguém cria um novo usuário, é criado um token de ativação e isto é controlado por um model activation que guarda seu estado numa tabela activate_account_tokens. Há também o controller que recebe o token e ele verifica se o usuário possui a feature read:activation_token.

Novo tipo de erro: ServiceError

Antes eu tinha criado um DatabaseError para isolar o que era erro causado pelo serviço do banco de dados, mas preferi trocar tudo para um chamado ServiceError, pois ele engloba também o serviço de email e qualquer outro serviço que a gente venha integrar. Sempre que esse erro for invocado, o que vai chegar no client é um InternalServerError, mas com o status code de 503 de Service Unavailable. Então pelo client a gente sabe se é um erro interno clássico 500 que algo inesperado aconteceu e a gente realmente não soube lidar, ou um erro interno que a gente soube lidar, mas não teve o que fazer.

Código único de erro

Agora os erros customizados da aplicação possuem um campo especial chamado errorUniqueCode. Este campo serve para imediatamente localizar em que parte do código o erro foi invocado. Ele deve ser único para cada parte do código, e não para cada tipo de erro. Então se num mesmo arquivo houver dois ServiceErrors, cada um deverá ter um errorUniqueCode diferente. Fiz essa escolha, pois não está sendo muito confiável o stack trace de aplicações que passam por um processo de minificação e build, como o Next.js passa agressivamente. Fora que um mesmo tipo erro pode ser invocado em várias camadas diferentes e fica mais fácil identificar qual camada foi responsável lendo esse código único. Ele é retornado para o client e identifica o contexto inteiro, por exemplo:

"errorUniqueCode": "MODEL:AUTHENTICATION:COMPARE_PASSWORDS:PASSWORD_MISMATCH"

Como o código é open source, não vejo problema ser explícito assim.

TODO: esse campo não foi utilizado em todos os erros do código, é preciso fazer isso ainda ao longo do projeto.

Erros do banco de dados logam a query

Agora quando não é possível executar uma query usando o database.query(), o texto dela (sem os valores) é logado. Isso facilita entender o que estava sendo executado e do que o banco reclamou.

Endpoint de saúde informa erro por statusCode

Agora o /api/v1/status retorna um 503 se algum serviço está fora do ar. Aliás, precisamos refatorar o controller e model para deixar ainda mais simples na minha visão e adicionar o serviço de email nessa verificação. Estamos usando o Sendgrid e acho que dá para mandar um email de mock para eles.

Testes automatizados

Continua uma delícia trabalhar com os testes automatizados e o projeto já nessa pequena escala ficaria inviável ser refatorado o quanto eu refatorei se não existissem testes automatizados. Ou era ter testes e refatorar, ou refatorar muito pouco por medo de quebrar algo.

O que estou fazendo agora é reorganizar os testes e tirar eles das pastas dos próprios componentes. Se quisermos uma cobertura boa deixando os testes num tamanho aceitável, vamos precisar separar e mais arquivos e isto pode poluir muito a pasta onde está de fato o source do projeto. Então a gente deveria trazer tudo para a para tests na raiz do projeto.

Inclusive nessa pasta eu criei uma subpasta chamada use-cases e lá sugiro colocar testes de fluxos inteiro, principalmente fluxos que não podem quebrar, aconteça o que acontecer. Um desses fluxos é o seguinte:

  1. Criar conta (com sucesso)
  2. Receber email (com sucesso)
  3. Ativar a conta (com sucesso)
  4. Fazer login (com sucesso)
  5. Usar sessão (com sucesso)

Por que com sucesso? Porque esse é o fluxo feliz, onde tudo funcionou, tudo foi digitado conforme esperado, todas as features disponíveis. Mais para frente devemos criar outro fluxo que é por exemplo:

  1. Criar conta (com sucesso)
  2. Receber email (com sucesso)
  3. Não clicar no link e não ativar a conta.
  4. Fazer login (com fracasso)
  5. Usar sessão (com fracasso)
Clone this wiki locally