Arquitetura multitenancy com coluna discriminadora — Parte 2
Adicionando autenticação e autorização com Bcrypt e JWT
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:
- Verificar se quem está fazendo alguma requisição é realmente um usuário que tem acesso ao sistema (autenticação).
- 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 comfind
será interceptada pela classeTenantAspect
e terá o filtrotenantFilter
ativado e preenchido com o valor dotenantId
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 filtrotenantFilter
.
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 chamadoBasic
*. - 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 chamadaBearer
*.
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 chamadoBearer
. - 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:
- Qualquer usuário pode tentar se autenticar.
- Somente usuários com o papel (role) "SUPER_ADMIN" podem criar, listar, atualizar e apagar tenants.
- Usuários que tem o papel (role) "SUPER_ADMIN" ou "ADMIN" podem criar, listar, atualizar e apagar usuários.
- 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:
- Uma aplicação multitenancy com uma coluna discriminadora.
- Autenticação do usuário contra o banco de dados para obtermos um JWT válido.
- 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 oid
dos próximos registros, que serão gerenciados pelo banco de dados e começam em1
.
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 arquivoapplication.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!