Trabalho Por Equipe DevBras

Capa do artigo: Convite de equipe que expira, vale uma vez e pode ser cancelado

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

  1. Gere um token aleatório com gerador criptográfico, com pelo menos 32 bytes.
  2. Guarde no banco apenas o hash SHA-256 do token, com e-mail, papel, quem criou e data de expiração.
  3. Envie o token no link. Ele não fica salvo em lugar nenhum do seu lado.
  4. 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.
  5. 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.
  6. Marque o convite como aceito e crie o vínculo com a equipe na mesma transação.
  7. 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?

Avalie este artigo

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