Privacidade Por Equipe DevBras

Capa do artigo: Link de redefinição de senha: prazo curto, uso único e token que não vaza

Link de redefinição de senha: prazo curto, uso único e token que não vaza

Implemente o link de "esqueci a senha" com token guardado só como hash, validade curta, consumo atômico, proteção contra o leitor de links e queda das sessões.

Uma funcionária sai da empresa e o e-mail dela é encaminhado para o gestor. Meses depois, o gestor procura uma mensagem antiga e encontra um "redefina sua senha" do sistema de RH. Clica por curiosidade. O link funciona e abre a tela de nova senha da conta dela. A resposta curta: o link de redefinição precisa expirar em pouco tempo, servir uma única vez e ser guardado no banco só como hash. Quando ele é usado, as sessões abertas da conta caem e a pessoa recebe um aviso de que a senha mudou.

Um link de redefinição é, na prática, uma senha temporária enviada por e-mail. Tudo o que você não faria com uma senha, como guardar em texto puro, deixar valer para sempre ou aceitar duas vezes, também não serve para ele.

Por que o link vira uma porta dos fundos

Caixas de entrada guardam tudo, são encaminhadas, sincronizadas em vários aparelhos e às vezes invadidas. O link que fica válido por dias ou semanas transforma qualquer acesso à caixa, inclusive antigo, em acesso à conta. E o token pode vazar por caminhos menos óbvios:

  • No banco. Se a tabela guarda o token puro, quem lê um backup tem links válidos para todas as redefinições pendentes.
  • No cabeçalho Referer. A página aberta pelo link carrega um script de análise ou uma fonte externa, e o navegador pode enviar a URL com o token para esse terceiro.
  • Nos logs. O token vai na URL, e a URL vai inteira para o log de acesso do servidor e do proxy.
  • No leitor automático de links. Ferramentas de segurança de e-mail corporativo abrem os links para verificar se são maliciosos. Se abrir o link já consome o token, a pessoa clica e recebe "link inválido".

Como implementar passo a passo

  1. Gere um token aleatório forte. 32 bytes de um gerador criptográfico, codificados em base64url.
  2. Guarde só o hash. SHA-256 do token na tabela, com o usuário, a validade e um campo de uso.
  3. Defina uma validade curta. Algo entre 15 minutos e 1 hora é comum. Escolha um número e escreva no e-mail.
  4. Invalide pedidos anteriores. Um pedido novo cancela os links pendentes da mesma conta.
  5. Abrir o link não consome nada. O GET só mostra o formulário de nova senha. O consumo acontece no POST, quando a pessoa envia a senha nova.
  6. Consuma de forma atômica. Trave a linha, confira validade e uso, troque a senha, marque o token como usado e derrube as sessões, tudo na mesma transação.
  7. Proteja a página. Cabeçalho Referrer-Policy: no-referrer, nenhum script de terceiro e nenhum log com a query string.
  8. Avise a pessoa. Depois da troca, envie um e-mail dizendo que a senha foi alterada e o que fazer se não foi ela.

A tabela

CREATE TABLE redefinicoes_senha (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  usuario_id  BIGINT UNSIGNED NOT NULL,
  token_hash  CHAR(64) NOT NULL UNIQUE,
  criado_em   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  expira_em   DATETIME NOT NULL,
  usado_em    DATETIME NULL,
  INDEX (usuario_id)
);

Criar o pedido e consumir o token em Node.js

const crypto = require('crypto');
const argon2 = require('argon2');
const pool = require('./db'); // mysql2/promise

const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');

async function criarRedefinicao(usuarioId) {
  const token = crypto.randomBytes(32).toString('base64url');
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();
    await conn.execute(
      'UPDATE redefinicoes_senha SET usado_em = NOW() WHERE usuario_id = ? AND usado_em IS NULL',
      [usuarioId]
    );
    await conn.execute(
      'INSERT INTO redefinicoes_senha (usuario_id, token_hash, expira_em) VALUES (?, ?, NOW() + INTERVAL 30 MINUTE)',
      [usuarioId, sha256(token)]
    );
    await conn.commit();
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    conn.release();
  }
  return 'https://rh.exemplo.com.br/redefinir?t=' + token;
}

async function consumirRedefinicao(token, novaSenha) {
  const novoHash = await argon2.hash(novaSenha);
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();
    const [linhas] = await conn.execute(
      'SELECT id, usuario_id FROM redefinicoes_senha WHERE token_hash = ? AND usado_em IS NULL AND expira_em > NOW() FOR UPDATE',
      [sha256(String(token))]
    );
    const pedido = linhas[0];
    if (!pedido) {
      await conn.rollback();
      return null;
    }
    await conn.execute('UPDATE usuarios SET senha_hash = ? WHERE id = ?', [novoHash, pedido.usuario_id]);
    await conn.execute('UPDATE redefinicoes_senha SET usado_em = NOW() WHERE id = ?', [pedido.id]);
    await conn.execute(
      'UPDATE sessoes SET revogada_em = NOW() WHERE usuario_id = ? AND revogada_em IS NULL',
      [pedido.usuario_id]
    );
    await conn.commit();
    return pedido.usuario_id;
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    conn.release();
  }
}

O FOR UPDATE trava a linha do pedido até o commit. Se dois envios do formulário chegarem ao mesmo tempo, o segundo espera o primeiro terminar e então não encontra mais o token disponível. Sem a trava, os dois poderiam passar pela checagem antes de qualquer um marcar o uso. O hash da senha nova é calculado antes de abrir a transação, para a trava não ficar presa enquanto o Argon2 trabalha.

A tabela sessoes é a mesma do post sair de todos os aparelhos. Redefinir a senha derruba todas, porque quem redefine pode estar justamente expulsando um invasor. Valide a nova senha com a sua política antes de chamar consumirRedefinicao, para que um erro de validação não queime o token: quem recebe "senha curta demais" precisa poder tentar de novo com o mesmo link.

A página que o link abre

O GET de /redefinir confere se o token existe e está válido e, se estiver, mostra o formulário com o token num campo oculto. Responda com Referrer-Policy: no-referrer e sem nenhum script ou fonte externa. Se o token for inválido, mostre uma frase neutra e um botão para pedir outro link. No servidor web, evite que a query string dessa rota vá para o log de acesso, ou pelo menos limite quem lê esse log.

O e-mail em si

O texto diz quanto tempo o link vale, que ele serve uma vez e que, se a pessoa não pediu, basta ignorar. Não coloque a senha nova no e-mail, nem a atual. Depois da troca, um segundo e-mail confirma a alteração. O pedido de redefinição também precisa de limite para não virar ferramenta de spam, como explica limite no "esqueci a senha", e a tela que recebe o pedido não pode confirmar se o e-mail tem conta, como mostra login que não entrega a lista de clientes.

Erros comuns

  • Guardar o token puro no banco. Um backup lido por quem não devia vira um molho de chaves para as contas com pedido pendente.
  • Consumir o token no GET. O leitor de links do e-mail corporativo usa o link antes da pessoa, e ela recebe "link expirado" na primeira tentativa.
  • Validade de dias. O link antigo esquecido numa caixa encaminhada continua abrindo a conta.
  • Checar e marcar o uso em passos separados, sem trava. Dois envios simultâneos trocam a senha duas vezes com o mesmo token.
  • Não derrubar as sessões. O invasor continua logado depois de a vítima redefinir a senha.
  • Script de análise na página de redefinição. A URL com o token pode ir para um terceiro no cabeçalho Referer.

Checklist

  • O token tem 32 bytes aleatórios e o banco guarda só o SHA-256.
  • O link expira em no máximo uma hora e o e-mail diz o prazo.
  • Um pedido novo invalida os anteriores da mesma conta.
  • Abrir o link não consome o token; só o envio do formulário consome.
  • O consumo trava a linha e marca o uso na mesma transação da troca.
  • Redefinir a senha revoga todas as sessões da conta.
  • A página do link usa Referrer-Policy: no-referrer e não carrega terceiros.
  • A pessoa recebe um e-mail confirmando a troca.

Pegue um e-mail de redefinição antigo do seu sistema e clique no link duas vezes: alguma delas ainda deixa trocar a senha?

Avalie este artigo

Referência: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html