Dia 2 — Autenticação
Senha com hash e login
A senha não vai para o banco
Autenticação e autorização são perguntas diferentes, e a diferença é a que separa "quem é você" de "o que você pode". A autenticação é a pergunta feita no login, comparando usuário e senha para descobrir quem está chamando; a autorização é a pergunta feita a cada rota, depois que o nome já é conhecido: essa pessoa tem permissão para apagar este registro. Uma API que só responde à primeira pergunta tem autenticação sem autorização, e é exatamente o estado do DELETE medido no dia 1.
A tabela de usuário tem uma coluna password_hash e não tem coluna de senha. Não é estilo: é o que separa um vazamento de banco de uma lista de senhas que as pessoas reutilizam em todo lugar. O campo de senha nunca em texto vale para o banco, para o log e para o backup: a senha digitada existe em memória, no corpo da requisição, e em nenhum lugar depois disso.
Texto puro não serve. SELECT * FROM tb_usuario com a senha na coluna entrega a senha de todo mundo. Um hash não é reversível: não existe "desfazer" hash, existe refazer a conta com uma senha nova.
Hash puro não serve. hash de senha é feito para ser rápido, e isso vira dois problemas:
- quem rouba a tabela consegue testar milhões de senhas por segundo, porque a conta de quem tenta é barata;
- duas pessoas com a mesma senha recebem o mesmo hash, e a tabela passa a dizer quem usa senha repetida.
Sal: o tempero que falta
O sal é um valor aleatório gerado antes de hashear, misturado na conta e guardado junto do resultado. Ele resolve os dois problemas de uma vez. gerarHash sorteia 16 bytes novos a cada chamada, e por isso o sal é o primeiro segmento da string gravada — é o que permite comparar senha depois sem ter guardado nada em separado.
A comparação de senha nunca guarda o valor: o que vai para o banco é o sal e o hash. Quem autentica reenvia a senha, e conferirSenha lê o sal de dentro da string, refaz a mesma conta e compara com timingSafeEqual.
O exemplo grava duas contas com a mesma senha de exemplo e imprime a comparação real:
hash 1 == hash 2 ? false mesmo tamanho ? true
Mesmo tamanho porque o formato é sempre scrypt$<sal em hex>$<derivada em hex> — 104 caracteres nas duas. Conteúdo diferente porque o sal é sorteado a cada gerarHash.
O efeito colateral é o mais importante: um banco vazado não diz quem usa a mesma senha. Sem sal, as duas linhas seriam idênticas e a comparação seria uma linha de GROUP BY.
O hash deste exemplo muda a cada execução, e isso é esperado: o sal é sorteado de novo. Rodar o exemplo duas vezes dá linhas diferentes justamente nessa parte, e idênticas em todo o resto. É o mesmo caso da porta, que também muda a cada rodada.
As duas funções
Toda biblioteca de hash oferece o mesmo par. É esse contrato que o serviço vai chamar, e é ele que o login usa:
function gerarHash(senha) // senha -> string para gravar function conferirSenha(senha, hash) // senha, hash gravado -> true ou false
conferirSenha não recalcula do zero com um sal novo: ele lê o sal que está dentro da string gravada, refaz a mesma conta e compara. Por isso o sal precisa estar guardado — e por isso o hash gravado nunca pode ser "reorganizado" nem "encurtado" depois.
O exemplo usa scrypt de node:crypto, que já vem no Node e não precisa de npm install. bcryptjs é a alternativa mais comum em projeto com framework: a assinatura é a mesma, e trocar de biblioteca é trocar a implementação dessas duas funções — nenhuma outra linha muda.
As duas bibliotecas usam a mesma palavra para a mesma coisa: o salt é o sal, e a biblioteca só o chama assim porque o termo nasceu em inglês. Onde a documentação escrever salt, o código desta aula escreve sal no comentário e o valor em hexadecimal no meio da string — o mesmo mecanismo com o nome que a busca já conhece.
Comparar sem dar pista
Dois detalhes que separam login de verdade de login de brinquedo.
timingSafeEqual. O === de string comum sai do laço na primeira byte diferente: quem está atacando mede quanto tempo levou e descobre o prefixo do hash, um caractere por tentativa. crypto.timingSafeEqual percorre os dois inteiros e compara tudo.
if (calculada.length !== esperada.length) return false; return crypto.timingSafeEqual(calculada, esperada);
A linha do tamanho vem primeiro de propósito: timingSafeEqual lança se os buffers tiverem tamanhos diferentes, e um throw dentro do login vira 500 em vez de "senha errada".
A mesma mensagem nos dois casos. O exemplo imprime as três tentativas:
senha certa -> {"ok":true,"id":1} senha errada -> {"ok":false,"motivo":"usuario ou senha invalidos"} usuario inexistente -> {"ok":false,"motivo":"usuario ou senha invalidos"}
"Usuário inexistente" e "senha errada" devolvem a mesma frase. Dizer qual dos dois falhou entrega a lista de quem tem conta na API — basta tentar e ver o que muda.
O que o log mostra e o que ele esconde
A tabela é criada com UNIQUE em nm_email, então o e-mail duplicado falha no banco com ER_DUP_ENTRY e não no código. É o comportamento desejado: a regra que o banco garante sozinho não precisa ser reescrita em JavaScript.
O SELECT do exemplo imprime nm_email e o começo do hash, e o COUNT confirma com número que zero linhas têm a senha em texto.
No log do login entram id, e-mail e o resultado. Nunca entram a senha, o hash, o sal ou a string completa de password_hash. Um log de login com o hash inteiro é um log que vaza a lista de senhas, e o console.log que grava em arquivo é o mesmo console.log da tela.
password_hashnão é segredo de servidor e não precisa estar no.env— é conteúdo de negócio, gerado por usuário e guardado como qualquer outro dado. Segredo é a credencial do banco, que está no.env(dia 10) e nunca no repositório.
Migrar de
md5ousha1parascryptoubcryptmuda a coluna e invalida as senhas já gravadas: o usuário tem que redefinir a senha. É uma migração de dados, não de esquema, e odowndela não existe — a volta é o esquema antigo, não as senhas antigas.
Exemplo
'use strict'; // Exemplo da aula 1 do dia 2: senha com hash e login. // // Senha nunca e gravada em texto. O que vai para a coluna `password_hash` e um // derivado da senha mais um sal aleatorio, e a comparacao na hora do login // passa por `timingSafeEqual` — que compara em tempo constante, para o tempo // de resposta nao contar a quem esta tentando. // // O hash deste exemplo vem de `node:crypto` (`scrypt`), que ja vem no Node e // nao precisa de `npm install`. `bcryptjs` e a alternativa mais comum em // projeto com framework: a assinatura e a mesma, trocar e so trocar a // implementacao das duas funcoes. // // O hash e o sal sao os SEIS primeiros caracteres da string, porem os VALORES // aqui sao de exemplo e nao servem para nada: o material nao guarda segredo // de servidor, e o `.env` guarda a credencial do banco (veja o dia 10). const crypto = require('node:crypto'); const { createConnection } = require('mysql2/promise'); // ----------------------------------------------------------- funcoes de hash // As duas assinaturas que qualquer biblioteca de hash oferece. Quem chama o // login precisa delas e nao precisa saber qual e a implementacao. function gerarHash(senha) { return new Promise((resolve, reject) => { // 16 bytes de sal aleatorio por senha: duas contas com a mesma senha // recebem hashes diferentes, e o mapa nao diz quem usa a mesma senha. const sal = crypto.randomBytes(16); crypto.scrypt(senha, sal, 32, (erro, derivada) => { if (erro) return reject(erro); resolve('scrypt$' + sal.toString('hex') + '$' + derivada.toString('hex')); }); }); } async function conferirSenha(senha, hashGuardado) { const partes = String(hashGuardado).split('$'); if (partes.length !== 3 || partes[0] !== 'scrypt') return false; const sal = Buffer.from(partes[1], 'hex'); const esperada = Buffer.from(partes[2], 'hex'); const calculada = await new Promise((resolve, reject) => { crypto.scrypt(senha, sal, esperada.length, (erro, derivada) => (erro ? reject(erro) : resolve(derivada))); }); // timingSafeEqual lanca se os buffers tiverem tamanhos diferentes, e o // tamanho diferente ja e motivo suficiente para recusar a senha. if (calculada.length !== esperada.length) return false; return crypto.timingSafeEqual(calculada, esperada); } async function main() { const c = await createConnection({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, multipleStatements: true, }); // ------------------------------------------------------- tabela de usuarios await c.query(` CREATE TABLE IF NOT EXISTS tb_usuario ( id INT AUTO_INCREMENT PRIMARY KEY, nm_email VARCHAR(80) NOT NULL, password_hash VARCHAR(120) NOT NULL, dt_criacao DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_usuario_email (nm_email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await c.query('TRUNCATE TABLE tb_usuario'); // --------------------------------------------------------------- cadastro console.log('--- cadastro: a senha entra como hash ---'); // Senha de exemplo do material. Nao e senha de nada, e o valor que entra // no codigo de exemplo nao e segredo de servidor: segredo de servidor esta // no `.env` (dia 10) e nunca no arquivo. const SENHA_EXEMPLO = 'exemplo-do-material'; const hash1 = await gerarHash(SENHA_EXEMPLO); console.log('senha em texto : ' + SENHA_EXEMPLO); console.log('hash gerado : ' + hash1.slice(0, 26) + '...'); console.log('tamanho do hash : ' + hash1.length + ' caracteres'); console.log('formato : scrypt$<sal em hex>$<derivada em hex>'); console.log('este hash muda a cada execucao: o sal e sorteado de novo,'); console.log('e e por isso que rodar o exemplo duas vezes da linhas diferentes aqui'); const [r1] = await c.execute( 'INSERT INTO tb_usuario (nm_email, password_hash) VALUES (?, ?)', ['[email protected]', hash1]); console.log('usuario gravado : id ' + r1.insertId); // A segunda conta prova que o sal funciona: mesma senha, hash diferente. const hash2 = await gerarHash(SENHA_EXEMPLO); console.log('\nmesma senha, segunda conta:'); console.log('hash 1 == hash 2 ? ' + (hash1 === hash2)); console.log('mesmo tamanho ? ' + (hash1.length === hash2.length)); await c.execute('INSERT INTO tb_usuario (nm_email, password_hash) VALUES (?, ?)', ['[email protected]', hash2]); // O que o banco guarda de fato: nada em texto. const [linhas] = await c.query('SELECT nm_email, password_hash FROM tb_usuario ORDER BY id'); console.log('\n--- o que esta no banco ---'); for (const u of linhas) { console.log(u.nm_email.padEnd(18) + ' -> ' + u.password_hash.slice(0, 26) + '...'); } const [achouTexto] = await c.query( 'SELECT COUNT(*) AS n FROM tb_usuario WHERE password_hash = ?', [SENHA_EXEMPLO]); console.log('linhas com a senha em texto: ' + achouTexto[0].n); // ------------------------------------------------------------------ login console.log('\n--- login: tres tentativas ---'); const tentarLogin = async (email, senha) => { const [u] = await c.query( 'SELECT * FROM tb_usuario WHERE nm_email = ?', [email]); if (u.length === 0) { // Usuario inexistente devolve a MESMA mensagem de senha errada: dizer // que o e-mail nao existe entrega a lista de quem tem conta. return { ok: false, motivo: 'usuario ou senha invalidos' }; } const ok = await conferirSenha(senha, u[0].password_hash); return ok ? { ok: true, id: u[0].id } : { ok: false, motivo: 'usuario ou senha invalidos' }; }; let r = await tentarLogin('[email protected]', SENHA_EXEMPLO); console.log('senha certa -> ' + JSON.stringify(r)); r = await tentarLogin('[email protected]', 'senha-errada'); console.log('senha errada -> ' + JSON.stringify(r)); r = await tentarLogin('[email protected]', SENHA_EXEMPLO); console.log('usuario inexistente-> ' + JSON.stringify(r)); // ------------------------------------------------ o que NUNCA vai para o log console.log('\n--- o que o login devolve ao cliente ---'); console.log('sucesso: { id, email } — sem hash, sem sal'); console.log('falha: { erro } — sem dizer qual dos dois falhou'); await c.end(); } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
--- cadastro: a senha entra como hash --- senha em texto : exemplo-do-material hash gerado : scrypt$c6316d3f6515673fc7a... tamanho do hash : 104 caracteres formato : scrypt$<sal em hex>$<derivada em hex> este hash muda a cada execucao: o sal e sorteado de novo, e e por isso que rodar o exemplo duas vezes da linhas diferentes aqui usuario gravado : id 1 mesma senha, segunda conta: hash 1 == hash 2 ? false mesmo tamanho ? true --- o que esta no banco --- [email protected] -> scrypt$c6316d3f6515673fc7a... [email protected] -> scrypt$4d5725af895f3e37fe6... linhas com a senha em texto: 0 --- login: tres tentativas --- senha certa -> {"ok":true,"id":1} senha errada -> {"ok":false,"motivo":"usuario ou senha invalidos"} usuario inexistente-> {"ok":false,"motivo":"usuario ou senha invalidos"} --- o que o login devolve ao cliente --- sucesso: { id, email } — sem hash, sem sal falha: { erro } — sem dizer qual dos dois falhou
Token e proteção de rota
O token é texto, e isso não é defeito
A proteção da rota começa no header de auth: a requisição autenticada carrega um header Authorization: Bearer <token>, e é desse header que sai o token que a chamada seguinte leva. Sem ele, a rota protegida responde 401 antes de olhar qualquer outra coisa.
Um JWT tem três partes separadas por ponto, e o exemplo decodifica as duas primeiras na tela:
header decodificado : {"alg":"HS256","typ":"JWT"} payload decodificado: {"sub":"ana","iat":<segundos>,"exp":<segundos>} tamanho de cada parte: header 36, payload 63, assinatura 43
O token inteiro é base64url(header) . base64url(payload) . assinatura. Quem tem o token pode ler o payload — e isso é projeto, não falha. Se o token escondesse o id, o servidor não teria como saber quem está chamando sem consultar o banco a cada requisição.
O que protege é a terceira parte. Assinar token é calcular um HMAC-SHA256 de header.payload com uma chave secreta; verificar token é recalcular e comparar. A função assinarToken(payload, segredo) devolve as três partes prontas para ir no header, e verificarToken(token, segredo) faz o caminho inverso.
Proteger rota é a segunda metade: mesmo com token válido e bem assinado, a rota decide se aquele token abre aquela porta. É o que separa 401 de 403, e a tabela do fim desta aula diz quando cada um aparece.
base64urlnão ébase64: troca+por-e/por_, e tira o=. É o que permite o token viajar em header HTTP sem escaping. Um+virado em espaço no meio da URL já quebrou OAuth no mundo inteiro uma vez.
O segredo nunca está no arquivo
A chave vem de process.env.JWT_SECRET. O exemplo avisa o que fez quando a variável não existe:
chave de assinatura: JWT_SECRET ausente — sorteada nova nesta execucao, em memoria
Isso é o comportamento de um servidor sem chave configurada: todo token emitido antes deixa de valer, porque a assinatura não bate com a assinatura de antes. É preferível a uma chave que está escrita no arquivo e é lida por qualquer pessoa que chegue perto.
O .env guarda a chave, o .gitignore da raiz exclui o .env, e o exemplo só nomeia a variável. Nenhum valor de segredo entra no material.
jwt.verify lança, e isso é o contrato
verificarToken devolve o payload ou lança. Não devolve null, e essa é a decisão que importa: quem devolve null acaba Laplacando "assinatura inválida" e "token expirado" no mesmo if, e o log perde a causa.
O exp é em segundos desde 1970. Date.now() está em milissegundos. Dividir por 1000 é o detalhe que faz o token vencer na hora certa, e a expiração do token é conferida antes de qualquer decisão de rota:
if (payload.exp && payload.exp < Date.now() / 1000) { throw new Error('token expirado'); }
Verificar token vencido é uma das cinco respostas que o exemplo mede, e ela sai com 401 e a causa nomeada — token expirado. A expiração do token não depende de o cliente lembrar: é a verificação que compara a data, e a data está dentro do próprio token.
A comparação da assinatura usa timingSafeEqual, pelo mesmo motivo do login do dia anterior: !== sai no primeiro caractere diferente e entrega o prefixo a quem ataca.
Middleware: o porteiro que decide antes do handler
exigirLogin(segredo, rotasPublicas, proximo) devolve o handler já com a checagem na frente. O detalhe estrutural está no terceiro parâmetro:
function exigirLogin(segredo, rotasPublicas, proximo) { return async function auth(req, res) { /* ... */ }; }
http.createServer entrega apenas (req, res) ao handler. Um middleware que espera um terceiro argumento na chamada receberia o próprio handler como se fosse a requisição, e o primeiro req.url quebraria com TypeError. Por isso proximo é recebido na criação — que é o formato que todo framework de middleware usa.
A ordem dentro do handler é a da decisão, em três passos: rota pública passa direto; sem Bearer no header responde 401; token inválido responde 401 com a causa. Em nenhum dos três o proximo é chamado — quem não passa pelo porteiro não chega na rota.
O que o exemplo mede
Cinco formas de chegar na rota protegida, todas com status real:
sem token -> 401 {"erro":"token ausente"} com token valido -> 200 {"usuario":"ana","expira_em_segundos":3600,...} token sem as 3 partes -> 401 {"erro":"token malformado"} payload trocado p/ admin -> 401 {"erro":"assinatura invalida"} token expirado -> 401 {"erro":"token expirado"}
A quarta linha é o ataque que o header existe para impedir: o cliente reescreve o payload para {"sub":"admin"}, mantém a assinatura antiga e reenvia. A assinatura não bate, e o servidor responde assinatura invalida em vez de aceitar admin.
A rota pública responde 200 sem token — é a prova de que a proteção está no meio, e não em cada rota se lembrar de checar.
401 e 403 não são sinônimos
| Status | Significado | Quando |
|---|---|---|
401 | não sei quem você é | token ausente, malformado, adulterado ou vencido |
403 | sei quem você é, e você não pode | token válido, sem permissão |
O 401 convida a mandar a credencial de novo. O 403 não: mandar o mesmo token de novo vai dar 403 de novo, e a resposta certa é outra — pedir permissão, ou recusar.
Token JWT não dá direito de revogar antes do
exp. Isso é a propriedade que o torna barato (o servidor não consulta nada) e o custo (quem stole um token usa até vencer). Para revogar na hora, o caminho é manter uma lista de tokens revogados ou usar token de vida curta com refresh — e é decisão de projeto, não de biblioteca.
alg: "none"é o ataque clássico de JWT: um token sem assinatura que a biblioteca aceita se o algorithm confiado vier do próprio token. A defesa é nunca escolher o algoritmo pelo que está no header, e sim pela configuração do servidor.
Exemplo
'use strict'; // Exemplo da aula 2 do dia 2: token JWT e protecao de rota. // // O `jsonwebtoken` nao esta instalado neste material, entao o exemplo faz o // que a biblioteca faz por baixo dos panos: assina o token com `HMAC-SHA256` // de `node:crypto`. Sao tres passos e nenhuma dependencia nova — // // base64url(header) . base64url(payload) . assinatura // // A assinatura e o que impede que alguem troque o `sub` dentro do token. Sem // ela o token e so texto: o cliente edita o payload e o servidor obedece. // // A CHAVE vem do ambiente, `JWT_SECRET`, e nunca do arquivo. Quando a variavel // nao existe, o exemplo sorteia uma chave nova em memoria e avisa: e assim // que o leitor ve o que acontece em um servidor sem chave configurada — todo // token emitido antes deixa de valer, porque a assinatura nao bate mais. const http = require('node:http'); const crypto = require('node:crypto'); // ------------------------------------------------------- JWT em node:crypto // base64url: o mesmo base64 com '+' virado '-' e '/' virado '_', sem '='. // O JWT precisa disso porque o token viaja em header HTTP. const b64url = (buf) => Buffer.from(buf) .toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const b64urlDecodifica = (txt) => Buffer.from(txt.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); function assinarToken(payload, segredo) { const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const corpo = b64url(JSON.stringify(payload)); const dados = header + '.' + corpo; const assinatura = b64url( crypto.createHmac('sha256', segredo).update(dados).digest()); return dados + '.' + assinatura; } // `verificarToken` devolve o payload OU lanca. Devolver `null` esconderia a // diferenca entre token invalido e token expirado, e o chamador acabaria // Laplacando os dois em "nao autorizado" — e o log perde a causa. function verificarToken(token, segredo) { const partes = String(token).split('.'); if (partes.length !== 3) throw new Error('token malformado'); const [header, corpo, assinatura] = partes; const esperada = b64url( crypto.createHmac('sha256', segredo).update(header + '.' + corpo).digest()); // Comparacao em tempo constante: o `!==` comum sai no primeiro caractere // diferente e entrega o prefixo da assinatura a quem ataca. const a = Buffer.from(assinatura); const b = Buffer.from(esperada); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { throw new Error('assinatura invalida'); } const payload = JSON.parse(b64urlDecodifica(corpo).toString('utf8')); // `exp` e em SEGUNDOS desde 1970; `Date.now()` esta em milissegundos. // Dividir por 1000 e o detalhe que faz o token expirar na hora certa. if (payload.exp && payload.exp < Date.now() / 1000) { throw new Error('token expirado'); } return payload; } class ErroDeAuth extends Error { constructor(mensagem, status) { super(mensagem); this.name = 'ErroDeAuth'; this.status = status; } } function responder(res, status, corpo) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(corpo)); } // O middleware de autenticacao. `proximo` e RECEBIDO AQUI, na criação do // middleware, e nao na chamada: o `http.createServer` so entrega `(req, res)` // ao handler, entao um middleware que espera um terceiro argumento receberia o // handler como se fosse a requisicao — e o primeiro `req.url` quebraria. // // E o mesmo formato de todo framework que usa middleware: a funcao devolve o // handler ja com o middleware na frente. function exigirLogin(segredo, rotasPublicas, proximo) { return async function auth(req, res) { const chave = req.method + ' ' + req.url.split('?')[0]; if (rotasPublicas.includes(chave)) return proximo(req, res); const partes = (req.headers.authorization || '').split(' '); // `Bearer` e o esquema: a palavra, um espaco, e o token. Sem o prefixo o // header esta la e o middleware nao sabe o que fazer com ele. if (partes.length !== 2 || partes[0] !== 'Bearer') { return responder(res, 401, { erro: 'token ausente' }); } try { req.usuario = verificarToken(partes[1], segredo); return proximo(req, res); } catch (erro) { // 401 e "nao sei quem voce e". O 403 e o status da aula seguinte, para // quando o token e valido mas nao tem permissao. return responder(res, 401, { erro: erro.message }); } }; } async function main() { // ------------------------------------------------- a chave, e de onde vem const doAmbiente = Boolean(process.env.JWT_SECRET); const segredo = doAmbiente ? process.env.JWT_SECRET : crypto.randomBytes(32).toString('hex'); console.log('chave de assinatura: ' + (doAmbiente ? 'JWT_SECRET, lida de process.env' : 'JWT_SECRET ausente — sorteada nova nesta execucao, em memoria')); console.log('o valor da chave nao entra no arquivo do exemplo, nem no log'); const { createConnection } = require('mysql2/promise'); const c = await createConnection({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, multipleStatements: true, }); await c.query(` CREATE TABLE IF NOT EXISTS tb_sessao ( id INT AUTO_INCREMENT PRIMARY KEY, nm_usuario VARCHAR(60) NOT NULL, criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await c.query('TRUNCATE TABLE tb_sessao'); // ------------------------------------------------------ o token, desmontado const agora = Math.floor(Date.now() / 1000); const token = assinarToken({ sub: 'ana', iat: agora, exp: agora + 3600, }, segredo); const [h, p, a] = token.split('.'); console.log('\n--- anatomia do token ---'); console.log('partes separadas por ponto: ' + token.split('.').length + ' (header, payload, assinatura)'); console.log('header decodificado : ' + b64urlDecodifica(h).toString('utf8')); console.log('payload decodificado: ' + b64urlDecodifica(p).toString('utf8') .replace(/"(iat|exp)":\d+/g, '"$1":<segundos>')); console.log('tamanho de cada parte: header ' + h.length + ', payload ' + p.length + ', assinatura ' + a.length); console.log('a assinatura tem ' + a.length + ' caracteres porque e HMAC-SHA256 em base64url'); console.log('o token inteiro TEMPO e a decodificavel: a protecao esta na assinatura'); // ------------------------------------------------------------------ rotas const rotasPublicas = ['POST /login', 'GET /saude']; const rotas = { 'POST /login': async (req, res) => { const partes = []; for await (const p of req) partes.push(p); const dados = JSON.parse(Buffer.concat(partes).toString('utf8') || '{}'); const nome = dados.usuario || 'ana'; await c.execute('INSERT INTO tb_sessao (nm_usuario) VALUES (?)', [nome]); // A resposta carrega `emitido: true` e NAO o token. Devolver o token no // corpo e o certo; devolver um texto no lugar dele e um bug — o cliente // receberia uma credencial que nao autentica nada. Quem precisa do token // de verdade le o header `Authorization` da chamada seguinte, e ele // volta gerado em memoria, nunca impresso em log. responder(res, 200, { emitido: true, usuario: nome }); }, 'GET /saude': (_req, res) => responder(res, 200, { ok: true }), 'GET /perfil': async (req, res) => { const [linhas] = await c.query( 'SELECT * FROM tb_sessao ORDER BY id DESC LIMIT 1'); // O que se devolve: quem o token diz que e, e quanto tempo resta. O // `exp` vira uma quantidade de SEGUNDOS, e nao uma data: e o que o // cliente precisa, e nao depende do fuso de quem le. responder(res, 200, { usuario: req.usuario.sub, expira_em_segundos: req.usuario.exp - Math.floor(Date.now() / 1000), ultimaSessao: linhas[0] ? linhas[0].nm_usuario : null, }); }, 'DELETE /sessao': async (_req, res) => { const [r] = await c.execute('DELETE FROM tb_sessao'); responder(res, 200, { removidas: r.affectedRows }); }, }; // O middleware entra uma vez so, na criacao do handler. const tratar = exigirLogin(segredo, rotasPublicas, async (req, res) => { const rota = rotas[req.method + ' ' + req.url]; if (!rota) return responder(res, 404, { erro: 'rota nao encontrada' }); await rota(req, res); }); const servidor = http.createServer(tratar); await new Promise((r) => servidor.listen(0, '127.0.0.1', r)); const base = 'http://127.0.0.1:' + servidor.address().port; console.log('\nAPI com rota protegida em ' + base); console.log('a porta muda a cada execucao: e o listen(0) pedindo uma livre'); const pedir = (metodo, caminho, token, corpo) => fetch(base + caminho, { method: metodo, headers: { ...(corpo ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: 'Bearer ' + token } : {}), }, body: corpo ? JSON.stringify(corpo) : undefined, }); try { console.log('\n--- rota publica: passa sem token ---'); let r = await pedir('GET', '/saude'); console.log('GET /saude sem token -> ' + r.status + ' ' + await r.text()); console.log('\n--- rota protegida: cinco formas de chegar ---'); r = await pedir('GET', '/perfil'); console.log('sem token -> ' + r.status + ' ' + await r.text()); r = await pedir('GET', '/perfil', token); console.log('com token valido -> ' + r.status + ' ' + await r.text()); r = await pedir('GET', '/perfil', 'token-inventado'); console.log('token sem as 3 partes -> ' + r.status + ' ' + await r.text()); // Payload trocado, assinatura mantida: e o ataque que o header precisa // impedir. O cliente diz que e admin e o servidor nao acredita. const adulterado = h + '.' + b64url(JSON.stringify({ sub: 'admin', exp: agora + 3600, })) + '.' + a; r = await pedir('GET', '/perfil', adulterado); console.log('payload trocado p/ admin -> ' + r.status + ' ' + await r.text()); const expirado = assinarToken({ sub: 'ana', iat: 1000, exp: 2000 }, segredo); r = await pedir('GET', '/perfil', expirado); console.log('token expirado -> ' + r.status + ' ' + await r.text()); console.log('\n--- login e logout com token ---'); r = await pedir('POST', '/login', null, { usuario: 'ana' }); console.log('POST /login -> ' + r.status + ' ' + await r.text()); r = await pedir('DELETE', '/sessao', token); console.log('DELETE /sessao -> ' + r.status + ' ' + await r.text()); } finally { await new Promise((r) => servidor.close(r)); console.log('\nservidor encerrado com close().'); } console.log('401 = nao sei quem voce e (ausente, malformado, adulterado ou vencido)'); console.log('403 = eu sei quem voce e, e voce nao pode — o status do proximo bloco'); await c.end(); } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
chave de assinatura: JWT_SECRET ausente — sorteada nova nesta execucao, em memoria
o valor da chave nao entra no arquivo do exemplo, nem no log
--- anatomia do token ---
partes separadas por ponto: 3 (header, payload, assinatura)
header decodificado : {"alg":"HS256","typ":"JWT"}
payload decodificado: {"sub":"ana","iat":<segundos>,"exp":<segundos>}
tamanho de cada parte: header 36, payload 63, assinatura 43
a assinatura tem 43 caracteres porque e HMAC-SHA256 em base64url
o token inteiro TEMPO e a decodificavel: a protecao esta na assinatura
API com rota protegida em http://127.0.0.1:38295
a porta muda a cada execucao: e o listen(0) pedindo uma livre
--- rota publica: passa sem token ---
GET /saude sem token -> 200 {"ok":true}
--- rota protegida: cinco formas de chegar ---
sem token -> 401 {"erro":"token ausente"}
com token valido -> 200 {"usuario":"ana","expira_em_segundos":3600,"ultimaSessao":null}
token sem as 3 partes -> 401 {"erro":"token malformado"}
payload trocado p/ admin -> 401 {"erro":"assinatura invalida"}
token expirado -> 401 {"erro":"token expirado"}
--- login e logout com token ---
POST /login -> 200 {"emitido":true,"usuario":"ana"}
DELETE /sessao -> 200 {"removidas":1}
servidor encerrado com close().
401 = nao sei quem voce e (ausente, malformado, adulterado ou vencido)
403 = eu sei quem voce e, e voce nao pode — o status do proximo bloco