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
- Gere um token aleatório forte. 32 bytes de um gerador criptográfico, codificados em base64url.
- Guarde só o hash. SHA-256 do token na tabela, com o usuário, a validade e um campo de uso.
- Defina uma validade curta. Algo entre 15 minutos e 1 hora é comum. Escolha um número e escreva no e-mail.
- Invalide pedidos anteriores. Um pedido novo cancela os links pendentes da mesma conta.
- 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.
- 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.
- Proteja a página. Cabeçalho
Referrer-Policy: no-referrer, nenhum script de terceiro e nenhum log com a query string. - 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-referrere 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?
Referência: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html