Back-End

26 set, 2018

Implementando autenticação JWT – O Front-End

Publicidade

Vamos continuar de onde paramos no nosso último artigo sobre a implementação JWT no back-end. Cobriremos agora a integração com o front-end da nossa aplicação. Para este caso estou usando o React, mas você pode fazer com qualquer outro framework, ou até manualmente se precisar.

Utilizarei o React na versão 16.4.0, juntamente com o pacote jsonwebtoken na versão 8.3.0 para decodificarmos os tokens. Na hora de fazer as requisições, vamos usar o Axios na versão 0.18.0 e o LocalForage na versão 1.7.2 para poder armazenar os dados no LocalStorage.

Para ficar ainda mais simples, estou utilizando o boilerplate create-react-app para abstrair todo o tipo de configuração e manter a base dos arquivos padrão para todos. Basicamente, o que vamos fazer é:

  • Criar um formulário de login
  • Enviar os dados do usuário de forma obscura para a API (seguindo os contratos que criamos)
  • Aplicar a lógica para o retorno da API no nosso front-end

Novamente, para que o exemplo (e, consequentemente, o artigo) não se estenda de mais, vamos nos ater apenas às partes importantes do código, então somente trabalharemos com o arquivo LoginPage.js, que será nossa página principal, e também nossos serviços de consumo (que vamos criar ao longo do artigo). Para saber sobre o fluxo de login que vamos utilizar, dê uma olhada no parágrafo “O fluxo” do artigo anterior.

Assim como no artigo anterior, vou dar o mesmo disclaimer: Este código é um exemplo para estudo e referência e possui falhas de segurança, então não é aconselhável utilizá-lo em produção.

Criando a base da tela

Na nossa pasta src criaremos uma outra pasta chamada Pages e dentro dela mais uma chamada LoginPage. Nela vamos criar um arquivo LoginPage.js. Aqui criaremos nosso formulário de login. Para sintetizar bastante as informações não faremos nenhuma validação no formulário, vamos apenas focar no fluxo.

Para simplificar ainda mais, estou usando o pacote semantic-ui-react, que provê uma série de componentes prontos para não precisarmos ficar editando nossos CSS.

Então, para iniciar a tela vamos criar um modelo base importando já os serviços que vamos utilizar:

import React, { Component } from 'react'
import { Grid, Form, Button, Segment } from 'semantic-ui-react'
import AuthenticationService from '../../Services/AuthenticationService'
import StorageProvider from '../../Services/StorageProvider'
import jsonWebTokenService from 'jsonwebtoken'

class LoginPage extends Component {
    constructor () {
        super()
        this.state = {
            loading: true,
            username: null,
            password: null
        }
        
        this.auth = new AuthenticationService()
        this.localForage = StorageProvider
        this.handleSubmit = this.handleSubmit.bind(this)
        this.handleChange = this.handleChange.bind(this)
        this.saveJwt = this.saveJwt.bind(this)
    }
}

export default LoginPage

Até agora não temos nada de mais, apenas importamos nossos serviços e criamos nossos estados iniciais. Vamos agora criar o método render, que será o nosso formulário:

import React, { Component } from 'react'
import { Grid, Form, Button, Segment } from 'semantic-ui-react'
import AuthenticationService from '../../Services/AuthenticationService'
import StorageProvider from '../../Services/StorageProvider'
import jsonWebTokenService from 'jsonwebtoken'

class LoginPage extends Component {
    constructor () {
        super()
        this.state = {
            loading: true,
            username: null,
            password: null
        }
        
        this.auth = new AuthenticationService()
        this.localForage = StorageProvider
        this.handleSubmit = this.handleSubmit.bind(this)
        this.handleChange = this.handleChange.bind(this)
        this.saveJwt = this.saveJwt.bind(this)
    }
    
    render () {
        return (
       <Grid columns={1} padded style={{ height: '100vh' }} verticalAlign='middle' className='login-wrapper'>
        <Grid.Row as='main' centered>
          <Grid.Column largeScreen={5} computer={5} tablet={5} mobile={12}>
            <Segment id='loginForm'>
              <Form onSubmit={this.handleSubmit}>
                <Form.Input
                  disabled={this.state.loading}
                  focus
                  icon='user'
                  iconPosition='left'
                  loading={this.state.loading}
                  name='username'
                  fluid
                  label='Usuário'
                  onChange={this.handleChange}
                  placeholder='Seu usuário'
                />

                <Form.Input
                  disabled={this.state.loading}
                  icon='lock'
                  iconPosition='left'
                  loading={this.state.loading}
                  name='password'
                  fluid
                  label='Senha'
                  onChange={this.handleChange}
                  type='password'
                  placeholder='Sua senha'
                />

                <Button
                  disabled={this.state.loading}
                  loading={this.state.loading}
                  size='huge'
                  fluid
                  content='Entrar'
                  icon='sign in'
                  labelPosition='left'
                  color='vk'
                  type='submit'
                />
              </Form>
            </Segment>
          </Grid.Column>
        </Grid.Row>
      </Grid>
        )
    }
}

export default LoginPage

A tela no momento não deve funcionar porque precisamos implementar os métodos necessários, mas ao fazer isso vamos ter um formulário simples.

Salvando os estados

Vamos agora implementar os métodos da página, começando do mais simples para o mais complexo. O primeiro será o handleChange, que será responsável por atualizar os estados quando os campos do formulário foram preenchidos:

import React, { Component } from 'react'
import { Grid, Form, Button, Segment } from 'semantic-ui-react'
import AuthenticationService from '../../Services/AuthenticationService'
import StorageProvider from '../../Services/StorageProvider'
import jsonWebTokenService from 'jsonwebtoken'

class LoginPage extends Component {
    constructor () { /* ... */ }
    
    handleChange (target, data) {
        this.setState({
            [data.name]: data.value
        })
    }
    
    render () { /* ... */ }
}

export default LoginPage

Basicamente a função atualizará os dados do estado de acordo com o nome do campo, como chamamos nossos campos pelos mesmos nomes do que as chaves do estado, o programa vai buscar dinamicamente um campo para atualizar – isso evita a necessidade de fazer um método para cada campo.

Salvando a informação do usuário

O próximo método da lista será o saveJwt, este método pode ser feito de várias formas, mas a sua função principal será sempre salvar os dados do JWT no LocalStorage:

import React, { Component } from 'react'
import { Grid, Form, Button, Segment } from 'semantic-ui-react'
import AuthenticationService from '../../Services/AuthenticationService'
import StorageProvider from '../../Services/StorageProvider'
import jsonWebTokenService from 'jsonwebtoken'

class LoginPage extends Component {
    constructor () { /* ... */ }
    
    handleChange (target, data) { /* ... */ }
    
    saveJwt (jwt) {
        try {
          if (jwt) {
            const decodedJwt = jsonWebTokenService.decode(jwt)
            await this.localforage.setItem('jwt_usuario', jwt)
            await this.localforage.setItem('dados_usuario', decodedJwt)
            return true
          }
        } catch (err) {
            if (err instanceof jsonWebTokenService.JsonWebTokenError) return false
            throw err
        }
    }
    
    render () { /* ... */ }
}

export default LoginPage

Aqui temos que notar uma informação importante: o pacote jsonwebtoken tem dois métodos de decodificação de um token:

  • jsonWebTokenService.decode(jwt, [options]): decodifica um token, mas não verifica se sua assinatura está correta, ou seja, apenas pega os dados contidos no token e os exibe em texto plano – este é o equivalente a usar o site jwt.io para verificar o conteúdo do seu token – isto é muito útil no nosso caso porque não podemos ter nenhum tipo de secret no nosso cliente, visto que é fácil acessar os dados contidos, mesmo utilizando React e variáveis de ambiente, pois, no Webpack, estes dados são substituídos por uma variável global com o valor em texto plano.
  • jsonWebTokenService.verify(jwt, secret, [options]): faz exatamente a mesma coisa que o método anterior, mas também verifica a assinatura de acordo com o seu secret e, como o próprio nome já diz, o secret é para ser um segredo. Portanto, não podemos utilizar no nosso client, então este método se torna extremamente valioso no back-end, porque temos que checar a assinatura do token sempre que fizermos uma chamada para uma de nossas APIs, felizmente isso já é feito automaticamente pelo plugin que utilizamos no outro artigo.

Basicamente o que fazemos neste método é pegar nosso token JWT retornado pelo nosso servidor e decodificá-lo para guardar sua informação em um lugar acessível que podemos recuperar ao longo da aplicação.

A implementação do nosso arquivo Services/StorageProvider, que estamos utilizando como um wrapper do LocalForage ficaria assim:

import localforage from 'localforage'

localforage.config({
  driver: [localforage.LOCALSTORAGE, localforage.INDEXEDDB],
  name: 'iMasters',
  storeName: 'iMasters_keys'
})

export default localforage

Fazemos este wrapper apenas como uma prática de desacoplamento, visto que podemos utilizar este serviço com as mesmas configurações ao longo da nossa aplicação – seria quase como um Factory Pattern. Desta forma podemos definir as configurações em apenas um lugar e reutilizar o mesmo serviço ao longo das nossas páginas.

Existem outras formas de armazenar o token. Poderíamos utilizar, por exemplo, cookies, mas a manipulação seria mais complexa, então, pelo bem da simplicidade, vamos manter o LocalForage.

Enviando as informações do usuário

Vamos para o último método da nossa página. Aqui estará a maior parte da nossa lógica. Primeiramente vamos capturar os dados do usuário e enviar para nosso back-end, o nosso método handleSubmit ficaria assim:

import React, { Component } from 'react'
import { Grid, Form, Button, Segment } from 'semantic-ui-react'
import AuthenticationService from '../../Services/AuthenticationService'
import StorageProvider from '../../Services/StorageProvider'
import jsonWebTokenService from 'jsonwebtoken'

class LoginPage extends Component {
    constructor () { /* ... */ }
    
    handleChange (target, data) { /* ... */ }
    
    saveJwt (jwt) { /* ... */ }
    
    handleSubmit () {
        this.setState({loading: true}) // Para desabilitarmos os campos
        try {
            const {username, password} = this.state
            const jwt = this.auth.doLogin(username, password)
            const isUserValid = this.saveJwt(jwt)
            if (isUserValid) // usuário é válido, pode prosseguir
        } catch (err) {
            // Tratamento de erros e exibição de mensagem para o usuário
        }
    }
    
    render () { /* ... */ }
}

export default LoginPage

Percebam que não há nada de mais neste código, a parte importante está nos nossos serviços de login. Vamos descrever o primeiro método do AuthenticationService:

import crypto from 'crypto'
import axios from 'axios'

export default class AuthenticationService {
    constructor () {
        this.http = axios
    }
    
    async doLogin (user, pass) {
        try {
          const md5Pass = crypto
            			    .createHash('md5')
            				.update(pass)
            				.digest('hex') // Cria um MD5 da senha
          const {nonce, encodedData} = this._encrypt(`${user} ${pass}`) // encripta com AES
          const requestOptions = {
              headers: {
                  Authorization: `Bearer ${encodedData}`,
                  'X-Nonce': nonce
              },
              validateStatus: (status) => status < 500
           }
          const {data: jwt, status} = await this.http.get(`nossaurldeapi/auth`, requestOptions)
          if (status === 401) throw new Error('Usuário ou senha inválidos')
          return jwt              
        } catch (err) {
           throw err
        }
    }
}

Veja que estamos fazendo alguns passos importantes que definimos no nosso fluxo de back-end. Primeiro estamos criptografando em um hash MD5 a senha do usuário para que ela não fique em texto plano quando trafegarmos, isso aumenta um pouco a segurança que temos em qualquer tipo de comunicação.

Depois, estamos utilizando uma função _encrypt, que é uma função interna do nosso serviço. Esta função irá criptografar os dados utilizando o ID de usuário do cliente e enviar para nosso servidor:

import crypto from 'crypto'
import axios from 'axios'

export default class AuthenticationService {
    constructor () {
        this.http = axios
    }
    
    async doLogin (user, pass) { /* ... */ }
    
    _encrypt (data) {
        try {
            const passHash = crypto
            	.createHash('md5')
            	.update(process.env.REACT_APP_CLIENT_ID, 'utf-8')
            	.digest('hex')
            	.toUpperCase()
            const nonce = Buffer.alloc(16, Math.random().toString(36).substr(7))
            const alg = 'aes-256-cbc'
            const cipher = crypto.createCipheriv(alg, passHash, nonce)
            return {
                nonce: nonce.toString('base64'),
                encodedData: cipher.update(data, 'utf-8', 'hex') + cipher.final('hex')
            }
        } catch (err) {
            throw err
        }
    }
}

Este método é um pouco mais complexo para se entender, vamos por partes:

  • Primeiramente estamos criando um hash MD5 com o conteúdo da nossa variável de ambiente CLIENT_ID. Este valor é o que está no campo aud do nosso JWT, portanto é um valor público e não há problema em exibir por aqui. Estamos falando que queremos este hash com a saída em hexadecimal – por isso usamos digest('hex') – e tudo em letras maiúsculas.
  • Criamos um Buffer de tamanho 16 bytes e preenchemos ele com uma sequencia aleatória de números e letras. Isso é feito através do método toString, que, quando utilizado em tipos Number, pode receber um radical que indica a base do número. Estamos passando 36, que será a base do nosso número. Isso vai nos gerar um monte de letras e números aleatórios. Dessa string final, vamos pegar apenas os sete primeiros caracteres para ser o nosso NONCE. Este vetor é importante porque cria um número de uso único para cada tentativa de envio de senha, fazendo com que ataques diretos sejam ineficientes.
  • Por fim vamos utilizar o algoritmo aes-256-cbc para criptografar o nosso dado – que é user pass separado por espaço – utilizando tudo o que criamos antes e então vamos retornar um objeto contendo o NONCE que foi utilizado para criar este hash e o hash em si codificado em base64 apenas para fins de compatibilidade, uma vez que este tipo de cifra pode conter caracteres que não estão no escopo Unicode, então nossa request poderia ficar incompatível.

Finalizando

Uma vez que enviarmos este requisição para nosso servidor, teremos nosso fluxo anterior funcionando perfeitamente. Vamos decodificar as partes e ler o usuário no banco de dados, retornando seus dados.

Este é um dos muitos métodos de login que podemos implementar manualmente. Existem várias modificações de segurança que podemos fazer como, por exemplo, a assinatura de requisições através de uma chave única, ou então até mesmo criptografia assimétrica, mas este é um assunto para outro artigo.

Até mais!