Skip to content

Sistema de Autenticacao em Dois Fatores (2FA)

Visao Geral

O ProCases implementa autenticacao em dois fatores (2FA) baseada em TOTP (Time-based One-Time Password) usando a biblioteca speakeasy. O 2FA e obrigatorio para saques em saldo (PIX/cash) acima de um threshold configuravel (WITHDRAWAL_2FA_THRESHOLD_CENTS, default 10000 = R$100) e pode ser habilitado voluntariamente pelo usuario para proteger sua conta.

Escopo: este 2FA/threshold cobre o fluxo de saque em saldo (WithdrawalService). O saque de skin (Waxpeer) e um fluxo separado, aprovado por limites de auto-aprovacao, e nao usa TOTP.

O fluxo completo consiste em 4 endpoints no PaymentController:

  1. Setup: gera secret + QR code
  2. Enable: verifica token e ativa
  3. Disable: verifica token e desativa
  4. Verify: verifica validade de um token

Arquitetura de Arquivos

CamadaArquivoResponsabilidade
Presentationsrc/presentation/controllers/payment.controller.tsEndpoints HTTP (4 rotas 2FA)
Presentationsrc/presentation/modules/payment.module.tsModulo NestJS
Applicationsrc/application/use-cases/payment/generate-2fa-secret.use-case.tsGerar secret + QR
Applicationsrc/application/use-cases/payment/enable-2fa.use-case.tsHabilitar 2FA
Applicationsrc/application/use-cases/payment/disable-2fa.use-case.tsDesabilitar 2FA
Applicationsrc/application/dto/payment.dto.tsDTOs (Enable2FADto)
Infrastructuresrc/infrastructure/auth/two-factor.service.tsServico TOTP (speakeasy)

TwoFactorService

Servico de infraestrutura que encapsula toda a logica TOTP:

typescript
@Injectable()
export class TwoFactorService {
  constructor(private configService: ConfigService) {}

  generateSecret(username: string): { secret: string; otpauthUrl: string } {
    const secret = speakeasy.generateSecret({
      name: `ProCases (${username})`,
      issuer: 'ProCases',
      length: 32,
    });

    return {
      secret: secret.base32,
      otpauthUrl: secret.otpauth_url || '',
    };
  }

  async generateQRCode(otpauthUrl: string): Promise<string> {
    return QRCode.toDataURL(otpauthUrl);
  }

  verifyToken(secret: string, token: string): boolean {
    return speakeasy.totp.verify({
      secret,
      encoding: 'base32',
      token,
      window: 1,
    });
  }

  generateToken(secret: string): string {
    return speakeasy.totp({
      secret,
      encoding: 'base32',
    });
  }
}

Parametros do TOTP

ParametroValorDescricao
issuer"ProCases"Nome do emissor exibido no app autenticador
name"ProCases ({username})"Identificador da conta no app
length32Tamanho do secret em bytes (256 bits)
encodingbase32Formato do secret (padrao TOTP)
window1Tolerancia temporal: aceita token atual + 1 anterior/posterior

Tolerancia Temporal (window)

Com window: 1, o sistema aceita tokens de 3 periodos de 30 segundos:

[periodo anterior] [periodo atual] [proximo periodo]
     30 seg            30 seg          30 seg
     aceito            aceito          aceito

Isso permite uma margem de ate ~30 segundos de dessincronizacao entre o relogio do servidor e o app autenticador do usuario.

Formato do OTP Auth URL

otpauth://totp/ProCases%20(PlayerName)?secret=JBSWY3DPEHPK3PXP&issuer=ProCases

Esse URL e codificado no QR code e lido pelo app autenticador (Google Authenticator, Authy, etc.).

QR Code

O QR code e gerado como uma data URL base64 em formato PNG:

typescript
const qrCode = await QRCode.toDataURL(otpauthUrl);
// Retorna: "data:image/png;base64,iVBORw0KGgo..."

O frontend pode exibir diretamente com <img src={qrCode} />.


Endpoints

POST /payment/2fa/setup

Gera um novo secret TOTP e QR code para o usuario. O secret e salvo no banco mas o 2FA nao e ativado ainda.

Autenticacao: AuthGuard (session ID via cookie)

Body: nenhum

Response (200) - TwoFactorSetupResponseDto:

typescript
{
  secret: string;     // Secret base32 para inserir manualmente
  qrCode: string;     // QR code como data URL base64
  otpauthUrl: string;  // URL otpauth:// para o app autenticador
}

Exemplo de response:

json
{
  "secret": "JBSWY3DPEHPK3PXPGEZDGNBVGY3TQOJQGM4A",
  "qrCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...",
  "otpauthUrl": "otpauth://totp/ProCases%20(PlayerName)?secret=JBSWY3DPEHPK3PXPGEZDGNBVGY3TQOJQGM4A&issuer=ProCases"
}

Implementacao:

typescript
@Injectable()
export class Generate2FASecretUseCase {
  constructor(
    private userRepository: UserRepository,
    private twoFactorService: TwoFactorService,
  ) {}

  async execute(userId: bigint) {
    const user = await this.userRepository.findById(userId);
    if (!user) throw new NotFoundException('User not found');

    const { secret, otpauthUrl } = this.twoFactorService.generateSecret(
      user.username
    );

    // Salva o secret no banco (nao ativa ainda)
    await this.userRepository.update(userId, {
      twoFactorSecret: secret,
    });

    const qrCode = await this.twoFactorService.generateQRCode(otpauthUrl);

    return { secret, qrCode, otpauthUrl };
  }
}

Fluxo:

  1. Busca o usuario no banco
  2. Gera um novo secret TOTP com 32 bytes de entropia
  3. Salva o twoFactorSecret na tabela User (campo criptografado)
  4. Gera o QR code como data URL
  5. Retorna secret + QR code + otpauth URL

ATENCAO: Chamar este endpoint multiplas vezes sobrescreve o secret anterior. Se o usuario ja escaneou o QR code anterior e chamar setup novamente, o anterior e invalidado.


POST /payment/2fa/enable

Ativa o 2FA verificando que o usuario configurou corretamente o app autenticador.

Autenticacao: AuthGuard

Rate Limiting: @Throttle({ default: { limit: 5, ttl: 60000 } }) - maximo 5 tentativas por minuto

Body - Enable2FADto:

typescript
{
  token: string  // Codigo de 6 digitos do app autenticador
}

Response (200):

typescript
{
  success: true,
  message: "Two-factor authentication enabled successfully"
}

Erros:

StatusMensagemCausa
400Two-factor authentication already enabled2FA ja esta ativo
400Two-factor secret not generatedChamou enable sem chamar setup antes
400Invalid tokenToken errado ou expirado
404User not founduserId invalido
429Too Many RequestsExcedeu 5 tentativas em 60 segundos

Implementacao:

typescript
@Injectable()
export class Enable2FAUseCase {
  constructor(
    private userRepository: UserRepository,
    private twoFactorService: TwoFactorService,
  ) {}

  async execute(userId: bigint, token: string) {
    const user = await this.userRepository.findById(userId);
    if (!user) throw new NotFoundException('User not found');

    if (user.twoFactorEnabled) {
      throw new BadRequestException('Two-factor authentication already enabled');
    }

    if (!user.twoFactorSecret) {
      throw new BadRequestException('Two-factor secret not generated');
    }

    const isValid = this.twoFactorService.verifyToken(
      user.twoFactorSecret, token
    );
    if (!isValid) {
      throw new BadRequestException('Invalid token');
    }

    await this.userRepository.update(userId, {
      twoFactorEnabled: true,
    });

    return {
      success: true,
      message: 'Two-factor authentication enabled successfully',
    };
  }
}

Validacoes em ordem:

  1. Usuario existe
  2. 2FA nao esta ja habilitado (twoFactorEnabled === false)
  3. Secret foi gerado previamente (twoFactorSecret !== null)
  4. Token informado e valido (TOTP verification com window=1)

Somente apos todas as validacoes, o campo twoFactorEnabled e setado como true.


POST /payment/2fa/disable

Desativa o 2FA e limpa o secret do banco.

Autenticacao: AuthGuard

Rate Limiting: @Throttle({ default: { limit: 5, ttl: 60000 } }) - maximo 5 tentativas por minuto

Body - Enable2FADto:

typescript
{
  token: string  // Codigo de 6 digitos do app autenticador
}

Response (200):

typescript
{
  success: true,
  message: "Two-factor authentication disabled successfully"
}

Erros:

StatusMensagemCausa
400Two-factor authentication not enabled2FA nao esta ativo
400Two-factor secret not foundSecret corrompido/ausente
400Invalid tokenToken errado ou expirado
404User not founduserId invalido
429Too Many RequestsExcedeu 5 tentativas em 60 segundos

Implementacao:

typescript
@Injectable()
export class Disable2FAUseCase {
  constructor(
    private userRepository: UserRepository,
    private twoFactorService: TwoFactorService,
  ) {}

  async execute(userId: bigint, token: string) {
    const user = await this.userRepository.findById(userId);
    if (!user) throw new NotFoundException('User not found');

    if (!user.twoFactorEnabled) {
      throw new BadRequestException('Two-factor authentication not enabled');
    }

    if (!user.twoFactorSecret) {
      throw new BadRequestException('Two-factor secret not found');
    }

    const isValid = this.twoFactorService.verifyToken(
      user.twoFactorSecret, token
    );
    if (!isValid) {
      throw new BadRequestException('Invalid token');
    }

    await this.userRepository.update(userId, {
      twoFactorEnabled: false,
      twoFactorSecret: null, // Limpa o secret
    });

    return {
      success: true,
      message: 'Two-factor authentication disabled successfully',
    };
  }
}

IMPORTANTE: Ao desabilitar, o twoFactorSecret e setado como null. Isso significa que o usuario precisara fazer setup novamente se quiser reativar o 2FA. O QR code anterior nao funcionara mais.


POST /payment/2fa/verify

Verifica se um token TOTP e valido sem alterar estado.

Autenticacao: AuthGuard

Rate Limiting: @Throttle({ default: { limit: 5, ttl: 60000 } }) - maximo 5 tentativas por minuto

Body - Enable2FADto:

typescript
{
  token: string  // Codigo de 6 digitos
}

Response (200):

typescript
{
  valid: boolean  // true se o token e valido
}

Implementacao no Controller:

typescript
@Post('2fa/verify')
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } })
async verify2FA(@CurrentUser() user: User, @Body() dto: Enable2FADto) {
  const isValid = user.twoFactorSecret
    ? await this.generate2FASecretUseCase['twoFactorService'].verifyToken(
        user.twoFactorSecret,
        dto.token,
      )
    : false;

  return { valid: isValid };
}

Se o usuario nao tem twoFactorSecret, retorna { valid: false } sem erro. Esse endpoint e usado para verificar tokens antes de operacoes sensiveis (saques, etc.).


Fluxo Completo do Usuario

Ativar 2FA

1. Usuario acessa pagina de seguranca
2. Frontend chama POST /payment/2fa/setup
3. Backend gera secret, salva no banco, retorna QR code
4. Frontend exibe QR code e campo para digitar token
5. Usuario escaneia QR com Google Authenticator/Authy
6. Usuario digita o codigo de 6 digitos
7. Frontend chama POST /payment/2fa/enable com o token
8. Backend verifica token contra o secret salvo
9. Se valido: twoFactorEnabled = true
10. 2FA esta ativo para saques

Desativar 2FA

1. Usuario acessa pagina de seguranca
2. Frontend mostra campo para digitar token
3. Usuario digita o codigo de 6 digitos do app
4. Frontend chama POST /payment/2fa/disable com o token
5. Backend verifica token contra o secret salvo
6. Se valido: twoFactorEnabled = false, twoFactorSecret = null
7. 2FA esta desativado, secret deletado

Usar 2FA (Saque em saldo)

1. Usuario solicita saque POST /payment/withdraw/request (dentro de withUserBalanceLock)
2. Backend valida min/max e saldo (via TransactionService.getUserBalance)
3. Se amountCents >= WITHDRAWAL_2FA_THRESHOLD_CENTS (default R$100) E !twoFactorEnabled -> ForbiddenException ("2FA required")
4. Cria withdrawal status PENDING_2FA com expiresAt = +5min
5. Frontend exibe campo para token 2FA
6. Usuario digita token
7. Frontend chama POST /payment/withdraw/:id/confirm com twoFactorCode
8. Backend checa expiracao, valida token via TwoFactorService, transiciona PENDING_2FA -> PROCESSING (atomico) e debita

Este e o fluxo de saque em saldo (WithdrawalService). O saque de skin (Waxpeer) segue outro caminho e nao passa por TOTP.


Campos na Tabela User

prisma
model User {
  twoFactorEnabled  Boolean  @default(false) @map("two_factor_enabled")
  twoFactorSecret   String?  @map("two_factor_secret")
}
CampoTipoDescricao
twoFactorEnabledBooleanSe o 2FA esta ativo
twoFactorSecretString?Secret base32 (null quando 2FA desabilitado)

Estados possiveis:

twoFactorEnabledtwoFactorSecretEstado
falsenull2FA nunca configurado
false"JBSWY..."Setup feito, mas nao habilitado
true"JBSWY..."2FA ativo e funcionando
truenullEstado invalido (nao deveria existir)

Rate Limiting

Todos os endpoints 2FA (exceto setup) possuem rate limiting para prevenir brute force:

typescript
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } })
ConfiguracaoValor
Limite5 requisicoes
Janela60 segundos
EscopoPor IP (ThrottlerGuard padrao)

Um atacante tentando adivinhar o token de 6 digitos (1.000.000 combinacoes) precisaria de:

  • 5 tentativas a cada 60 segundos
  • 200.000 minutos = ~139 dias para testar todas as combinacoes
  • Como o token muda a cada 30 segundos, brute force e impossivel na pratica

Seguranca

Armazenamento do Secret

O twoFactorSecret e armazenado na tabela User. Em producao, considerar:

  • Criptografia em repouso no banco de dados (TDE)
  • Campo criptografado na aplicacao antes de salvar

Protecoes Implementadas

  1. Rate limiting: 5 tentativas por minuto em enable/disable/verify
  2. Window = 1: aceita apenas 3 periodos de 30 segundos (90 segundos total)
  3. Secret de 32 bytes: 256 bits de entropia
  4. Limpeza do secret ao desabilitar: previne reuso de QR codes antigos
  5. Validacao antes de ativar: garante que o app esta configurado corretamente

Vulnerabilidades Conhecidas

  1. Setup nao tem rate limit: um atacante poderia chamar setup repetidamente (mas so sobrescreve o secret, nao compromete seguranca)
  2. Verify no controller acessa service diretamente: this.generate2FASecretUseCase['twoFactorService'] -- acesso nao ideal, mas funcional

Dependencias

PacoteVersaoUso
speakeasy^2.0.0Geracao e verificacao TOTP
qrcode^1.5.0Geracao de QR code como data URL
@nestjs/throttler^5.0.0Rate limiting

Debugging

Gerar token manualmente (para testes)

typescript
const speakeasy = require('speakeasy');
const token = speakeasy.totp({
  secret: 'JBSWY3DPEHPK3PXPGEZDGNBVGY3TQOJQGM4A',
  encoding: 'base32',
});
console.log('Token atual:', token); // ex: "123456"

Verificar token manualmente

typescript
const isValid = speakeasy.totp.verify({
  secret: 'JBSWY3DPEHPK3PXPGEZDGNBVGY3TQOJQGM4A',
  encoding: 'base32',
  token: '123456',
  window: 1,
});
console.log('Valid:', isValid);

Verificar estado do usuario no banco

sql
SELECT id, username, two_factor_enabled, two_factor_secret
FROM users
WHERE id = 123456789;

Problemas Comuns

ProblemaCausaSolucao
Token sempre invalidoRelogio do servidor dessincronizadoSincronizar via NTP
QR code nao funcionaURL com caracteres especiais no usernameVerificar encoding do username
"Two-factor secret not generated"Chamou enable sem chamar setupChamar setup primeiro
"Two-factor authentication already enabled"Tentando ativar novamenteJa esta ativo, nao precisa ativar
Rate limit atingido5+ tentativas em 60sAguardar 60 segundos
Secret null mas enabled trueEstado inconsistenteCorrigir manualmente no banco

Regras Importantes

  1. Setup gera um NOVO secret a cada chamada - o anterior e sobrescrito
  2. Enable requer token valido - prova que o app foi configurado corretamente
  3. Disable LIMPA o secret (twoFactorSecret = null) - impossibilita reuso
  4. Rate limit em 3 dos 4 endpoints - 5 tentativas por minuto
  5. Window = 1 significa tolerancia de 30 segundos para frente e para tras
  6. Issuer e "ProCases" em todos os secrets gerados
  7. Secret tem 32 bytes (256 bits) de entropia - padrao de seguranca alto
  8. O endpoint verify NAO altera estado - apenas verifica validade
  9. 2FA e obrigatorio para saques acima do threshold configurado
  10. Endpoints 2FA ficam no PaymentController (nao no AuthController) porque sao usados no contexto de pagamentos

Documentação Técnica - ProCases