DevGuideTK
← Trilha Backend

Autenticação em múltiplos fatores

TOTP, chaves de acesso e SMS, e o que cada fator protege de fato.

O TOTP desta página foi executado nas cinco linguagens e conferido com os vetores de teste do RFC 6238 e com a biblioteca pyotp. O fluxo de chave de acesso foi testado num Chrome real, com um autenticador virtual.

TOTP, em cada linguagem

Cada versão tem duas funções: totp, que calcula o código de um passo, e verificar, que aceita a janela de um passo para cada lado e recusa códigos de passos já usados. Só a biblioteca padrão.

import crypto from "node:crypto";

const PASSO_S = 30; // cada código vale 30 segundos
const JANELA = 1; // aceita um passo antes e um depois, para relógios fora de hora

export function totp(segredo: Buffer, passo: number, digitos = 6): string {
  const contador = Buffer.alloc(8);
  contador.writeBigUInt64BE(BigInt(passo));
  const mac = crypto.createHmac("sha1", segredo).update(contador).digest();
  const deslocamento = mac[mac.length - 1] & 0x0f;
  const numero = mac.readUInt32BE(deslocamento) & 0x7fffffff;
  return String(numero % 10 ** digitos).padStart(digitos, "0");
}

/** Devolve o passo aceito, ou null. Guarde o passo para recusar o mesmo código de novo. */
export function verificar(segredo: Buffer, codigo: string, ultimoPasso: number,
                          agora = Date.now() / 1000): number | null {
  const atual = Math.floor(agora / PASSO_S);
  for (let passo = atual - JANELA; passo <= atual + JANELA; passo++) {
    const esperado = Buffer.from(totp(segredo, passo));
    const recebido = Buffer.from(codigo);
    if (passo > ultimoPasso && recebido.length === esperado.length
        && crypto.timingSafeEqual(recebido, esperado)) return passo;
  }
  return null;
}

As funções recebem o segredo em bytes. O segredo do QR code vem em base32: decodifique com base64.b32decode no Python, base32.StdEncoding no Go, ou uma biblioteca nas outras linguagens.

O resultado, nas cinco linguagens

Com o segredo dos vetores de teste do RFC 6238 (12345678901234567890), em 8 dígitos, as cinco versões produziram os códigos da tabela do RFC para SHA-1:

Momento (segundos desde 1970)Código esperado pelo RFCNas cinco
599428708294287082
11111111090708180407081804
11111111111405047114050471
12345678908900592489005924
20000000006927903769279037
200000000006535313065353130

Depois, a verificação com o relógio parado em 1111111111, na ordem:

Código enviadoEsperadoNas cinco
Do passo anterior, com o relógio do celular atrasadoaceitoaceito
Do passo atualaceitoaceito
O mesmo do passo atual, de novorecusadorecusado
O do passo anterior, de novorecusadorecusado
De dois passos atrás, num usuário novorecusadorecusado
Do passo seguinte, num usuário novoaceitoaceito
000000recusadorecusado

Por fim, um segredo gerado pela pyotp em base32, decodificado e passado à versão em Python, produziu o mesmo código que a pyotp: a implementação é compatível com os aplicativos autenticadores.

O que muda de uma linguagem para outra

HMAC-SHA1Inteiro de 8 bytesComparação em tempo constante
TypeScriptcrypto.createHmac("sha1")writeBigUInt64BEcrypto.timingSafeEqual
Pythonhmac.new(..., hashlib.sha1)struct.pack(">Q")hmac.compare_digest
Gohmac.New(sha1.New)binary.BigEndian.PutUint64subtle.ConstantTimeCompare
JavaMac.getInstance("HmacSHA1")ByteBuffer.putLongMessageDigest.isEqual
C#HMACSHA1.HashDataBinaryPrimitives.WriteInt64BigEndianCryptographicOperations.FixedTimeEquals

O SHA-1 é fraco para assinaturas, mas não neste uso: o TOTP depende do HMAC, que continua seguro com SHA-1, e é o algoritmo que todos os aplicativos autenticadores aceitam.

A rota que confere o código

As funções acima são a parte fácil. A rota precisa de mais três cuidados:

  1. Limite de tentativas por conta, antes de conferir: no teste do nível Conceito, sem limite, o código caiu em 3,6 a 81 segundos.
  2. Guardar o passo aceito no usuário, na mesma transação do login, para dois pedidos simultâneos com o mesmo código não passarem os dois.
  3. Ler o segredo criptografado e decifrá-lo só na memória.

Com o limite de requisições do tópico de limite de requisições, um esboço:

app.post("/login/mfa", async (req, res) => {
  const usuario = await usuarioDoLoginPendente(req); // passou pela senha, falta o segundo fator
  if (!(await limite.permitir(`mfa:${usuario.id}`, 5, 15 * 60))) return res.status(429).end();

  const segredo = decifrar(usuario.totpSegredoCifrado);
  const passo = verificar(segredo, req.body.codigo, usuario.totpUltimoPasso);
  if (passo === null) return res.status(401).end();

  // Só grava se ninguém usou este passo antes (pedidos simultâneos)
  const gravou = await db.query(
    "UPDATE usuarios SET totp_ultimo_passo = $2 WHERE id = $1 AND totp_ultimo_passo < $2",
    [usuario.id, passo],
  );
  if (gravou.rowCount === 0) return res.status(401).end();
  await iniciarSessao(res, usuario);
  res.status(204).end();
});

As funções usuarioDoLoginPendente, limite, decifrar e iniciarSessao vêm dos tópicos de autenticação e de limite de requisições. Este trecho é um esboço e não foi executado como está.

Códigos de recuperação

import crypto from "node:crypto";

// 10 códigos de 10 caracteres, sem letras que se confundem (0/O, 1/I/L)
const ALFABETO = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";

export function gerarCodigos(quantidade = 10): string[] {
  return Array.from({ length: quantidade }, () =>
    Array.from({ length: 10 }, () => ALFABETO[crypto.randomInt(ALFABETO.length)]).join(""));
}

// Guarde só o hash; os códigos aparecem para o usuário uma única vez
export const hashDoCodigo = (codigo: string) =>
  crypto.createHash("sha256").update(codigo.toUpperCase().replace(/[^0-9A-Z]/g, "")).digest("hex");

No teste, o primeiro uso de um código foi aceito e o segundo, recusado. O mesmo código digitado em minúsculas e com um hífen no meio também foi aceito, porque a função normaliza antes do hash. Um código inventado foi recusado.

Chave de acesso com WebAuthn

O teste usou a biblioteca SimpleWebAuthn (versão 14) no servidor e no navegador. O fluxo tem duas idas e voltas, uma para o cadastro e outra para o login:

import {
  generateRegistrationOptions, verifyRegistrationResponse,
  generateAuthenticationOptions, verifyAuthenticationResponse,
} from "@simplewebauthn/server";

const RP_ID = "loja.exemplo.com";               // o domínio ao qual a chave fica presa
const ORIGEM = "https://loja.exemplo.com";

// Cadastro, passo 1: o servidor gera as opções e guarda o desafio na sessão
const opcoes = await generateRegistrationOptions({
  rpName: "Loja", rpID: RP_ID, userName: usuario.email,
  authenticatorSelection: { residentKey: "required", userVerification: "required" },
});
sessao.desafio = opcoes.challenge;

// Cadastro, passo 2: o navegador devolve a resposta; o servidor confere e guarda a chave pública
const cadastro = await verifyRegistrationResponse({
  response: req.body, expectedChallenge: sessao.desafio,
  expectedOrigin: ORIGEM, expectedRPID: RP_ID,
});
if (cadastro.verified) await salvarCredencial(usuario.id, cadastro.registrationInfo.credential);

// Login: o mesmo par de passos, com a credencial guardada
const login = await verifyAuthenticationResponse({
  response: req.body, expectedChallenge: sessao.desafio,
  expectedOrigin: ORIGEM, expectedRPID: RP_ID,
  credential: await credencialDoUsuario(req.body.id),
});

No navegador, startRegistration e startAuthentication, da @simplewebauthn/browser, recebem as opções do servidor e chamam o autenticador do dispositivo.

O teste no Chrome confirmou o cadastro e o login em loja.test. Na mesma página servida em 1oja.test, o navegador recusou as duas tentativas de assinatura, como mostra o nível Conceito. A recusa vem do navegador, antes de qualquer código do servidor: é por isso que a chave de acesso resiste a sites falsos.

O trecho acima resume o servidor testado. Num sistema real, o desafio fica na sessão do usuário, e não numa variável, e cada usuário pode ter várias credenciais.

Lista de revisão

TOTP

  • Há limite de tentativas por conta na conferência do código?
  • O último passo aceito é guardado, e o mesmo código é recusado na segunda vez?
  • A janela é de no máximo um passo para cada lado?
  • O segredo é guardado criptografado, e a comparação é em tempo constante?
  • O cadastro só é concluído depois que o usuário digita um código válido?

Chaves de acesso

  • O RP ID e a origem esperada estão fixos no servidor, e não vêm do pedido?
  • O desafio é aleatório, de uso único e guardado na sessão?
  • O usuário pode cadastrar mais de uma chave?

Recuperação

  • Os códigos de recuperação são guardados com hash e valem uma vez só?
  • Remover ou trocar um fator exige o segundo fator, ou um processo tão rigoroso quanto?
  • O usuário é avisado por todos os canais quando um fator muda?

Sessão

  • Ações sensíveis pedem o segundo fator de novo?
  • Trocar a senha ou um fator encerra as outras sessões?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Implementar TOTP

    Quero implementar o segundo fator com TOTP na minha aplicação.
    
    Pergunte qual linguagem, framework e banco eu uso.
    
    Vamos em etapas:
    1. Gerar o segredo, guardá-lo criptografado e mostrar o QR code.
    2. Concluir o cadastro só depois de um código válido.
    3. A rota de login que confere o código, com limite de tentativas por conta e uso único de cada código.
    4. Os códigos de recuperação, guardados com hash.
    5. Testes com os vetores do RFC 6238, com código repetido, com código fora da janela e com tentativas em excesso.
    
    Em cada etapa, me passe os comandos para testar.
  • Implementar chaves de acesso

    Quero implementar chaves de acesso (WebAuthn) na minha aplicação.
    
    Pergunte qual linguagem e framework eu uso e se as chaves vão substituir a senha ou ser um segundo fator.
    
    Explique, com código, usando uma biblioteca madura da minha linguagem:
    1. O cadastro: opções, desafio guardado na sessão, conferência e armazenamento da chave pública.
    2. O login, com a conferência da origem, do RP ID e do desafio.
    3. Várias chaves por usuário e a tela para gerenciá-las.
    4. Como testar com o autenticador virtual do Chrome.
    5. Como testar que um domínio diferente não consegue usar a chave.
  • Revisar uma implementação de MFA

    Vou colar abaixo o código do segundo fator de uma aplicação: cadastro, login e recuperação. Revise para produção.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho:
    1. Há limite de tentativas por conta na conferência do código?
    2. O mesmo código TOTP é recusado na segunda vez?
    3. A janela de tolerância é pequena?
    4. O segredo TOTP é guardado criptografado, e os códigos de recuperação com hash?
    5. A comparação é em tempo constante?
    6. Trocar e-mail, senha ou fatores pede o segundo fator de novo?
    7. Com WebAuthn: origem, RP ID e desafio são conferidos pelo servidor?
    
    Para cada problema, descreva como explorá-lo e mostre a correção.
    
    Código:
    [cole aqui]