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
Conteúdos relacionados