Convite de equipe que expira, vale uma vez e pode ser cancelado
Como implementar convites com token aleatório guardado como hash, prazo de validade, aceite único e revogação, com exemplo em Node.js e MySQL.
Março: a administradora do painel da agência convida marina@cliente.com.br, mas digita marina@clente.com.br. O e-mail volta, ela manda outro convite certo e esquece o primeiro. Em agosto, alguém que controla aquele domínio parecido abre um link que nunca expirou e entra no projeto com papel de administrador. Convite sem prazo é uma senha que circula por e-mail e ninguém lembra que existe.
A resposta curta: todo convite precisa ter prazo de validade (dias, não meses), valer para um único aceite, poder ser revogado antes de aceito e aparecer numa lista de pendentes para quem administra. O token vai no link, mas no banco você guarda só o hash dele. E o papel inicial é o mínimo, nunca "admin para facilitar".
Por que o link de convite é perigoso
O link de convite é uma credencial: quem tem o link entra. E ele viaja pelos piores lugares possíveis para uma credencial. Fica na caixa de entrada, é encaminhado para o colega "dar uma olhada", aparece em print de tela, vai parar no histórico do navegador e, às vezes, em logs de servidor que registram a URL completa.
Três propriedades transformam esse risco em algo administrável. O prazo limita por quanto tempo um link esquecido continua útil. O aceite único impede que o mesmo link sirva para duas contas. A revogação permite corrigir o erro de digitação na hora, em vez de torcer para ninguém clicar.
Como implementar, passo a passo
- Gere um token aleatório com gerador criptográfico, com pelo menos 32 bytes.
- Guarde no banco apenas o hash SHA-256 do token, com e-mail, papel, quem criou e data de expiração.
- Envie o token no link. Ele não fica salvo em lugar nenhum do seu lado.
- Ao abrir o link, peça login ou cadastro, e mostre um botão "Aceitar convite". O aceite acontece num POST, não na simples abertura do link.
- No aceite, confira em uma transação: o hash existe, não expirou, não foi aceito, não foi revogado e o e-mail confirmado da conta bate com o do convite.
- Marque o convite como aceito e crie o vínculo com a equipe na mesma transação.
- Mostre os convites pendentes na tela de administração, com botão para revogar.
Por que o aceite é um POST? Alguns filtros de e-mail corporativo abrem os links das mensagens para checar se são maliciosos. Se abrir o link já aceitasse o convite, o robô do filtro gastaria o seu aceite único.
A tabela
CREATE TABLE convites (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
equipe_id BIGINT UNSIGNED NOT NULL,
email VARCHAR(255) NOT NULL,
papel VARCHAR(30) NOT NULL DEFAULT 'leitor',
token_hash CHAR(64) NOT NULL,
criado_por BIGINT UNSIGNED NOT NULL,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expira_em DATETIME NOT NULL,
aceito_em DATETIME NULL,
aceito_por BIGINT UNSIGNED NULL,
revogado_em DATETIME NULL,
UNIQUE KEY uk_convites_token (token_hash),
KEY ix_convites_equipe (equipe_id, aceito_em, revogado_em)
);
Guardar o hash significa que um vazamento do banco, ou de um backup, não entrega links válidos. Como o token tem 32 bytes aleatórios, um SHA-256 simples basta; não é preciso um algoritmo lento como o de senhas, porque não há como adivinhar o token por tentativa.
Criar e aceitar em Node.js com mysql2
const crypto = require('node:crypto');
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
async function criarConvite(pool, { equipeId, email, papel, criadoPor }) {
const token = crypto.randomBytes(32).toString('base64url');
await pool.execute(
'INSERT INTO convites (equipe_id, email, papel, token_hash, criado_por, expira_em) ' +
'VALUES (?, ?, ?, ?, ?, NOW() + INTERVAL 7 DAY)',
[equipeId, email.trim().toLowerCase(), papel, hashToken(token), criadoPor]
);
return 'https://painel.exemplo.com.br/convite/' + token;
}
async function aceitarConvite(pool, token, usuario) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [linhas] = await conn.execute(
'SELECT id, equipe_id, email, papel FROM convites ' +
'WHERE token_hash = ? AND aceito_em IS NULL AND revogado_em IS NULL ' +
'AND expira_em > NOW() FOR UPDATE',
[hashToken(token)]
);
const convite = linhas[0];
if (!convite || convite.email !== usuario.emailConfirmado.toLowerCase()) {
await conn.rollback();
return { ok: false };
}
await conn.execute(
'UPDATE convites SET aceito_em = NOW(), aceito_por = ? WHERE id = ?',
[usuario.id, convite.id]
);
await conn.execute(
'INSERT INTO membros (equipe_id, usuario_id, papel) VALUES (?, ?, ?)',
[convite.equipe_id, usuario.id, convite.papel]
);
await conn.commit();
return { ok: true, equipeId: convite.equipe_id };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
Alguns detalhes que importam. O FOR UPDATE trava a linha durante a transação, então dois cliques simultâneos no mesmo link não criam dois membros. A expiração usa NOW() do banco na criação e na conferência, o que evita diferença de fuso entre aplicação e banco (o mesmo tipo de armadilha de fuso horário no PHP). E as consultas usam parâmetros, nunca concatenação de valores, como em Node.js e MySQL com parâmetro.
A resposta de falha é genérica de propósito. Para quem clicou, "convite inválido ou expirado; peça um novo a quem convidou" é suficiente. Detalhar se o token existe ou para qual e-mail foi emitido entrega informação a quem está testando links.
Revogar e listar pendentes
async function revogarConvite(pool, { conviteId, equipeId }) {
const [resultado] = await pool.execute(
'UPDATE convites SET revogado_em = NOW() ' +
'WHERE id = ? AND equipe_id = ? AND aceito_em IS NULL AND revogado_em IS NULL',
[conviteId, equipeId]
);
return resultado.affectedRows === 1;
}
async function listarPendentes(pool, equipeId) {
const [linhas] = await pool.execute(
'SELECT id, email, papel, criado_em, expira_em FROM convites ' +
'WHERE equipe_id = ? AND aceito_em IS NULL AND revogado_em IS NULL ' +
'AND expira_em > NOW() ORDER BY criado_em DESC',
[equipeId]
);
return linhas;
}
O filtro por equipe_id na revogação não é enfeite: a rota que chama essa função precisa conferir que quem pede é administrador daquela equipe, e o WHERE garante que um id de convite de outra equipe não seja afetado.
Reenviar sem reaproveitar o link
Quando o e-mail estava errado ou a pessoa perdeu a mensagem, não reencaminhe o mesmo link. Revogue o convite antigo e gere um novo. Assim, existe sempre no máximo um link válido por pessoa convidada, e a lista de pendentes conta a verdade.
Na página de aceite, evite carregar scripts e imagens de terceiros, porque a URL com o token pode vazar no cabeçalho de origem dessas requisições. Configurar o cabeçalho Referrer-Policy: no-referrer nessa rota reduz esse risco. Depois do aceite, redirecione para um caminho interno fixo, e não para um parâmetro da URL (veja por que URL aberta não é redirecionamento).
O papel do convite segue a mesma lógica do restante do sistema: começa no mínimo que a tarefa pede. O guia permissão começa no mínimo trata de como desenhar esses papéis.
Erros comuns
- Convite sem expiração. Um link esquecido em março continua abrindo a porta em agosto, para quem estiver com ele.
- Token guardado em texto puro. Um backup vazado vira uma lista de links válidos para entrar em equipes.
- Aceitar no GET. O robô do filtro de e-mail ou a pré-visualização do link consome o aceite antes da pessoa.
- Não conferir o e-mail da conta. Quem recebeu o link encaminhado entra com a própria conta, e a equipe passa a ter alguém que ninguém convidou.
- Papel de administrador no convite. Qualquer erro de destinatário vira acesso total ao projeto.
- Sem lista de pendentes. Ninguém sabe quais links estão circulando, então ninguém revoga.
Checklist
- O token é gerado com gerador criptográfico e tem pelo menos 32 bytes.
- O banco guarda só o hash do token.
- Todo convite tem data de expiração conferida no aceite.
- O aceite exige login, acontece em POST e marca o convite como usado na mesma transação.
- O e-mail confirmado da conta precisa bater com o do convite.
- Administradores veem os pendentes e conseguem revogar.
- Reenviar gera um link novo e invalida o anterior.
- O papel padrão do convite é o de menor permissão.
Abra a tela de equipe do seu sistema agora: dá para ver os convites que ainda não foram aceitos e cancelar um deles com um clique?
Referência: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html