2FA com TOTP em Node.js: Google Authenticator

Autenticação de dois fatores (2FA) com TOTP (Time-based One-Time Password) adiciona uma segunda camada de segurança: mesmo que a senha vaze, o atacante precisa também do dispositivo físico do usuário. O Google Authenticator e Authy são os apps mais usados, qualquer um compatível com RFC 6238 funciona.

Configurar TOTP com otpauth

Gerar segredo e QR Code para o Google Authenticator:

typescript
// npm install otpauth qrcode
// npm install -D @types/qrcode

import * as OTPAuth from 'otpauth'
import QRCode from 'qrcode'

// Gerar novo segredo TOTP para um usuário:
export function gerarSegredoTOTP(email: string): {
  segredo: string
  uri: string
} {
  const totp = new OTPAuth.TOTP({
    issuer: 'MinhaApp',
    label: email,
    algorithm: 'SHA1',
    digits: 6,
    period: 30,  // código muda a cada 30 segundos
    secret: OTPAuth.Secret.fromRandom(20),  // 160 bits aleatórios
  })

  return {
    segredo: totp.secret.base32,  // armazenar no banco (criptografado!)
    uri: totp.toString(),          // otpauth:// URI para o QR Code
  }
}

// Gerar QR Code como Data URL (para exibir no frontend):
export async function gerarQRCode(uri: string): Promise<string> {
  return QRCode.toDataURL(uri, {
    errorCorrectionLevel: 'M',
    width: 256,
    margin: 2,
  })
}

// Validar código TOTP digitado pelo usuário:
export function validarTOTP(segredo: string, codigo: string): boolean {
  const totp = new OTPAuth.TOTP({
    algorithm: 'SHA1',
    digits: 6,
    period: 30,
    secret: OTPAuth.Secret.fromBase32(segredo),
  })

  // delta: ±1 aceita código do período anterior/seguinte (compensar dessincronização)
  const delta = totp.validate({ token: codigo, window: 1 })
  return delta !== null  // null = inválido; número = posição na janela
}

Fluxo de ativação do 2FA

Endpoints para ativar e confirmar o 2FA:

typescript
// Passo 1: gerar segredo e exibir QR Code:
app.post('/api/auth/2fa/setup', autenticar, async (req, res) => {
  const usuario = await buscarUsuario(req.usuario!.userId)

  if (usuario.totp_ativo) {
    return res.status(400).json({ error: '2FA já está ativo' })
  }

  const { segredo, uri } = gerarSegredoTOTP(usuario.email)
  const qrCodeUrl = await gerarQRCode(uri)

  // Armazenar segredo TEMPORARIAMENTE (não ativo ainda):
  // Usar encryption para o segredo no banco:
  const segredoCriptografado = criptografar(segredo)
  await db.query(
    'UPDATE usuarios SET totp_segredo_pendente = $1 WHERE id = $2',
    [segredoCriptografado, usuario.id]
  )

  // Retornar QR Code + segredo manual (para usuários sem câmera):
  res.json({
    qrCode: qrCodeUrl,
    segredoManual: segredo,  // exibir como: JBSW Y3DP EHPK 3PXP
    instrucoes: 'Escaneie o QR Code no Google Authenticator e confirme com um código',
  })
})

// Passo 2: confirmar com código TOTP (ativar o 2FA):
app.post('/api/auth/2fa/confirmar', autenticar, async (req, res) => {
  const { codigo } = req.body as { codigo: string }
  const usuario = await buscarUsuario(req.usuario!.userId)

  if (!usuario.totp_segredo_pendente) {
    return res.status(400).json({ error: 'Inicie o setup primeiro' })
  }

  const segredo = descriptografar(usuario.totp_segredo_pendente)

  if (!validarTOTP(segredo, codigo)) {
    return res.status(400).json({ error: 'Código inválido — verifique o horário do dispositivo' })
  }

  // Gerar backup codes (usar uma vez se perder o dispositivo):
  const backupCodes = gerarBackupCodes()
  const backupCodesHash = backupCodes.map(hashApiKey)  // mesmo hash usado para API Keys

  // Ativar 2FA:
  await db.query(
    `UPDATE usuarios
     SET totp_ativo = true,
         totp_segredo = $1,
         totp_segredo_pendente = NULL,
         backup_codes = $2
     WHERE id = $3`,
    [usuario.totp_segredo_pendente, backupCodesHash, usuario.id]
  )

  res.json({
    message: '2FA ativado com sucesso',
    backupCodes,  // exibir UMA VEZ — armazenar com segurança
    aviso: 'Salve estes códigos de backup em local seguro',
  })
})

Integrar 2FA no fluxo de login

Login em dois passos com JWT temporário:

typescript
// Login com 2FA — dois passos:
app.post('/api/auth/login', async (req, res) => {
  const { email, senha } = req.body
  const usuario = await autenticarSenha(email, senha)
  if (!usuario) return res.status(401).json({ error: 'Credenciais inválidas' })

  // Se 2FA não está ativo: login completo normal:
  if (!usuario.totp_ativo) {
    const accessToken = await gerarAccessToken({ userId: usuario.id, email, role: usuario.role })
    return res.json({ accessToken, requer2fa: false })
  }

  // Se 2FA está ativo: retornar token temporário de 2FA:
  const token2fa = await new SignJWT({ userId: usuario.id, tipo: '2fa_pendente' })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('5m')  // 5 minutos para completar o 2FA
    .sign(ACCESS_SECRET)

  res.json({
    requer2fa: true,
    token2fa,
    // Nunca retornar o accessToken real aqui
  })
})

// Segundo passo: validar código TOTP:
app.post('/api/auth/2fa/verificar', async (req, res) => {
  const { token2fa, codigo } = req.body

  // Verificar token temporário:
  let payload: JWTPayload
  try {
    const result = await jwtVerify(token2fa, ACCESS_SECRET)
    payload = result.payload
    if (payload['tipo'] !== '2fa_pendente') throw new Error('Token inválido')
  } catch {
    return res.status(401).json({ error: 'Token de 2FA inválido ou expirado' })
  }

  const usuario = await buscarUsuario(payload.userId as string)
  const segredo = descriptografar(usuario.totp_segredo)

  // Tentar código TOTP:
  if (validarTOTP(segredo, codigo)) {
    const accessToken = await gerarAccessToken({ userId: usuario.id, email: usuario.email, role: usuario.role })
    return res.json({ accessToken })
  }

  // Tentar backup code:
  const codigoHash = hashApiKey(codigo)
  const backupIndex = usuario.backup_codes.indexOf(codigoHash)
  if (backupIndex !== -1) {
    // Consumir backup code (uso único):
    const novosBackupCodes = usuario.backup_codes.filter((_: string, i: number) => i !== backupIndex)
    await db.query('UPDATE usuarios SET backup_codes = $1 WHERE id = $2', [novosBackupCodes, usuario.id])
    const accessToken = await gerarAccessToken({ userId: usuario.id, email: usuario.email, role: usuario.role })
    return res.json({ accessToken, aviso: 'Backup code usado — restam ' + novosBackupCodes.length })
  }

  res.status(401).json({ error: 'Código inválido' })
})

Gerar e gerenciar backup codes

Códigos de emergência para recuperação de acesso:

typescript
import crypto from 'crypto'

// Gerar 10 backup codes de 8 caracteres alfanuméricos:
export function gerarBackupCodes(quantidade = 10): string[] {
  return Array.from({ length: quantidade }, () =>
    crypto.randomBytes(4).toString('hex').toUpperCase()
    // Resultado: "A3F9B2C1" — exibir em pares para legibilidade: "A3F9 B2C1"
  )
}

// Desabilitar 2FA (exige senha + código TOTP ou backup code):
app.post('/api/auth/2fa/desabilitar', autenticar, async (req, res) => {
  const { senha, codigo } = req.body
  const usuario = await buscarUsuario(req.usuario!.userId)

  // Verificar senha antes de desabilitar:
  if (!await verificarSenha(senha, usuario.senha_hash)) {
    return res.status(401).json({ error: 'Senha incorreta' })
  }

  const segredo = descriptografar(usuario.totp_segredo)
  if (!validarTOTP(segredo, codigo)) {
    return res.status(401).json({ error: 'Código TOTP inválido' })
  }

  await db.query(
    `UPDATE usuarios
     SET totp_ativo = false, totp_segredo = NULL, backup_codes = NULL
     WHERE id = $1`,
    [usuario.id]
  )

  // Notificar por email (ação sensível de segurança):
  await enviarEmail({
    para: usuario.email,
    assunto: '2FA desabilitado na sua conta',
    corpo: 'O segundo fator de autenticação foi removido da sua conta. Se não foi você, contate o suporte.',
  })

  res.json({ message: '2FA desabilitado com sucesso' })
})

Proteger o segredo TOTP no banco

Criptografar o segredo TOTP com AES-256-GCM:

typescript
// lib/cripto.ts — criptografia simétrica para segredos TOTP:
import crypto from 'crypto'

const CHAVE = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex')  // 32 bytes = 64 hex chars
// Gerar chave: openssl rand -hex 32

export function criptografar(texto: string): string {
  const iv = crypto.randomBytes(12)  // 96 bits para GCM
  const cipher = crypto.createCipheriv('aes-256-gcm', CHAVE, iv)

  const encrypted = Buffer.concat([
    cipher.update(texto, 'utf8'),
    cipher.final(),
  ])
  const tag = cipher.getAuthTag()  // tag de autenticação (integridade)

  // Formato: iv(24 hex) + tag(32 hex) + encrypted(hex)
  return iv.toString('hex') + tag.toString('hex') + encrypted.toString('hex')
}

export function descriptografar(dado: string): string {
  const iv = Buffer.from(dado.slice(0, 24), 'hex')
  const tag = Buffer.from(dado.slice(24, 56), 'hex')
  const encrypted = Buffer.from(dado.slice(56), 'hex')

  const decipher = crypto.createDecipheriv('aes-256-gcm', CHAVE, iv)
  decipher.setAuthTag(tag)

  return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8')
}

// Assim, mesmo que o banco de dados vaze, o segredo TOTP não fica exposto
// A ENCRYPTION_KEY é o único segredo crítico adicional — manter no .env / secrets manager

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

TOTP (Google Authenticator, Authy) é significativamente mais seguro que SMS. SMS é vulnerável a: SIM swapping (atacante transfere o número para o próprio chip), SS7 attacks (interceptação da rede telefônica), e phishing em tempo real. TOTP: o código é gerado offline no dispositivo, sem transmissão por rede telefônica, não pode ser interceptado. Para segurança real: TOTP ou hardware token (YubiKey). SMS: apenas como fallback ou para usuários que não conseguem usar TOTP.

O TOTP é baseado no timestamp atual, se o relógio do dispositivo do usuário estiver muito adiantado/atrasado, o código gerado não bate com o esperado pelo servidor. A janela window: 1 na validação aceita o código do período anterior e seguinte (±30 segundos), que cobre a maioria dos casos. Se o erro persiste: verificar se o dispositivo está com horário automático ativado. O servidor deve usar NTP para horário preciso.

Depende do contexto: para contas de alto valor (admin, faturamento, dados sensíveis): sim, obrigatório. Para usuários gerais: oferecer como opção com incentivos (badge de segurança, sem 2FA em sessões de confiança). Para APIs B2B: cobrar níveis de segurança por plano (Enterprise = 2FA obrigatório). A adesão voluntária com comunicação clara sobre benefícios costuma resultar em 20-40% de adoção sem forçar.

Passkey (WebAuthn/FIDO2) usa criptografia de chave pública, o dispositivo gera um par de chaves, a pública fica no servidor, a privada no dispositivo (protegida por biometria ou PIN). É resistente a phishing (vinculado ao domínio), não requer códigos digitados, e é mais conveniente. O TOTP ainda tem ampla adoção por ser suportado em qualquer dispositivo sem app especial. Passkey é o futuro, TOTP é o padrão atual mais seguro e amplamente suportado.

Ao fazer login com 2FA com sucesso: gerar um token de dispositivo confiável (UUID aleatório), armazenar o hash no banco associado ao usuário e dispositivo, e enviar como cookie httpOnly de longa duração. No próximo login: se o cookie de dispositivo confiável estiver presente e válido no banco, pular a etapa do TOTP. Importante: permitir que o usuário veja e revogue dispositivos confiáveis no dashboard de segurança.