-
Notifications
You must be signed in to change notification settings - Fork 390
032. Sistema de Autenticação e Autorização
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:
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.
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:
-
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. - Feature: a "habilidade" em questão. Então é o que está querendo ser feito por esse usuário.
- 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);
- 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 darequest.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. - 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 deread:session
. Se sim, a request continua e cai nogetHandler
, se não, um erro será retornado. - Mesma coisa para o
post
, onde agora pede pela feature porcreate:session
para criar novas sessões, ou seja, se logar no sistema. Então para por exemplo banir alguém, basta remover as featuresread:session
ecreate: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).
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.
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.
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
.
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
.
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.
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.
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.
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.
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:
- Criar conta (com sucesso)
- Receber email (com sucesso)
- Ativar a conta (com sucesso)
- Fazer login (com sucesso)
- 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:
- Criar conta (com sucesso)
- Receber email (com sucesso)
- Não clicar no link e não ativar a conta.
- Fazer login (com fracasso)
- Usar sessão (com fracasso)