Pular para o conteúdo
📖 COOKBOOK

Autenticador de dois fatores (TOTP), do QR code ao código.

O usuário escaneia um QR code no Google Authenticator, 1Password ou Authy e passa a ter um código que muda a cada 30 segundos. Esta receita cadastra o fator, gera códigos de reserva e usa o código para confirmar ações sensíveis, conferido pelo seu servidor.

O que vamos fazer

  • Cadastrar o autenticador: db.auth.mfa.enroll({ factorType: 'totp' }) devolve o QR code.
  • Confirmar o primeiro código com db.auth.mfa.verify().
  • Gerar 10 códigos de reserva, para quando o celular some.
  • Listar e remover fatores.
  • Pedir o código antes de uma ação sensível, com a conferência feita no servidor.

O que o MFA faz hoje, e o que ainda não faz

⚠️

O login ainda não pede o segundo fator sozinho. Um usuário com autenticador cadastrado entra só com a senha, e o mfa.verify não muda a sessão: o token não ganha um nível "com MFA", então uma policy de RLS não tem como exigir o segundo fator. Hoje o MFA serve para confirmar ações sensíveis (trocar o e-mail, excluir a conta, aprovar um pagamento), com a conferência feita no seu servidor, como no passo 5.

RecursoSituação
Cadastrar autenticador (QR code)✅ mfa.enroll({ factorType: 'totp' })
Conferir um código✅ mfa.verify({ factorType, code })
Códigos de reserva✅ mfa.enroll({ factorType: 'backup' })
Listar fatores✅ mfa.factors()
Remover um fator✅ pela API (DELETE /auth/v1/mfa/factors/:id); o SDK ainda não tem o método
O login exigir o código❌ ainda não
Nível de MFA no token, para usar no RLS❌ ainda não

Pré-requisitos

  • Usuário logado: todas as chamadas de MFA usam a sessão atual. Sem sessão, o SDK responde no_session.
  • @superdb/client 0.2.2 ou mais novo, com o cliente criado com o project.
  • Para desenhar o QR code, a biblioteca qrcode (npm install qrcode) ou qualquer outra.

Passo 1: cadastrar o autenticador

enroll cria o fator e devolve três coisas: o factor_id, o segredo em base32 (para quem prefere digitar) e a uri otpauth://, que vira o QR code.

src/mfa/cadastrar.ts
import QRCode from 'qrcode'
import { db } from '../lib/superdb'

export async function cadastrarAutenticador() {
  const { data, error } = await db.auth.mfa.enroll({
    factorType: 'totp',
    label: 'Meu App', // aparece no autenticador, ao lado de "SuperDB"
  })
  if (error) throw new Error(error.message)

  const { factor_id, secret, uri } = data as { factor_id: string; secret: string; uri: string }
  const qrCode = await QRCode.toDataURL(uri) // <img src={qrCode} />
  return { fatorId: factor_id, segredo: secret, qrCode }
}
ℹ️

O autenticador mostra SuperDB como emissor da conta. Use o label com o nome do seu app para o usuário reconhecer de qual serviço é o código.

Passo 2: confirmar o primeiro código

O fator já nasce ativo no enroll, sem etapa de confirmação no servidor. Por isso, peça o primeiro código na mesma tela: ele prova que o QR code foi lido. Se a pessoa desistir ou errar, remova o fator, para não ficar um autenticador que ninguém tem.

src/mfa/confirmar.ts
import { db } from '../lib/superdb'
import { removerFator } from './remover'

export async function confirmarCadastro(fatorId: string, codigo: string) {
  const { error } = await db.auth.mfa.verify({ factorType: 'totp', code: codigo.trim() })
  if (!error) return true
  if (error.code === 'mfa_invalid_code') return false // deixe tentar de novo
  await removerFator(fatorId) // qualquer outro erro: desfaz o cadastro
  throw new Error(error.message)
}

Na tela, ofereça um botão "Cancelar" que também chama removerFator(fatorId).

Passo 3: códigos de reserva

Dez códigos de uso único, para quando o celular é perdido ou trocado. Mostre uma vez só, com um botão de copiar ou baixar: o SuperDB guarda apenas o hash, e não há como exibir de novo.

src/mfa/reserva.ts
import { db } from '../lib/superdb'

export async function gerarCodigosDeReserva() {
  const { data, error } = await db.auth.mfa.enroll({ factorType: 'backup' })
  if (error) throw new Error(error.message)
  return (data as { codes: string[] }).codes // 10 códigos
}

// Mais tarde, no lugar do código do autenticador:
export async function usarCodigoDeReserva(codigo: string) {
  const { error } = await db.auth.mfa.verify({ factorType: 'backup', code: codigo.trim() })
  return !error // cada código funciona uma vez
}

Gerar de novo invalida todos os códigos anteriores. É o que o usuário deve fazer depois de gastar alguns.

Passo 4: listar e remover fatores

src/mfa/remover.ts
import { db } from '../lib/superdb'

type Fator = { id: string; kind: 'totp' | 'backup'; label: string | null; enrolledAt: string; lastUsedAt: string | null }

export async function listarFatores(): Promise<Fator[]> {
  const { data, error } = await db.auth.mfa.factors()
  if (error) throw new Error(error.message)
  return (data as { factors: Fator[] }).factors
}

// O SDK ainda não tem unenroll: a remoção é uma chamada direta à API de auth.
export async function removerFator(fatorId: string) {
  const { data: { session } } = await db.auth.getSession()
  if (!session) throw new Error('entre de novo')
  const r = await fetch(`${import.meta.env.VITE_SUPERDB_URL}/auth/v1/mfa/factors/${fatorId}`, {
    method: 'DELETE',
    headers: {
      Authorization: `Bearer ${session.access_token}`,
      apikey: import.meta.env.VITE_SUPERDB_ANON_KEY,
      'X-SuperDB-Project': import.meta.env.VITE_SUPERDB_PROJECT,
    },
  })
  if (!r.ok) throw new Error('não deu para remover o fator')
}

Passo 5: pedir o código antes de uma ação sensível

Esconder um botão atrás de um campo de código no navegador não protege nada: quem tem a sessão chama a sua API direto. A conferência precisa acontecer no servidor que executa a ação. O navegador manda o código e o access_token; o servidor pergunta ao SuperDB se o código é daquele usuário e só então age.

app/api/excluir-conta/route.ts (Next.js; a ideia vale em qualquer servidor)
const AUTH = process.env.SUPERDB_URL!         // https://auth.superdb.com.br
const ANON = process.env.SUPERDB_ANON_KEY!
const PROJETO = process.env.SUPERDB_PROJECT!
const CARENCIA_MS = 24 * 60 * 60 * 1000

function noSuperDB(caminho: string, token: string, init: RequestInit = {}) {
  return fetch(`${AUTH}${caminho}`, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
      apikey: ANON,
      'X-SuperDB-Project': PROJETO,
    },
  })
}

export async function POST(req: Request) {
  const token = req.headers.get('authorization')?.replace(/^Bearer\s+/i, '')
  const { code, tipo = 'totp' } = (await req.json().catch(() => ({}))) as { code?: string; tipo?: string }
  if (!token || !code) return Response.json({ erro: 'faltou o código' }, { status: 400 })
  if (tipo !== 'totp' && tipo !== 'backup') return Response.json({ erro: 'tipo inválido' }, { status: 400 })

  // 1. Quem é o usuário? Pergunte ao SuperDB, nunca ao corpo da requisição.
  const eu = await noSuperDB('/auth/v1/user', token)
  if (!eu.ok) return Response.json({ erro: 'sessão inválida' }, { status: 401 })
  const { user } = (await eu.json()) as { user: { id: string } }

  // 2. Carência: quem tem a sessão também consegue cadastrar um autenticador.
  //    Um fator com menos de 24 horas não autoriza ação sensível.
  const lista = await noSuperDB('/auth/v1/mfa/factors', token)
  if (!lista.ok) return Response.json({ erro: 'sessão inválida' }, { status: 401 })
  const { factors } = (await lista.json()) as { factors: { kind: string; enrolledAt: string }[] }
  const doTipo = factors.filter((f) => f.kind === tipo)
  const recente = doTipo.some((f) => Date.now() - Date.parse(f.enrolledAt) < CARENCIA_MS)
  if (doTipo.length === 0 || recente) {
    return Response.json({ erro: 'fator cadastrado há menos de 24 horas' }, { status: 403 })
  }

  // 3. O código é deste usuário? Limite: 10 tentativas por minuto.
  const r = await noSuperDB('/auth/v1/mfa/verify', token, {
    method: 'POST',
    body: JSON.stringify({ factor_type: tipo, code }),
  })
  if (!r.ok) return Response.json({ erro: 'código inválido' }, { status: 401 })

  // 4. Só agora, a ação sensível para user.id.
  return Response.json({ ok: true })
}
no navegador
const { data: { session } } = await db.auth.getSession()
const r = await fetch('/api/excluir-conta', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session!.access_token}` },
  body: JSON.stringify({ code: codigoDigitado }),
})
⚠️

Por que a carência de 24 horas. Cadastrar um autenticador, ou gerar códigos de reserva novos, também só pede a sessão. Sem a carência, alguém com a sessão de outra pessoa cadastraria o próprio celular e usaria o código dele na hora. Mostre a lista de fatores na tela de segurança do app, com a data de cadastro, para o usuário perceber um que não é dele.

💡

Nos Hooks do painel, os eventos before-mfa e after-mfa avisam o seu servidor a cada conferência. O before-mfa pode até recusar (por exemplo, depois de um alerta de fraude na conta).

Erros comuns

mfa_invalid_code (401) com o código certo

Quase sempre é o relógio do celular adiantado ou atrasado: o código TOTP depende da hora. Peça para ligar a hora automática nos ajustes do aparelho. Se a pessoa digitou o código logo antes de ele trocar, basta usar o próximo.

rate_limited (429)

Mais de 10 conferências em um minuto para o mesmo usuário. O limite existe para que ninguém chute os 6 dígitos; espere um minuto.

no_session

As funções de MFA precisam de alguém logado. Confira se a sessão não venceu: ela dura 1 hora, e só o SDK 0.3 em diante renova sozinho (veja SDK JS → Auth).

O usuário perdeu o celular

Como o login ainda não pede o segundo fator, ele entra com a senha, remove o fator antigo (passo 4) e cadastra o novo celular. Enquanto a carência do celular novo não passa, os códigos de reserva confirmam as ações sensíveis: mande tipo: 'backup' para o servidor do passo 5.

Essa página ajudou?