Pular para o conteúdo
📖 COOKBOOK

MFA TOTP em 30 linhas.

Segundo fator de autenticação via Google Authenticator/Authy. Usuário escaneia QR code, valida 6 dígitos, MFA ativo. Bonus: trusted devices e recovery codes.

O que vamos fazer

  • Tela de enrollment com QR code pro usuário escanear no app autenticador
  • Input dos 6 dígitos pra confirmar que o fator foi configurado certo
  • Em logins futuros, depois da senha, exigir o TOTP (AAL2)
  • Trusted devices: salvar device_id criptografado em cookie pra pular TOTP por 30 dias
  • Recovery codes: 10 códigos pra emergência (mostrados UMA vez)

Pré-requisitos

  • Usuário já autenticado (precisa estar logado pra fazer enroll de fator MFA)
  • @superdb/supabase-compat ≥ 0.4 (ou @superdb/auth-js ≥ 0.4)
  • Provider de autenticador (Google Authenticator, Authy, 1Password, Bitwarden — qualquer um que faça TOTP RFC 6238)

Passo 1 — Enroll do fator

Geração do secret + QR code. O QR é um otpauth:// URI, encodado como SVG:

app/seguranca/enroll-mfa.tsx
'use client'
import { useState } from 'react'
import { createBrowserClient } from '@superdb/supabase-compat'

export function EnrollMFA() {
  const [qr, setQr] = useState<string | null>(null)
  const [factorId, setFactorId] = useState<string | null>(null)
  const [code, setCode] = useState('')
  const [ativo, setAtivo] = useState(false)

  const db = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPERDB_URL!,
    process.env.NEXT_PUBLIC_SUPERDB_ANON_KEY!
  )

  async function iniciar() {
    const { data, error } = await db.auth.mfa.enroll({
      factorType: 'totp',
      friendlyName: 'Meu telefone',
    })
    if (error) return alert(error.message)
    setQr(data.totp.qr_code)  // SVG inline
    setFactorId(data.id)
  }

  async function confirmar() {
    if (!factorId) return
    const { error } = await db.auth.mfa.challengeAndVerify({ factorId, code })
    if (error) return alert('Código inválido')
    setAtivo(true)
  }

  if (ativo) return <p>✓ MFA ativo na sua conta</p>

  return (
    <div>
      {!qr && <button onClick={iniciar}>Ativar MFA</button>}
      {qr && (
        <>
          <div dangerouslySetInnerHTML={{ __html: qr }} />
          <input value={code} onChange={(e) => setCode(e.target.value)}
                 placeholder="000000" maxLength={6} />
          <button onClick={confirmar}>Confirmar</button>
        </>
      )}
    </div>
  )
}
⚠️

Atenção: entre enroll e challengeAndVerify, o fator está em estado unverified. Se o usuário fechar o navegador antes de confirmar, ele fica órfão. Tenha um botão "Cancelar" que chama db.auth.mfa.unenroll(factorId) pra limpar.

Passo 2 — Verificar AAL no login subsequente

Depois do signIn (password ou OAuth), confira o assurance level. Se o usuário tem MFA ativo, o nível necessário sobe pra aal2:

app/auth/mfa-challenge.tsx
const { data: aal } = await db.auth.mfa.getAuthenticatorAssuranceLevel()

if (aal.currentLevel === 'aal1' && aal.nextLevel === 'aal2') {
  // Pede o TOTP pro usuário
  const { data: factors } = await db.auth.mfa.listFactors()
  const totpFactor = factors.totp[0]

  const { data: challenge } = await db.auth.mfa.challenge({
    factorId: totpFactor.id,
  })

  // ... mostra input, usuário digita 6 dígitos ...

  await db.auth.mfa.verify({
    factorId: totpFactor.id,
    challengeId: challenge.id,
    code: '123456',
  })
}

Passo 3 — Trusted devices

Não force TOTP em todo login se o dispositivo já é conhecido. Salve um device_id criptografado em cookie httpOnly. TTL 30 dias.

app/api/trust-device/route.ts
import { cookies } from 'next/headers'
import { randomBytes, createHmac } from 'crypto'

const SECRET = process.env.TRUSTED_DEVICE_SECRET!  // 32 bytes random

export async function POST(req: Request) {
  const { userId } = await req.json()

  // Gera device_id atrelado ao user
  const deviceId = randomBytes(16).toString('hex')
  const signature = createHmac('sha256', SECRET)
    .update(`${userId}:${deviceId}`)
    .digest('hex')

  const cookieStore = await cookies()
  cookieStore.set('trusted_device', `${deviceId}.${signature}`, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 30,  // 30 dias
    path: '/',
  })

  // Salva no banco pra você poder revogar
  // INSERT INTO trusted_devices (user_id, device_id, expires_at) VALUES (...)
  return new Response('ok')
}

No login, antes de pedir TOTP, valide o cookie. Se bate com o user e ainda está no banco → pula MFA:

app/auth/mfa-challenge.tsx
const trusted = cookieStore.get('trusted_device')?.value
if (trusted) {
  const [deviceId, sig] = trusted.split('.')
  const expected = createHmac('sha256', SECRET)
    .update(`${user.id}:${deviceId}`)
    .digest('hex')
  if (sig === expected && await deviceAindaValido(deviceId)) {
    return  // Pula TOTP, eleva AAL via API admin
  }
}

Passo 4 — Recovery codes

Ao confirmar enrollment, gere 10 códigos de 8 caracteres. Mostre UMA vez — depois disso ficam só hashes no banco:

app/seguranca/recovery-codes.ts
import { randomBytes, createHash } from 'crypto'

export async function gerarRecoveryCodes(userId: string) {
  const codes = Array.from({ length: 10 }, () =>
    randomBytes(5).toString('hex').toUpperCase().match(/.{4}/g)!.join('-')
  )
  // ex: 'A3F2-9B41'

  const hashes = codes.map((c) =>
    createHash('sha256').update(c).digest('hex')
  )

  // Salve hashes no banco (tabela mfa_recovery_codes)
  await dbAdmin.from('mfa_recovery_codes').insert(
    hashes.map((hash) => ({ user_id: userId, code_hash: hash, used: false }))
  )

  return codes  // Mostre UMA vez pro user salvar
}

Pra usar um recovery code (cenário: perdeu o telefone):

app/auth/recovery.ts
async function usarRecoveryCode(userId: string, code: string) {
  const hash = createHash('sha256').update(code).digest('hex')
  const { data } = await dbAdmin
    .from('mfa_recovery_codes')
    .select('id')
    .eq('user_id', userId)
    .eq('code_hash', hash)
    .eq('used', false)
    .single()

  if (!data) throw new Error('Código inválido')

  // Marca como usado (one-time)
  await dbAdmin.from('mfa_recovery_codes')
    .update({ used: true, used_at: new Date() })
    .eq('id', data.id)

  // Eleva sessão pra AAL2
}

Resultado

App com MFA real: enrollment com QR, validação subsequente, trusted devices que reduzem fricção, recovery codes pra emergência. Pronto pra compliance básica (SOC 2, ISO).

meu-app.com/seguranca
v0.4.2
PROTEÇÃO DA CONTA
Autenticação em dois fatores
Escaneie no Google Authenticator
000 000

Variações

WebAuthn / Passkeys (em breve)

SuperDB tem WebAuthn no roadmap pra Sprint 9. Quando sair, a API vai ser idêntica — só muda o factorType de 'totp' pra 'webauthn'. Você não vai precisar reescrever a UI de enroll.

SMS OTP como segundo fator

Já disponível. Use factorType: 'phone' e passe o número:

enroll-sms.ts
await db.auth.mfa.enroll({
  factorType: 'phone',
  friendlyName: 'iPhone Felipe',
  phone: '+5511999998888',
})
// SuperDB envia OTP via SMS; user digita pra confirmar
💡

Dica: TOTP é mais seguro que SMS (SIM swap é vetor real). Use SMS só como fallback ou pra públicos que não vão instalar app autenticador.

Forçar MFA por role

Em apps B2B é comum exigir MFA pra admins. Cheque o role + AAL no middleware:

middleware.ts
const { data: { user } } = await db.auth.getUser()
const isAdmin = user?.app_metadata?.role === 'admin'

const { data: aal } = await db.auth.mfa.getAuthenticatorAssuranceLevel()

if (isAdmin && aal.currentLevel !== 'aal2') {
  return NextResponse.redirect(new URL('/seguranca/enroll-mfa', request.url))
}

Erros comuns

Time drift no dispositivo do usuário

TOTP depende do relógio. Se o telefone do user está adiantado/atrasado mais de ~30s, todos os códigos falham. Solução: o SuperDB já aceita janela de ±30s. Se ainda assim falhar, oriente o user a habilitar "Sincronização automática de hora" nas configurações.

Perder o secret antes do verify

Se o user fechar a aba depois do enroll e antes de confirmar, ele fica num limbo: o fator existe (unverified), mas ele não tem mais acesso ao QR. Solução: detecte fatores unverified e ofereça "Refazer enrollment" (chama unenroll + enroll de novo).

Brute force no código

5 tentativas erradas seguidas → o SuperDB retorna 429 e bloqueia o factor por 15min. Não tente bypass — é proteção contra brute force. Na UI, mostre tentativas restantes e ofereça recovery code depois da 3ª falha.

ℹ️

Sobre compliance: ter MFA ativa por padrão pra contas admin já cobre parte significativa do controle CC6.1 da SOC 2. Não substitui audit log e gestão de chaves, mas é o primeiro item da lista.

Essa página ajudou?