Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Usando tokens JWT de forma segura
Lucas Santos
Lucas Santos

Posted on • Originally published atblog.lsantos.dev on

     

Usando tokens JWT de forma segura

Segurança na Web é uma preocupação que deveria ser de todas as pessoas, principalmente as que estão atrás de projetos de desenvolvimento de software. Para devs, é muito mais preocupante criar sistemas que não seguem regras de segurança, pois qualquer tipo de ação maliciosa pode escalar muito rápido.

Se você já leu meus conteúdos, sabe que eu sou um grande fã do padrão da RFC7519, o famosoJWT, tanto é que há um bom tempo eu cheguei aescrever um artigo sobre como tudo funciona e explicar todos os detalhes da implementação de um token desse tipo.

O problema é que essa implementação é inerentementeinsegura, e eu vou te dizer o por quê.

O código usado nesse exemplo pode ser encontradono meu GitHub

O problema

Durante todo o período que os tokens JWT existem, eles já foram alvos dediversas controvérsias, grande parte delas diz que os tokens são sujeitos a um tipo de ataque específico chamado XSS Attack.

Se você ainda não sabe o que é um ataque XSS, deixo esse vídeo que fiz em parceria com o Código Fonte TV

Na maioria das aplicações, quando recebemos um token JWT do servidor, por via de regra pensamos:

"Onde eu vou armazenar esse token para evitar ter que logar o usuário todas as vezes?" – Praticamente todo mundo

E, na maioria das vezes, oLocalStorage é o local escolhido. Ele é uma API de uso extremamente simples, armazena dados entre sessões e entre abas, então desde que a aba esteja no mesmo domínio, o browser vai armazenar o token e permitir que ele seja usado. É uma das formas mais eficientes de se fazer um login.

O maior problema é que ele pode ser acessado facilmente via JS, então qualquer site com uma vulnerabilidade a XSS, ou seja, a possibilidade de fazer um script malicioso ser executado no domínio, vai fazer com que o token seja automaticamente inseguro, porque qualquer pessoa pode ler esse token usando scripts.

As soluções

Existem uma miríade de soluções para contornar esse problema, vamos explorar algumas delas.

Token em memória

Para contornar esse problema, podemos usar uma outra técnica, ao invés de armazenar o token direto noLocalStorage, podemos deixá-lo apenas na memória, ou seja, a gente nunca armazena ele em lugar nenhum. Por exemplo:

const _token = nullfetch('/login', {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify({ username: 'usuario', password: 'senha' })}).then(data => data.json()).then(token => { _token = token })
Enter fullscreen modeExit fullscreen mode

Fazer isso deixa o token virtualmente invisível para qualquer script já que não está no mesmo escopo do outro script e vive apenas na memória, mesmo que ele ainda seja acessível com ummemory dump , mas para isso o atacante precisa estar com acesso ao computador da vítima.

Porém temos um lado negativo bastante forte, se o usuário recarregar a página ou trocar de aba, não vamos ter o token salvo nesse novo ambiente, então precisaríamos pedir o login do usuário a cada momento, o que não é uma excelente UX.

Existe uma forma de contornar esse impecilho com o uso de tokens de longa duração, vamos falar sobre eles mais a frente.

HttpOnly cookies

Para nivelar o conhecimento sobre cookies, um cookie é um pedaço de informação até 4kb que fica salvo no browser entre abas desde que as mesmas estejam no mesmo domínio. É uma outra forma de persistir dados mesmo quando a sessão do usuário terminou, uma vez que, mesmo que o usuário feche o browser, os cookies continuarão lá para um futuro uso.

Cookies são muito usados naquelas caixas de seleção "Lembrar de mim" durante logins de usuário.

A outra opção aqui seria armazenar o token de acesso em um cookie com a flaghttpOnly ativada. Cookies com essa propriedade não são acessíveis pelo JavaScript, apenas pelo browser e pelas requisições.

const express = require('express');const app = express();// preparação de middlewares e etc...app.post('/login', (req, res) => {  // realiza o login...  res.cookie('token', '12345', { maxAge: 5*60*1000, httpOnly: true, sameSite: 'strict' });  res.send('OK')})
Enter fullscreen modeExit fullscreen mode

Pela própria forma padrão como browsers funcionam, temos que eles enviam de volta para o servidor todos os cookies setados por esse mesmo domínio a cada requisição, ou seja, em todas as chamadas subsequentes a essa para nosso backend iríamos ter um cookie com o nosso token de usuário por lá e ai poderíamos validar através deles ao invés de usar um headerAuthorization: Bearer como é o comum.

O problema é que tokens JWT podem armazenar muita informação e, muitas vezes, esses tokens ultrapassam os 4kb de limite, então é um pouco arriscado usar esses Cookies para armazenar tokens de acesso, fora que eles ficam salvos no computador do usuário e, apesar de não serem alvo de XSS, poderiam ser vítimas de um outro tipo de ataque chamadoCross Site Requet Forgery ou CSRF.

O CSRF pode ser mitigado e prevenido com o uso de outros tokens anti-CSRF, que é um assunto para o próximo artigo.

Um misto de ideias

Existem outras soluções possíveis que vou falar no final deste artigo e também vou explorar em mais detalhes nos artigos futuros, mas e se a gente usasse uma mistura das duas soluções que eu propus antes?

A ideia é que façamos o login do usuário, armazenamos o token de acesso direto na memória, ninguém vai ter acesso a ele, mas como lidamos com a questão de manter os logins?

Refresh Tokens

Uma das saídas que temos para lidar com o caso de tokens voláteis como é o caso de armazenamento em memória são os chamadosrefresh tokens. A ideia é que um token de acesso seja de curta duração (no máximo 15 minutos) enquanto um token de refresh tenha uma duração mais longa (algumas horas ou dias).

Um token de refresh tem como objetivo somente te dar um novo token de acesso, então ele não tem nenhuma informação interna, e nem deveria ter. Por isso ele é um token extremamente leve que pode ser armazenado em um Cookie.

Basicamente se tivermos uma rota como essa, qualquer token de refresh válido poderia criar um novo token de acesso:

router.post('/refresh', withRefreshAuth, (_, res) => {  const accessToken = createAccessToken(user)  const refreshToken = createRefreshToken(user)  setRefreshCookie(res, refreshToken)  res.json({ accessToken })})
Enter fullscreen modeExit fullscreen mode

Note que também estamos recriando o token de refresh para evitar de ter outro endpoint para isso, então quando rotacionamos um, já rotacionamos os dois.

Essa solução ainda sim não é a ideal, porque ainda sim temos um token que pode produzir outros tokens. A maior diferença é que podemos manter um controle maior sobre os tokens de acesso que foram criados e podemos diminuir a superfície de ataque ao ter esses tokens com uma duração muito menor.

Fingerprinting

Uma forma que podemos manter os tokens de refresh seguros é o uso do que chamamos defingerprints, eles se parecem bastante com tokens CSRF. A ideia é ter um valor único que é gerado do lado do servidor e armazenado como um Cookie seguro.

Além disso a fingerprint é incluída no token, de forma que ela não possa ser alterada sem invalidar o token.

O código para este exemplo está no branchfingerprinting dorepositório do GitHub. Veja o arquivohandlers.ts eindex.js para asdiferenças

Quando o usuário fizer uma request para um refresh, a fingerprint irá junto com o token de refresh, podemos então decodificar o token e verificar se a fingerprint do token é a mesma do Cookie, se por algum motivo a fingerprint do Cookie for diferente da que está no token, teremos um acesso inválido.

Além disso, podemos criar um hash do nosso refresh token e armazenar esse hash em um banco de dados temporário (como o Redis) para poder invalidar sessões ou tokens comprometidos como medida de proteção adicional.

Para não deixar esse artigo muito longo, vou escrever um outro somente sobre como foi feito o processo de fingerprinting.

Implementação

Para implementarmos uma solução desse tipo vamos simular um app que realiza buscas de usuário, vamos ter alguns usuários em um banco de dados local e vamos usar dois tokens diferentes para poder realizar a autenticação. O código deste repositório está no meu GitHub que eu já mostrei lá em cima.

Backend

Vamos começar com o backend e, para simular uma aplicação no browser, eu construí um pequeno app usando somente JavaScript e HTML pra que seja muito mais fácil de ver o que acontece por baixo dos panos.

Nota: Lembrando que, nesse exemplo, estou deixando intencionalmente de fora algumas boas práticas em prol da didática.

Nota 2: Não vou descrever os arquivos básicos (package.json, tsconfig.json, etc) você pode ir até o repositório para copiá-los.

Para essa aplicação vamos usar algumas bibliotecas como dependências diretas, então rode o comando de instalação:

npm i cookie-parser dotenv express jsonwebtoken
Enter fullscreen modeExit fullscreen mode

Eu instalei algumas bibliotecas de desenvolvimento, principalmente pelo uso do TypeScript:

npm i -D @types/cookie-parser @types/node @types/express @types/jsonwebtoken copyfiles rimraf ts-node ts-node-dev typescript
Enter fullscreen modeExit fullscreen mode

No meupackage.json eu também criei alguns scripts para facilitar o desenvolvimento, o arquivo ficou assim:

{  "name": "jwt",  "version": "0.0.1",  "description": "",  "main": "dist/backend.js",  "scripts": {    "dev": "tsnd src/index.ts",    "build": "rimraf ./dist && tsc && copyfiles -u 1 \"./src/frontend/**/*.*\" ./dist",    "start": "node dist/index.js"  },  "keywords": [],  "author": "Lucas Santos <hello@lsantos.dev> (https://lsantos.dev/)",  "license": "MIT",  "dependencies": {    "cookie-parser": "^1.4.6",    "dotenv": "^16.0.1",    "express": "^4.18.1",    "jsonwebtoken": "^8.5.1"  },  "devDependencies": {    "@types/cookie-parser": "^1.4.3",    "@types/express": "^4.17.13",    "@types/jsonwebtoken": "^8.5.8",    "@types/node": "^18.7.3",    "copyfiles": "^2.4.1",    "rimraf": "^3.0.2",    "ts-node": "^10.9.1",    "ts-node-dev": "^2.0.0",    "typescript": "^4.7.4"  }}
Enter fullscreen modeExit fullscreen mode

Pulando a criação básica do app, vamos criar uma pastasrc e dentro dela vamos começar criando nosso banco de dados de usuários:

export type User = {  username: string  name: string  age: number  social: string  password: string}export const users: User[] = [  {    name: 'Lucas Santos',    age: 27,    social: 'twitter.lsantos.dev',    username: 'lsantosdev',    password: '123456'  },  {    name: 'Rosa Barnett',    age: 33,    social: 'http://ko.st/wa',    username: 'rosabarnett',    password: '123456'  },  {    name: 'Russell Spencer',    age: 66,    social: 'http://egki.tp/ecbu',    username: 'russellspencer',    password: '123456'  }]
Enter fullscreen modeExit fullscreen mode

Agora, vamos criar nosso ponto de entrada para a nossa aplicação, que vai ser o arquivoindex.ts, vamos começar importando tudo que a gente precisa usar e definindo os middlewares globais:

  • Vamos usar ocookie-parser para poder parsear os headersCookie que o browser vai enviar de volta pra gente, caso contrário não teremos a chavereq.cookies
  • Para fazer o parsing do corpo da requisição (para a rota de login), estou usando oexpress.json()
import path from 'path'import dotenv from 'dotenv'import express from 'express'import cookieParser from 'cookie-parser'dotenv.config()const app = express()app.use(express.json())app.use(cookieParser())
Enter fullscreen modeExit fullscreen mode

Primeiro vamos carregar as nossas variáveis do nosso arquivo.env que deve estar na raiz da nossa aplicação e tem o seguinte conteúdo:

ACCESS_TOKEN_SECRET=secret_access_tokenREFRESH_TOKEN_SECRET=secret_refresh_tokenACCESS_TOKEN_DURATION_MINUTES=5REFRESH_TOKEN_DURATION_MINUTES=120
Enter fullscreen modeExit fullscreen mode

Esse arquivo está também no repositório, mas lembre-se que não é uma boa prática mandar variáveis de ambiente para o repositório público. Além disso, os secrets de cada token devem ser muito mais seguros do que os que coloquei aqui.

Para ficar mais fácil de entender, vou separar os handlers de cada rota em outro arquivo chamadohandlers.ts, que vamos criar depois, mas já podemos importar ele aqui também:

import path from 'path'import dotenv from 'dotenv'import express from 'express'import cookieParser from 'cookie-parser'import { apiRoutes } from './handlers'dotenv.config()const app = express()app.use(express.json())app.use(cookieParser())
Enter fullscreen modeExit fullscreen mode

Nosso frontend precisa estar no mesmo domínio da nossa aplicação então vou usar o próprio express para servir os arquivos HTML através doexpress.static(). Vamos colocar todo o site atrás de um caminho/site para poder separar ele da API:

// Código anteriorapp.use('/site',   express.static(        path.resolve(__dirname, './frontend'),         { cacheControl: false }  ))
Enter fullscreen modeExit fullscreen mode

Depois vamos usar umRouter para poder trazer as nossas rotas da API:

app.use('/api', apiRoutes)
Enter fullscreen modeExit fullscreen mode

E por fim vamos ouvir a porta 3000:

app.listen(3000, () => console.log('JWT example listening on port 3000!'))
Enter fullscreen modeExit fullscreen mode

Agora vamos criar um novo arquivo chamadohandlers.ts onde vamos criar toda a nossa lógica. Primeiro vamos importar as funções que vamos utilizar:

import { createHmac } from 'crypto'import {   NextFunction,   Request,   RequestHandler,   Response,   Router } from 'express'import jwt, { JwtPayload } from 'jsonwebtoken'import { User, users } from './users'
Enter fullscreen modeExit fullscreen mode

Então se você estiver usando TypeScript, vamos estender duas interfaces. A primeira vai ser a própriaResponse do Express para podermos adicionar a tipagem ao objetores.locals que é um objeto onde podemos incluir qualquer informação para passar para os próximos middlewares.

No nosso caso, vamos ter um objeto que vai conter o nosso usuário (que já está tipado no nosso "banco de dados") e o hash do nosso refresh token:

interface ExtendedResponse extends Response<any, { user: Partial<User>; refreshHash: string }> {}
Enter fullscreen modeExit fullscreen mode

Vamos criar um outro tipo que será o payload do nosso token, que é todo o objeto de usuário excluindo a senha e o username (que está na chavesub):

interface AccessTokenPayload extends JwtPayload, Omit<User, 'username' | 'password'> {}
Enter fullscreen modeExit fullscreen mode

Além disso vamos simular um banco de dados de sessões usando umMap, onde vamos armazenar os nossos tokens de refresh junto com a qual usuário eles pertencem:

const refreshTokenDB = new Map<string, string>()
Enter fullscreen modeExit fullscreen mode

Por fim vamos criar o nosso router para começar a criação das rotas:

const router = Router()
Enter fullscreen modeExit fullscreen mode

Login

Nossa API vai ter 3 rotas, a primeira será a rota de login, que será completamente aberta, a ideia dessa rota é que a gente receba o usuário e a senha no corpo da requisição, verifique se o usuário existe no banco de dados, se sim vamos gerar um token de acesso e um token de refresh para esse usuário, setar os cookies necessário e retornar o token de acesso direto no corpo da requisição para poder ser salvo pelo front-end.

router.post('/login', (req, res: ExtendedResponse) => {  const { username, password } = req.body  const user = users.find((user) => user.username === username && user.password === password)  if (!user) return res.status(401).send('Unauthorized')  const accessToken = createAccessToken(user)  const refreshToken = createRefreshToken(user)  setRefreshCookie(res, refreshToken)  res.json({ accessToken })})
Enter fullscreen modeExit fullscreen mode

Estou usando algumas funções auxiliares para podermos criar os tokens, vamos criá-las, começando pelas funções de criação de tokens.

A criação do token de acesso é bastante simples, vamos apenas assinar um novo jwt com todos os dados do usuário (exceto a senha) e fazer com que ele dure apenas 5 minutos:

const createAccessToken = (user: User) => {  return jwt.sign(    { sub: user.username, name: user.name, age: user.age, social: user.social },    process.env.ACCESS_TOKEN_SECRET!,    {      audience: 'urn:jwt:type:access',      issuer: 'urn:system:token-issuer:type:access',      expiresIn: `${process.env.ACCESS_TOKEN_DURATION_MINUTES}m`    }  )}
Enter fullscreen modeExit fullscreen mode

Perceba que estou usando oaudience eissuer com URNs, isso é uma boa prática para poder identificar quem está gerando o token e para quem ele se destina.

O token de refresh é um pouco mais complicado porque temos que adicionar ele ao nosso banco de dados e criar um timeout para expirar este token. Em bancos como o Redis, esse tipo de função (chamado de TTL) já é implementado por padrão.

Primeiro vamos criar um token assinado, osub do token será o username do usuário, o tipo de token é definido naaudience e ele dura 120 minutos:

const createRefreshToken = (user: User) => {  const token = jwt.sign({ sub: user.username }, process.env.ACCESS_TOKEN_SECRET!, {    audience: 'urn:jwt:type:refresh',    issuer: 'urn:system:token-issuer:type:refresh',    expiresIn: `${process.env.REFRESH_TOKEN_DURATION_MINUTES}m`  })}
Enter fullscreen modeExit fullscreen mode

Depois vamos criar um hash do nosso token para salvar no banco de dados, salvar a sessão e criar o timeout, depois disso vamos retornar o token:

const createRefreshToken = (user: User) => {  const token = jwt.sign({ sub: user.username }, process.env.ACCESS_TOKEN_SECRET!, {    audience: 'urn:jwt:type:refresh',    issuer: 'urn:system:token-issuer:type:refresh',    expiresIn: `${process.env.REFRESH_TOKEN_DURATION_MINUTES}m`  })  const tokenHash = createHmac('sha512', process.env.REFRESH_TOKEN_SECRET!).update(token).digest('hex')  refreshTokenDB.set(tokenHash, user.username)  setTimeout(() => {    refreshTokenDB.delete(tokenHash)    console.log(`Refresh token ${tokenHash} expired`)    console.table(refreshTokenDB.entries())  }, 5 * 60 * 1000)  console.table(refreshTokenDB.entries())  return token}
Enter fullscreen modeExit fullscreen mode

Outra função que estou usando bastante é um utilitário só para evitar a repetição de código para criar os cookies, ela só faz a criação do Cookie de forma segura:

const setRefreshCookie = (res: ExtendedResponse, token: string) => {  res.cookie('refresh-token', token, {    httpOnly: true,    secure: true,    sameSite: 'strict',    expires: new Date(Date.now() + Number(process.env.REFRESH_TOKEN_DURATION_MINUTES) * 60 * 1000)  })}
Enter fullscreen modeExit fullscreen mode

Para ficar mais fácil, vou descrever os objetos que a gente está usando para configurar os cookies:

  • httpOnly: Impede que o token seja acessível pelo JS
  • secure: Impede o uso do cookie fora de ambientes HTTPS
  • sameSite: Os cookies só podem ser usados no mesmo domínio
  • expires: Data de expiração do token

Refresh

A próxima rota que temos que criar é a rota de refresh, que vai receber o cookie com o refresh token e irá fazer a lógica para criar um novo token de acesso. Mas essa rota só pode ser acessível se o token de refresh estiver presente, então para isso vou criar um middleware de autenticação.

A ideia desse middleware é que a gente primeiro pegue o Cookie de dentro da request e verifique se ele existe, se não vamos retornar um erro:

const withRefreshAuth = (req: Request, res: ExtendedResponse, next: NextFunction) => {  const token = req.cookies['refresh-token']  if (!token) return res.status(401).send('Unauthorized')}
Enter fullscreen modeExit fullscreen mode

Depois disso vamos ver se o token é válido, para isso vamos usar a funçãojwt.verify que, ao mesmo tempo, valida e decodifica o token. Se o processo correu com sucesso devemos cair dentro do nossotry, se não, vamos retornar um erro de token inválido, perceba que eu estou passando a audience para o verificador para que ele também possa atestar a validade desse token:

const withRefreshAuth = (req: Request, res: ExtendedResponse, next: NextFunction) => {  const token = req.cookies['refresh-token']  if (!token) return res.status(401).send('Unauthorized')  try {    jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!, {      audience: 'urn:jwt:type:refresh'    })  } catch (error) {    return res.status(401).send('Unauthorized')  }}
Enter fullscreen modeExit fullscreen mode

Dentro do nosso bloco de sucesso, vamos então gerar um hash desse token e inclui-lo dentro deres.locals:

const withRefreshAuth = (req: Request, res: ExtendedResponse, next: NextFunction) => {  const token = req.cookies['refresh-token']  if (!token) return res.status(401).send('Unauthorized')  try {    jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!, {      audience: 'urn:jwt:type:refresh'    })    const tokenHash = createHmac('sha512', process.env.REFRESH_TOKEN_SECRET!).update(token).digest('hex')    res.locals.refreshHash = tokenHash    next()  } catch (error) {    return res.status(401).send('Unauthorized')  }}
Enter fullscreen modeExit fullscreen mode

Agora podemos criar a nossa rota com o middleware de autenticação:

router.post('/refresh', withRefreshAuth, (_, res) => {})
Enter fullscreen modeExit fullscreen mode

A ideia da rota é simples, vamos fazer o seguintes passos:

  1. Já validamos o token, então temos que verificar se ele existe no nosso banco de dados
  2. Se existir, vamos buscar o usuário com o qual ele está relacionado
  3. Geramos um novo token de acesso e um novo token de refresh
  4. Enviamos o token de refresh via cookie e retornamos o token de acesso

O código final fica assim:

router.post('/refresh', withRefreshAuth, (_, res) => {  const username = refreshTokenDB.get(res.locals.refreshHash)  const user = users.find((user) => user.username === username)  if (!username || !user) return res.status(403).send('Could not find user for this refresh token')  const accessToken = createAccessToken(user)  const refreshToken = createRefreshToken(user)  setRefreshCookie(res, refreshToken)  res.json({ accessToken })})
Enter fullscreen modeExit fullscreen mode

Uma rota protegida

Agora vamos criar a nossa rota de usuário, a rota que será protegida pelo nosso token JWT, ela vai retornar um dos nossos usuários do banco de dados, mas ela precisa estar protegida pelo token de acesso (não o de refresh), vamos fazer outro middleware para ele.

A ideia é mais simples ainda, só precisamos buscar o token de dentro do headerAuthorization e, se ele for válido, então podemos decodificar o mesmo e criar um objeto de usuário dentro deres.locals:

const withAccessAuth = (req: Request, res: ExtendedResponse, next: NextFunction) => {  const token = req.headers['authorization']?.split('Bearer ')[1]  if (!token) return res.status(401).send('Unauthorized')  try {    const { sub, name, age, social } = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!, {      audience: 'urn:jwt:type:access'    }) as AccessTokenPayload    res.locals.user = { username: sub!, name, age, social }    next()  } catch (error) {    return res.status(401).send('Unauthorized')  }}
Enter fullscreen modeExit fullscreen mode

Lembrando que para esse tipo de rota protegida, é padrão enviarmos um header do tipoAuthorization: Bearer <token>, por isso que estamos quebrando a string.

Já podemos criar a nossa rota protegida:

router.get('/users/:username', withAccessAuth, (req, res) => {  const user = users.find((user) => user.username === req.params.username)  if (!user) return res.status(404).send('User not found')  res.json(user)})
Enter fullscreen modeExit fullscreen mode

A ideia é simplesmente buscar um dado no banco e retornar esse dado, sempre validando se o token passado é válido.

Com isso terminamos a criação das nossas rotas, precisamos só exportar o nosso router:

export const apiRoutes = router
Enter fullscreen modeExit fullscreen mode

Front end

Agora que terminamos o back end da nossa aplicação, vamos começar a trabalhar no front end. Para facilitar, não usei nenhum tipo de framework, mas criei tudo do zero usando somente o bootstrap como CSS e um arquivo JS onde vamos colocar nossa lógica.

Para o arquivo HTML, não faz muito sentido explicar o que está acontecendo nele, até porque ele só tem a marcação da página, então vou apenas deixar o código que está no arquivoindex.html dentro de uma pastasrc/frontend por aqui para podermos olhar:

<!DOCTYPE html><html lang="en">  <head>    <meta charset="UTF-8" />    <meta http-equiv="X-UA-Compatible" content="IE=edge" />    <meta name="viewport" content="width=device-width, initial-scale=1.0" />    <!-- CSS only -->    <link      href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css"      rel="stylesheet"      integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx"      crossorigin="anonymous"    />    <title>Safe JWT</title>  </head>  <body>    <div>      <div>        <div>          <form>            <input              required              type="text"              name="username"                           autocomplete="username"              placeholder="Username"              value="lsantosdev"            />            <input              required              type="password"                           autocomplete="current-password"              name="password"              placeholder="Password"              value="123456"            />            <input type="submit" value="Login" />          </form>        </div>        <div>          <div role="alert">            <strong>Message:</strong> <span></span>          </div>        </div>      </div>      <div>        <div><strong>Raw access token</strong></div>        <div><code></code></div>      </div>      <div>        <div><strong>Decoded access token</strong></div>        <div><pre></pre></div>      </div>      <div>        <div><button type="button" disabled>Force token Refresh</button></div>      </div>      <div>        <div>          <form>            <input required type="text" name="username" autocomplete="username" placeholder="Search for username" />            <input type="submit" value="Search" />          </form>        </div>        <div>          <div role="alert">            <strong>Results:</strong>            <pre></pre>          </div>        </div>      </div>    </div>    <script src="index.js"></script>    <script      src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js"      integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"      crossorigin="anonymous"    ></script>  </body></html>
Enter fullscreen modeExit fullscreen mode

No final, esse HTML e CSS deve nos dar uma página assim (não sou tão bom com design):

Usando tokens JWT de forma segura

Dentro da mesma pastafrontend vamos criar um arquivoindex.js e fazer algumas preparações.

Primeiro, para podermos trabalhar mais facilmente, eu criei uma função para atualizar as mensagens de erro no app:

function updateMessage(message, selector = '.login-result') {  const infoBox = document.querySelector(selector)  infoBox.innerHTML = message}
Enter fullscreen modeExit fullscreen mode

Depois, vamos criar um ambiente seguro para guardar o nosso token de acesso. Sei que é tentador colocar essa informação no objetodocument, mas infelizmente esse é um objeto global que está acessível por qualquer script dentro da página, vamos tentar manter ele mais restrito.

Além disso, uma ideia bacana seria que, quando esse token fosse atualizado, a tela fosse automaticamente atualizada também, por isso vamos usar umProxy junto com umSymbol.

Vamos começar criando o Symbol:

const tokenSymbol = Symbol.for('accessToken')
Enter fullscreen modeExit fullscreen mode

Agora vamos criar um Proxy, o Proxy é um objeto que intercepta chamadas a outros objetos, como ele não trabalha sobre primitivos (como strings) vamos criar um objeto e usar o Symbol como chave para poder acessar o nosso access token:

const internalToken = new Proxy({ [tokenSymbol]: null }, {})
Enter fullscreen modeExit fullscreen mode

O valor inicial vai ser nulo, e o segundo objeto serão as configurações do nosso Proxy, o primeiro deles vai ser a configuração dogetter, que é quando alguém tenta trazer o valor desse objeto.

Como estou trabalhando com o objeto do token, não tenho como retornar o proxy em si, então vou usar a API deReflection para poder obter a propriedade que está sendo chamada, se for uma função, vamos retornar ela já com othis correto, se não, vamos retornar apenas o valor:

const internalToken = new Proxy({ [tokenSymbol]: null }, {    get(target, prop) {      const primitive = Reflect.get(target, tokenSymbol)      const value = primitive[prop]      return typeof value === 'function' ? value.bind(primitive) : value    },})
Enter fullscreen modeExit fullscreen mode

O próximo é osetter que é onde vamos fazer a mágica:

const internalToken = new Proxy(  { [tokenSymbol]: null },  {    get(target, prop) {      const primitive = Reflect.get(target, tokenSymbol)      const value = primitive[prop]      return typeof value === 'function' ? value.bind(primitive) : value    },    set(target, _, value) {      document.querySelector('#rawToken').innerHTML = value      const header = atob(value.split('.')[0])      const payload = JSON.parse(atob(value.split('.')[1]))      document.querySelector(        '#decodedToken'      ).innerHTML = `<strong>Header:</strong>${header}<br>---<br><strong>Payload</strong>: ${JSON.stringify(        payload,        null,        2      )}<br> <b>Expires at ${new Date(payload.exp * 1000).toLocaleTimeString()}</b>`      document.querySelector('#refreshAction').disabled = false      return Reflect.set(target, tokenSymbol, value)    }  })
Enter fullscreen modeExit fullscreen mode

Basicamente o que estamos fazendo é atualizando a nossa página com as informações que recebemos e, no final, estamos usando novamente a api de reflection, só que dessa vez para setar o valor do Symbol com o novo token.

Login

Vamos fazer a ação de login ao clicar no botão, para isso vamos adicionar um event listener que vai converter os dados do nosso formulário em umFormData e depois em JSON para podermos usar ofetch para mandar para a nossa rota:

document.querySelector('#loginForm').addEventListener('submit', async (e) => {  e.preventDefault()  updateMessage('Logging in...')  const form = new FormData(e.target)  const data = Object.fromEntries(form.entries())  const result = await fetch('/api/login', {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify(data)  })})
Enter fullscreen modeExit fullscreen mode

Depois de receber a resposta, vamos tratar o resultado e atualizar a variável do token com o nosso token de acesso:

document.querySelector('#loginForm').addEventListener('submit', async (e) => {  e.preventDefault()  updateMessage('Logging in...')  const form = new FormData(e.target)  const data = Object.fromEntries(form.entries())  const result = await fetch('/api/login', {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify(data)  })  updateMessage(result.ok ? 'Login successful' : `Login failed with ${result.status}`)  if (result.status === 200) {    const response = await result.json()    internalToken[tokenSymbol] = response.accessToken  }})
Enter fullscreen modeExit fullscreen mode

Silent Refresh

Uma outra tecnica que é bastante usada com tokens de refresh é osilent refresh, que é o ato de fazer o refresh do token de acesso antes de o mesmo estar vencido, então digamos que nosso token de acesso dure por 5 minutos, a cada 4 minutos e meio faremos uma requisição silenciosamente ao endpoint de/refresh e ele nos dará um novo token de acesso bem como um novo refresh token.

Para fazer isso é bastante simples, basta usarmos a nossa ação de fazer login para setar um intervalo que irá chamar uma função que irá fazer o refresh dos tokens. Vamos alterar o nosso código do login para incluir duas outras linhas:

document.querySelector('#loginForm').addEventListener('submit', async (e) => {  e.preventDefault()  updateMessage('Logging in...')  const form = new FormData(e.target)  const data = Object.fromEntries(form.entries())  const result = await fetch('/api/login', {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify(data)  })  updateMessage(result.ok ? 'Login successful' : `Login failed with ${result.status}`)  if (result.status === 200) {    const response = await result.json()    internalToken[tokenSymbol] = response.accessToken    setInterval(refreshToken, refreshIntervalMinutes)    updateMessage('Next refresh at ' + new Date(Date.now() + refreshIntervalMinutes).toLocaleTimeString())  }})
Enter fullscreen modeExit fullscreen mode

E vamos adicionar uma nova variável no topo do arquivo para dizer quando queremos fazer o refresh:

const refreshIntervalMinutes = 4.5 * 60 * 1000
Enter fullscreen modeExit fullscreen mode

E vamos, claro aproveitar para fazer a função de refresh que vai só fazer uma chamada com ofetch:

function refreshToken() {  updateMessage('Refreshing token...')  fetch('/api/refresh', {    method: 'POST'  })    .then((res) => res.json())    .then(({ accessToken }) => {      internalToken[tokenSymbol] = accessToken      updateMessage('Next refresh at ' + new Date(Date.now() + refreshIntervalMinutes).toLocaleTimeString())    })}
Enter fullscreen modeExit fullscreen mode

Buscando o usuário

Para fazer a chamada de busca ao usuário vamos usar a mesma técnica, enviando os dados do formulário para a nossa rota protegida com um cabeçalhoAuthorization:

document.querySelector('#userForm').addEventListener('submit', async (e) => {  e.preventDefault()  if (!internalToken) return updateMessage('Login first', '.user-result')  updateMessage('Searching user...', '.user-result')  const form = new FormData(e.target)  const data = Object.fromEntries(form.entries())  const result = await fetch(`/api/users/${data.username}`, {    headers: {      Authorization: `Bearer ${internalToken}`    }  })  updateMessage(result.ok ? 'User found' : `Search failed with ${result.status}`, '.user-result')  if (result.status === 200) {    const response = await result.json()    updateMessage(JSON.stringify(response, null, 2), '.user-result')  }})
Enter fullscreen modeExit fullscreen mode

Force refresh

O último passo é dar vida ao botão de forçar o refresh, que é basicamente chamar a nossa função de refresh que fizemos antes:

document.querySelector('#refreshAction').addEventListener('click', refreshToken)
Enter fullscreen modeExit fullscreen mode

Resultado

O resultado pode ser visto quando clicamos no botão de login:

Usando tokens JWT de forma segura

Vamos ter os dados do token de acesso disponíveis pelo JavaScript através da memória, mas não conseguimos o refresh token a não ser que abramos o DevTools na guiaapplication:

Usando tokens JWT de forma segura

Também é possível ver que fizemos a requisição que retornou o cookie para nós:

Usando tokens JWT de forma segura

Do lado do servidor, podemos ver que os tokens estão sendo setados e expirados conforme o tempo vai passando:

Usando tokens JWT de forma segura

Veja como ficou o resultado final animado:

Usando tokens JWT de forma segura

Conclusão

Essa saga ainda não terminou! Vamos explorar muito mais sobre como podemos armazenar e utilizar tokens de forma segura nos próximos artigos! Duas leituras que recomendo bastante sãoas do blog da Hasura sobre tokens eesse artigo super legal do Ryan Chenkie.

Não deixa de voltar para ver eassinar a newsletter para conteúdo novo e exclusivo!

Top comments(0)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Brazilian developer since 2011. Microsoft MVP, Docker Captain, and Google Dev Expert. OpenJS foundation member and Node.js contributor. Loves JavaScript and TypeScript.
  • Location
    Stockholm - Sweden
  • Education
    Bachelor of Science and Technology
  • Pronouns
    He/him
  • Work
    Founding Engineer @ Openvolt | OSS Contributor | Professional Trainer
  • Joined

More fromLucas Santos

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp