Back-End

11 set, 2018

Implementando autenticação JWT – O Back-end

Publicidade

No artigo anterior dei uma introdução básica ao JWT e mostrei como ele funciona. Agora vamos começar implementando uma pequena API que vamos utilizar como endpoint padrão para login no nosso sistema fictício.

Utilizaremos o Node.js com o Hapi.js na versão 17.5.1, um webserver bastante robusto e de fácil configuração. Neste exemplo vamos criar uma rota que faz, basicamente, duas coisas:

  • Decodifica as credenciais do usuário
  • Busca o mesmo no banco de dados

Para que o exemplo não fique absurdamente comprido, vamos simplificar bastante uma série de tarefas, Por exemplo, não vou implementar nem o servidor completo e nem demonstrarei o uso da biblioteca de conexão com o banco de dados – assumiremos que isso já está pronto.

O fluxo

O fluxo de login que vamos fazer vai seguir alguns passos:

  • Acessaremos a página de login do nosso site
  • Iremos inserir nossas informações no formulário
  • A página transformará nossas informações em um hash
  • Também irá gerar um número de uso único chamado NONCE
  • Vamos enviar as informações e o NONCE para o servidor no formato usuário e senha encriptados com o algoritmo aes-256-cbc através do header Authorization como Bearer
  • O servidor irá decodificar os dados usando a chave e o NONCE
  • Se nosso usuário existir ele então irá enviar de volta o token JWT

Aqui temos que notar algumas coisas importantes. Nosso modelo de autenticação é muito básico para que nosso exemplo não fique complicado demais. O que vamos fazer aqui é dar mais foco no JWT do que no processo de login em si, por isso algumas partes podem e devem ser melhoradas para que a segurança fique ainda melhor. Por exemplo:

  • Ao invés de gerar o NONCE no client front-end, poderíamos enviar uma request ao servidor pedindo que nos enviasse tal número
  • Poderíamos requisitar, além do NONCE, um código de acesso que seria nossa chave de criptografia local juntamente com um ID ou até mesmo o próprio NONCE. O servidor manteria a chave de criptografia em um cache com expiração curta, juntamente com o ID para que fosse possível decriptar as informações enviadas

Em teoria não há maneira segura o suficiente de se fazer um login em uma página web. Todas as implementações têm falhas. O que fazemos é minimizar a chance de um ataque ou um roubo de informações através de ataques como, por exemplo, o man in the middle.

Mas, mesmo apesar de todos os nossos esforços, não temos como ser 100% seguros, pois o client possui todas as informações, então mesmo que buscássemos muitas informações do servidor, ainda não poderíamos incluir informações sensíveis no próprio client, tendo que sobrecarregar o servidor com esses dados e essas verificações.

Para este exemplo vamos utilizar um modelo que não é definitivamente seguro em termos de login, mas que exemplifica bem o que e onde podemos melhorar no processo para uma possível implementação em produção, então não use este fluxo em sua aplicação real.

O servidor

Antes de falarmos do servidor, vamos incluir algumas dependências que precisaremos para que ele funcione bem. A primeira delas é o hapi em si, depois teremos uma biblioteca que fará o trabalho de registrar o JWT como um meio válido de autenticação chamada hapi-auth-jwt2.

Nosso servidor está em um arquivo chamado server.js; ele contém algumas funções importantes, mas sua função principal é a que cria o próprio servidor:

const Hapi = require('hapi')

const server = new Hapi.Server({
    port: process.env.PORT,
    routes: {
        valdate: { failAction: 'log' },
        cors: {
          origin: ["*"],
          headers: ["Access-Control-Allow-Origin","Access-Control-Allow-Headers","Origin", "X-Requested-With", "Content-Type", "X-Nonce", "Authorization"],
          credentials: true
        }
    }
})

Veja que estamos permitindo qualquer endereço via CORS. Em uma aplicação real você deveria definir os domínios que podem acessar sua API. Além disso estamos permitindo alguns headers específicos da nossa aplicação. Os mais importantes, são:

  • X-Nonce: vai ser por onde vamos receber o NONCE do client
  • Authorization: header padrão do PHP para envio de autorizações

Um pouco mais abaixo, no mesmo arquivo, teremos uma outra função chamada init, que é exportada para fora para iniciarmos nosso servidor:

function init () {
    await registraPlugins(server) // Função que registra todos os nossos plugins do Hapi
    
    server.auth.strategy('imasters-jwt', 'jwt', {
        key: new Buffer(process.env.IMASTERS_CLIENT_SECRET, 'utf-8'),
        validate: async () => ({isValid: true}),
        verifyOptions: {
            algorithms: ['HS256'],
            audience: process.env.IMASTERS_AUDIENCE
        }
    })
    
    await registraRotas(server) // Registra todas as nossas rotas
    
    return server
}

module.exports = { init }

Aqui temos uma parte importante; veja onde temos server.auth.strategy – esta é a parte onde vamos falar para nosso servidor que ele vai precisar de autenticação em algumas rotas. Essa autenticação é chamada de strategy. Se você quiser entender um pouco mais veja a documentação sobre a API do strategy.

  • O primeiro parâmetro desta função é um identificador da strategy. Podemos ter mais de uma strategy no nosso servidor, por exemplo, quando temos sistemas diferentes que usam autenticações diferentes (ou até ambos usando JWT, mas cada um possui um SECRET diferente). Será por meio deste identificador que vamos dizer a rota na qual a strategy deve ser usada.
  • O segundo parâmetro define qual é o tipo de autenticação. Vamos escrever jwt porque queremos receber uma autenticação JWT no nosso header Authorization

O último parâmetro é um objeto com as seguintes propriedades:

  • – key: esta é a chave pela qual o JWT será assinado, ou seja, a chave de criptografia que será usada pelo servidor para garantir a integridade do token. Esse valor não pode ser público, por isso que chamamos de SECRET
  • – validate: aqui vamos escrever uma função simples que será executada sempre que um token for verificado e decodificado. Este é um campo totalmente pessoal; você pode escrever qualquer coisa aqui e estará disponível depois se precisarmos validar se este token é válido ou não. No nosso caso é retornar um objeto simples
  • – verifyOptions: neste campo é onde vamos definir quais são os campos do JWT que vamos validar para considerar um JWT como válido. Naturalmente o campo exp já é validado por padrão, além dele vamos validar se o algoritmo usado para criptografar o token é o HS256 e também se a Audience é a que definimos na variável de ambiente IMASTERS_AUDIENCE.

Os objetos definidos dentro desta função são todos pertencentes ao pacote hapi-auth-jwt2 que falamos anteriormente. Para saber mais sobre cada um você pode checar a documentação.

Basicamente o que estamos fazendo até aqui é criar o nosso meio de autenticação e registrá-lo no nosso servidor. Por fim, simplesmente iniciaremos o servidor que criamos:

const {init} = require('./server')

const sv = await init()
await sv.start()

A rota

Para criarmos a nossa rota, vamos incluir uma outra biblioteca chamada jsonwebtoken, que é a biblioteca oficial para a implementação do JWT. Com ela vamos decodificar, validar e criar novos tokens a partir dos nossos dados de entrada. Também vamos utilizar o moment para criar os timestamps de expiração e criação.

Além de tudo isso vamos usar a biblioteca nativa crypto para criptografar e decriptar nossos dados.

Como a rota que vai realizar a autenticação deve receber qualquer tipo de request, ela será a única rota do sistema que vai ser totalmente aberta, então todos podem acessar. Essa rota estará no arquivo get-auth.handler.js; vamos começar com a função principal:

const moment = require('moment')
const {createHash, createDecipheriv, Decipher} = require('crypto')
const {sign} = require('jsonwebtoken')
const CryptoError = require('../Errors/CryptoError')
const UserNotFoundError = require('../Errors/UserNotFoundError')

function handler (Request, h) {
    try {
        const {authorization, 'x-nonce': nonce} = request.headers // Obtemos os headers
        const credenciais = decifrarCredenciais(authorization, nonce) // Decifra os dados
        const usuario = await obtemUsuario(credenciais) // Busca o usuário no banco
        const jwtFinal = gerarJwt(gerarPayload(usuario)) // Gera a resposta
    } catch (error) {
        console.error(error.name, error.message)
        if (error instanceof CryptoError || error instanceof UserNotFoundError) return h.response('Usuário não autorizado').code(401)
        return h.response('Erro ao realizar login').code(500)
    }
}

module.exports = {
    method: 'GET',
    path: '/auth',
    handler,
    options: {
        description: 'Realiza login de usuário'
    }
}

Inicialmente prestaremos atenção no nosso catch. Nele temos uma verificação se o erro é instancia de outro erro, ou seja, vamos ter que criar classes de erros estendidas da classe Error padrão para verificarmos se um usuário já existe ou não na base, ao invés de fazer essas verificações dentro do código de negócio. Vamos tratá-lo como um erro. Um exemplo dessas classes de erro seria nosso arquivo UserNotFoundError.js:

class UserNotFoundError extends Error {
    constructor (userID) {
        super(`O usuário ${userID} não poder ser encontrado`)
        this.name = 'UserNotFoundError'
    }
}

A classe CryptoError será utilizada quando não conseguirmos decodificar os dados; provavelmente por algum NONCE errado.

Na linha 7 estamos extraindo os headers que necessitamos para verificar o token e ai passamos essas informações para uma série de funções que não sabemos o que são. Vamos defini-las, começando pela função decifrarCredenciais:

function decifrarCredenciais (bearer, nonce) {
    try {
        const hashChave = createHash('md5')
        	.update(process.env.IMASTERS_CLIENT_ID, 'utf8')
        	.digest('hex')
        	.toUpperCase() // Criamos a chave que vai decodificar os dados recebidos
        const vetorInicializacao = Buffer.from(nonce, 'base64')
        const decipher = createDecipheriv('aes-256-cbc', hashChave, vetorInicializacao)
        const decifrado = decipher.update(bearer.replace('Bearer ', ''), 'hex', 'utf8') + decipher.final('utf8')
        const [user, pass] = decifrado.split(' ')
        
        return { user, pass }
    } catch (error) {
        throw new CryptoError(nonce)
    }
}

Estamos decodificando o payload que recebemos da request do cliente. Essa request vai conter dois headers: o primeiro, Authorization, conterá uma string Bearer XXXX onde XXXX é algum hash para uma string contendo usuario md5(senha) separado por espaço. O segundo header é o X-Nonce que vai conter um valor em base64 que será nosso vetor de inicialização.

Para que a criptografia funcione, além da senha vamos definir este número de uso único. Sem ele podemos até decriptar os dados, mas eles ficam embaralhados porque o vetor de inicialização é utilizado para definir as posições e também os caracteres do texto original, agindo como um dicionário. Isso adiciona uma camada extra de segurança, visto que uma pessoa só poderá utilizar este número uma única vez (por isso uma das recomendações é recebê-lo do servidor, o que não estamos fazendo aqui).

Vamos utilizar o método createDecipheriv para criar uma classe decodificadora que recebe o algoritmo, a senha que utilizamos como senha de criptografia e também o chamado IV (initialization vector), que é o vetor de inicialização que definimos como Nonce anteriormente.

Então vamos utilizar a classe Decipher para de fato decifrar os dados, removendo a string Bearer que vem junto no header Authorization. O resultado que obteremos está em hexadecimal, mas queremos o output em utf-8, por isso que colocamos os últimos dois parâmetros.

Além disso precisamos incluir um segmento de código chamado de final, que é um pequeno bloco que impede que o hash seja alterado após ser finalizado.

Por fim retornamos os dados de usuário para a função obtemUsuario:

async function obtemUsuario (credenciais) {
    try {
		return await provedorDeUsuarios.encontrarUsuario(credenciais)
    } catch (error) {
        throw error
    }
}

Essa função receberá o objeto { user, pass } da função anterior e buscará o usuário no banco de dados. Aqui vamos assumir duas coisas:

  • O provedorDeUsuarios já está definido e pode ou não ser uma classe que vai retornar uma Promise com os dados do usuário no banco
  • Se o usuário não for encontrado, então a função irá jogar um UserNotFoundError para nós

Vamos passar os dados de usuário para a função jwtFinal, que contém a função gerarPayload. Vamos começar de dentro para fora com a função gerarPayload:

function gerarPayload (usuario) {
    return {
        sub: usuario.id,
        name: usuario.nome,
        mail: usuario.email,
        login: usuario.username,
        isAdmin: usuario.isAdmin,
        iat: Math.floor(moment.now()/1000), // Timestamp de hoje
        exp: moment().add(2, 'days').unix() // Validade de 2 dias
    }
}

Essa função é a mais simples, mas ao mesmo tempo a mais crucial do sistema; ela que definirá quais são os campos que vamos enviar no payload do nosso JWT. Veja que temos alguns campos conhecidos como o sub, iat e exp, mas todos os demais são completamente próprios do nosso sistema e estarão disponíveis na aplicação se precisarmos.

Este é o motivo do JWT ser tão interessante: você pode enviar toda uma informação de usuário junto com ele, evitando várias requisições desnecessárias ao banco de dados.

Todo este objeto será enviado a uma função final que, de fato, vai gerar e assinar o JWT:

function gerarJwt (payload) {
    return sign(payload, 
                process.env.IMASTERS_CLIENT_SECRET, 
                {audience: process.env.IMASTERS_AUDIENCE}
               )
}

Vamos utilizar o método sign do pacote jsonwebtoken para criar e assinar um token com nosso SECRET e, juntamente, criar o campo audience no mesmo.

Veja as demais opções que podemos ter no terceiro parâmetro, pois também podemos enviar outros campos não obrigatórios para o JWT de forma mais rápida. Por exemplo, se removermos a chave exp da nossa função anterior, podemos enviar a data de expiração direto no método sign:

function gerarJwt (payload) {
    return sign(payload, 
                process.env.IMASTERS_CLIENT_SECRET, 
                {
        		  audience: process.env.IMASTERS_AUDIENCE,
        		  expiresIn: '2 days'
                }
               )
}

Junto com uma série de outras customizações.

Conclusão

Nossa rota e servidor já estão prontos para uso e, no próximo artigo, veremos como interagir com este servidor back-end através do nosso cliente front-end desenvolvido em React.

Até mais!