Arquitetura multitenancy com coluna discriminadora — Parte 2

Adicionando autenticação e autorização com Bcrypt e JWT

Filipe Martins
8 min readMar 3, 2020

--

English version

Chegou a hora da segunda parte do artigo. A primeira está disponível aqui.

A forma de rodar o projeto é igual ao que vimos na primeira parte do artigo.
As partes importantes do projeto que foram alteradas e/ou adicionadas veremos agora.

Apagando as tabelas da primeira parte do artigo

Na primeira parte do artigo usamos o Flyway para criar as tabelas, só que para essa parte do artigo a estrutura de tabelas é outra. Para facilitar, não vamos brincar com a parte de migração do Flyway, vamos deletar as tabelas.

Para isso, caso tenha subido o PostgreSQL como sugeri na primeira parte, basta seguir os passos abaixo:

docker exec -it postgres psql -U postgres

Depois de executar o comando acima você terá acesso ao psql, que serve para executar comando no banco de dados. Agora basta executar:

DROP table tenants, users;

Pronto, agora pode executar o projeto do repositório abaixo sem problemas.

Repositório do projeto

O que é JWT?

JWT (JSON Web Tokens) é um padrão do setor RFC 7519, aberto, para representar reivindicações (claims) de forma segura entre duas partes.

A citação acima foi retirada, traduzida e adaptada daqui, onde podem ser vistos outros detalhes, libs e um excelente debugger de JWTs.

No caso do projeto desse artigo o mesmo é utilizado para:

  1. Verificar se quem está fazendo alguma requisição é realmente um usuário que tem acesso ao sistema (autenticação).
  2. Verificar quais ações ele pode realizar (autorização).

A biblioteca que vamos usar é a mais famosa e completa do Java, a JJWT.

Principais pontos do desenvolvimento

Na primeira parte do artigo mostrei como podemos fazer consultas passando automaticamente o tenantId do usuário logado.

Lembra como isso é feito? Isso mesmo:

Qualquer classe que herda de TenantableRepository em que o método começa com find será interceptada pela classe TenantAspect e terá o filtro tenantFilter ativado e preenchido com o valor do tenantId do usuário autenticado.

Para fazermos a autenticação do usuário precisamos buscar o mesmo na base de dados, certo? O ponto é que UserRepository herda de TenantableRepository e o novo método que temos para buscar usuários, findByEmail, começa com…find! Deu para entender o problema?

Na autenticação não temos tenantId!

Isso mesmo: O tenantId vem justamente da tabela users; é nela que sabemos qual e o tenant do usuário! E como resolvi isso?

Tive que implementar uma forma de permitir que métodos não tenham o filtro tenantFilter ativado. Assim nasceu a seguinte anotação:

Essa anotação deve ser usada em métodos de classes que herdam de TenantableRepository mas que NÃO queremos que tenham o filtro tenantFilter ativado!
Resumindo: Fazendo assim um "SELECT" sem o "WHERE" de tenantId!

CUIDADO: Essa anotação deve ser usada em pontos bem específicos e controlados, para não expor dados de um tenant para usuários de outro tenant.

A classe UserRepository agora tem o método findByEmail anotado com a anotação DisableTenantFilter permitindo assim buscar usuários somente pelo email. Para encerrar veremos como desabilitamos o filtro tenantFilter.

Essa é nossa famosa classe TenantAspect que agora tem mais essa responsabilidade:

Verificar se o método executado está anotado com a anotação DisableTenantFilter e, em caso positivo, não ativa o filtro tenantFilter.

Com esse ponto que parece simples, explicado, vamos continuar com os principais pontos do desenvolvimento.

Abaixo está a classe SignService. A mesma é usada pela classe SignController, única rota não protegida da API.

  • Linhas 16 a 18: Verificamos se foi passado um header chamado Authorization com um schema chamado Basic*.
  • Linhas 21 a 24: Decodificamos o header Authorization que chegou em Base64, obtendo assim o email e senha que serão usados na autenticação.
  • Linhas 25: Procuramos no banco de dados por um usuário com o email obtido no passo anterior.
  • Linha 26: Verificamos se a senha informada é a mesma senha que está gravada no banco de dados.
    Um ponto importante aqui é: A senha no banco de dados é criptografada, utilizando o algoritmo Bcrypt.

NUNCA ARMAZENE SENHAS SEM CRIPTOGRAFIA OU COM ALGUM ALGORITMO QUE JÁ FOI QUEBRADO!

  • Linha 27: Aqui é criado o JWT e devolvido ao usuário em um header chamada Authorization com um schema chamada Bearer*.
    Percebam que na criação do JWT estamos passando 4 informações do usuário: email, roleId, roleName e tenantId.

Veremos abaixo onde essas informações são utilizadas.

* Mais detalhes sobre os schemas Basic e Bearer podem ser vistos aqui.

A classe JwtService é responsável por criar e verificar JWTs.

  • Linha 4 e 5: O coração da segurança do JWT: A chave de assinatura!
    Ela deve ser um string, porém não uma qualquer: Tem que ser grande, difícil de ser descoberta e o principal: SEMPRE EM UMA VARIÁVEL DE AMBIENTE!

E é aqui que fazemos tudo isso: Obtemos a chave de assinatura de uma propriedade chamada jwt.signin.key que está presente no arquivo application.properties. Nesse arquivo temos o seguinte:

A propriedade jwt.signin.key será preenchida com o valor da variável de ambiente JWT_SIGNING_KEY ou, caso a mesma não esteja configurada, terá como valor padrão o que vem depois dos dois pontos.

Para algo tão sensível, NUNCA coloque um valor default!
Coloquei para facilitar o uso nesse projeto base e a escrita do artigo, só!

Vamos continuar com a classe JwtService:

  • Linhas 19 a 21: Aqui estamos vendo as 4 informações que falamos na classe anterior sendo usadas para criar o JWT. Existem outras que são recomendadas mas que foram omitidas para facilitar a explicação. Mais detalhes sobre elas podem ser vistas aqui.
  • Linha 27: É nesse ponto que checamos se um JWT é válido ou não.
    Se alguém tentar criar ou mudar um JWT, para quem sabe se passar por um outro usuário e/ou tenant, sem ter usado a nossa chave de assinatura, vai receber um erro.

Isso conclui a forma que criamos e como podemos validar um JWT.

Veremos agora onde de fato validamos o JWT.

Já vimos a classe TenantAuthorizationFilter na primeira parte do artigo, mas ela era bem simples. Agora a mesma tem muito mais responsabilidades.

  • Linhas 13 a 16: Verificamos se foi passado um header chamado Authorization com um schema chamado Bearer.
  • Linha 26: É hora da verdade: Aqui verificamos se o JWT enviado na requisição é válido.
  • Linhas 27 a 32: Se o JWT for válido, pegamos as informações email, roleId, roleName e tenantId e criamos um novo objeto TenantAuthenticationToken e com isso temos um usuário autenticado e autorizado.

Agora veremos como podemos proteger, ou não, endpoints da nossa API.

A classe SecurityConfiguration já foi explicada na primeira parte do artigo mas agora ela também tem mais responsabilidades.

  • Linha 14: O único endpoint desprotegido que temos é o /api/signIn.
    O motivo? Se protegermos esse endpoint, como alguém vai conseguir se autenticar e pegar um JWT valido para acessar os outros endpoints?
  • Linha 15: O endpoint /api/tenants será acessado somente por usuários que tem o papel (role) "SUPER_ADMIN".
  • Linha 16: O endpoint /api/users, no verbo GET, ou seja, só para consultas, será acessível por usuários que tem o papel (role) "SUPER_ADMIN", "ADMIN" ou "USER".
  • Linha 17: As demais APIs não cobertas pelas regras das linhas acima serão acessíveis por usuários que tenham o papel (role) "SUPER_ADMIN" ou "ADMIN".

Resumindo os 4 pontos acima:

  1. Qualquer usuário pode tentar se autenticar.
  2. Somente usuários com o papel (role) "SUPER_ADMIN" podem criar, listar, atualizar e apagar tenants.
  3. Usuários que tem o papel (role) "SUPER_ADMIN" ou "ADMIN" podem criar, listar, atualizar e apagar usuários.
  4. Usuários com o papel (role) "USER" só podem listar usuários.

Isso concluiria a parte de autenticação e autorização…mas tenho que explicar mais uma coisa:

Personificação de tenant

Lembra que o projeto da primeira parte do artigo faz com que o tenantId seja inserido automaticamente em todas os inserts, selects, updates e deletes?

Se você prestou atenção no arquivo src/main/resources/db/migration/V1__initial_setup.sql vai perceber que o único usuário que existe inicialmente é o usuário "super.admin@demo.com", cuja senha não criptografada é "super.admin", que tem o papel (role) "SUPER_ADMIN" e que "vive" em um tenant especial, o "Super tenant administrator".

Agora você deve estar se perguntando:

Como é que eu vou criar um usuário em outro tenant, sem ser o "Super tenant administrator"?

Bem, é ai que vem uma escolha que fiz, não sei se é a melhor mas é a que atendeu meu caso de uso e permitiu não mexer muito na lógica: PERSONIFICAÇÃO DE TENANT!

Isso mesmo: Usuários com o papel (role) "SUPER_ADMIN" podem utilizar o endpoint /api/tenants/impersonate/{tenantId} podendo assim criar JWTs com o tenantId escolhido!

Parece algo simples mas com isso diminuímos muito o código que permitiria tal coisa, mas o maior ganho é: Manter a lógica de acesso aos dados de um tenant a mesma para qualquer usuário!

Vamos ver como isso foi feito?

A classe acima é bem simples: Ela pega o email do usuário autenticado e cria um JWT com o tenantId passado na requisição.

E esse serviço só é usado pelo controller TenantController que, como já vimos: Só pode ser acessado por usuários que tenham o papel (role) "SUPER_ADMIN".

Concluindo

Temos agora um projeto que permite ter:

  1. Uma aplicação multitenancy com uma coluna discriminadora.
  2. Autenticação do usuário contra o banco de dados para obtermos um JWT válido.
  3. Em posse de um JWT válido, continuarmos a termos autenticação (sem precisar verificar novamente no banco) e ganharmos o poder de autorização em nossos endpoints através da reivindicação (claim) roleName.

Testando

Disponibilizei no diretório "src/test/resources/spring-multitenancy-column-discriminator.postman_collection.json" uma collection do Postman com todas as chamadas para testar tudo que vimos nesse artigo.

Bônus do projeto

Implementei funcionalidades, listadas abaixo, que não fazem parte do escopo desse artigo e que o deixariam maior do que já está.

  • Na classe UserService existe um tratamento para que usuários sejam criados ou atualizados com papéis (roles) iguais ou mais baixos do que a do usuário autenticado.
    Isso é necessário para não permitir, por exemplo, que um usuário com o papel (role) "USER" crie ou atualize usuários com o papel (role) "ADMIN" ou "SUPER_ADMIN" e assim por diante.
  • Na mesma classe do ponto acima coloquei uma trava para evitar que um usuário com o papel (role) "SUPER_ADMIN" seja criado fora do tenant "Super tenant administrator".
  • O motivo do usuário e tenant iniciais começarem com o id = 0 é para não entrar em conflito com o id dos próximos registros, que serão gerenciados pelo banco de dados e começam em 1.

Os items acima não existem, ou são implementados de forma diferente, na arquitetura do meu projeto pessoal. Fique a vontade para mudar conforme o seu caso de uso.

  • Quer desligar o Flyway e deixar o "Spring Data JPA" criar e atualizar o banco de dados para você?
    Só adicionar essas 3 linhas no arquivo application.properties. O comentário vai também: Não esqueça de remover isso quando for pra produção ou o desenvolvimento já estiver avançado…evita MUITA dor de cabeça!
# REMOVE WHEN GOING TO PRODUCTION #
spring.flyway.enabled=false
spring.jpa.hibernate.ddl-auto=update

Bem, é isso pessoal! Curti demais estudar e desenvolver essa arquitetura, que vai servir de base para um projeto pessoal meu, mas curti mais ainda escrever sobre!

Espero que tenham gostado e até o próximo artigo!

--

--