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

    $ runstack deploy --plan starter

    Não quer configurar manualmente?

    Não quer configurar manualmente? Implante o VPS para APIs em menos de 3 minutos com a Runstack. Infraestrutura da OPEN DATACENTER, com servidores no Brasil.

    Perguntas frequentes