Skip to content

Autenticação 2FA (TOTP)

O CSGOFlip implementa Two-Factor Authentication usando TOTP (Time-based One-Time Password), compatível com Google Authenticator, Authy e outros apps.

Quando é Necessário?

Operação2FA Obrigatório
Login❌ Opcional
Abertura de caixas❌ Não
Batalhas❌ Não
Saque até R$ 100❌ Não (se ativado)
Saque acima de R$ 100✅ Sim (se ativado)
Desativar 2FA✅ Sim
Mudar dados sensíveis✅ Sim

Fluxo de Ativação

Implementação

Gerar Secret

typescript
// src/infrastructure/auth/two-factor.service.ts

import * as speakeasy from 'speakeasy';
import * as QRCode from 'qrcode';

@Injectable()
export class TwoFactorService {
  async generateSecret(user: User): Promise<TwoFactorSetup> {
    // Gera secret de 20 caracteres
    const secret = speakeasy.generateSecret({
      length: 20,
      name: `CSGOFlip:${user.username}`,
      issuer: 'CSGOFlip',
    });

    // Gera QR Code
    const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);

    // Salva temporariamente no Redis (5 minutos para confirmar)
    await this.redis.setex(
      `2fa_setup:${user.id}`,
      300,
      secret.base32,
    );

    return {
      secret: secret.base32,
      qrCode: qrCodeUrl,
      otpauthUrl: secret.otpauth_url,
    };
  }
}

Ativar 2FA

typescript
async enable(userId: bigint, code: string): Promise<BackupCodes> {
  // Busca secret temporário
  const secret = await this.redis.get(`2fa_setup:${userId}`);
  
  if (!secret) {
    throw new BadRequestException('Setup expired. Generate new QR code.');
  }

  // Verifica código
  const isValid = speakeasy.totp.verify({
    secret,
    encoding: 'base32',
    token: code,
    window: 1, // Permite 1 código anterior/posterior (30s tolerância)
  });

  if (!isValid) {
    throw new BadRequestException('Invalid code');
  }

  // Gera backup codes (10 códigos de 8 caracteres)
  const backupCodes = this.generateBackupCodes(10);
  const hashedCodes = backupCodes.map(c => this.hashCode(c));

  // Salva no usuário
  await this.userRepository.update(userId, {
    twoFactorSecret: secret,
    twoFactorEnabled: true,
    twoFactorBackupCodes: hashedCodes,
  });

  // Remove setup temporário
  await this.redis.del(`2fa_setup:${userId}`);

  // Log de auditoria
  await this.auditService.log({
    action: '2FA_ENABLED',
    userId,
  });

  // Retorna backup codes (única vez que são mostrados!)
  return {
    backupCodes,
    message: 'Guarde estes códigos em local seguro. Cada um pode ser usado apenas uma vez.',
  };
}

private generateBackupCodes(count: number): string[] {
  return Array.from({ length: count }, () =>
    crypto.randomBytes(4).toString('hex').toUpperCase()
  );
}

private hashCode(code: string): string {
  return crypto.createHash('sha256').update(code).digest('hex');
}

Verificar Código

typescript
async verify(userId: bigint, code: string): Promise<boolean> {
  const user = await this.userRepository.findById(userId);

  if (!user.twoFactorEnabled || !user.twoFactorSecret) {
    throw new BadRequestException('2FA not enabled');
  }

  // Tenta verificar como TOTP normal
  const isValidTotp = speakeasy.totp.verify({
    secret: user.twoFactorSecret,
    encoding: 'base32',
    token: code,
    window: 1,
  });

  if (isValidTotp) {
    return true;
  }

  // Tenta como backup code
  const hashedInput = this.hashCode(code.toUpperCase());
  const backupCodeIndex = user.twoFactorBackupCodes.indexOf(hashedInput);

  if (backupCodeIndex !== -1) {
    // Remove backup code usado
    const newBackupCodes = [...user.twoFactorBackupCodes];
    newBackupCodes.splice(backupCodeIndex, 1);

    await this.userRepository.update(userId, {
      twoFactorBackupCodes: newBackupCodes,
    });

    await this.auditService.log({
      action: '2FA_BACKUP_CODE_USED',
      userId,
      metadata: { remainingCodes: newBackupCodes.length },
    });

    return true;
  }

  return false;
}

Desativar 2FA

typescript
async disable(userId: bigint, code: string): Promise<void> {
  // Precisa verificar código para desativar
  const isValid = await this.verify(userId, code);

  if (!isValid) {
    throw new BadRequestException('Invalid 2FA code');
  }

  await this.userRepository.update(userId, {
    twoFactorSecret: null,
    twoFactorEnabled: false,
    twoFactorBackupCodes: [],
  });

  await this.auditService.log({
    action: '2FA_DISABLED',
    userId,
  });
}

Uso em Saques

typescript
// src/application/use-cases/payment/request-withdrawal.use-case.ts

async execute(userId: bigint, amount: bigint, twoFactorCode?: string) {
  const user = await this.userRepository.findById(userId);

  // Verifica se precisa de 2FA
  const requires2FA = user.twoFactorEnabled && amount > 10000n; // > R$ 100

  if (requires2FA) {
    if (!twoFactorCode) {
      throw new BadRequestException({
        error: '2FA_REQUIRED',
        message: 'Two-factor authentication required for this withdrawal',
      });
    }

    const isValid = await this.twoFactorService.verify(userId, twoFactorCode);
    
    if (!isValid) {
      throw new BadRequestException('Invalid 2FA code');
    }
  }

  // Continua com o saque...
}

API Endpoints

POST /auth/2fa/generate

Gera QR code para configuração:

json
// Response
{
  "secret": "JBSWY3DPEHPK3PXP",
  "qrCode": "data:image/png;base64,iVBORw0KGgo...",
  "otpauthUrl": "otpauth://totp/CSGOFlip:PlayerOne?secret=JBSWY3DPEHPK3PXP&issuer=CSGOFlip"
}

POST /auth/2fa/enable

Ativa 2FA com código de verificação:

json
// Request
{
  "code": "123456"
}

// Response
{
  "backupCodes": [
    "A1B2C3D4",
    "E5F6G7H8",
    "I9J0K1L2",
    // ... mais 7 códigos
  ],
  "message": "2FA enabled successfully"
}

POST /auth/2fa/verify

Verifica código (para operações sensíveis):

json
// Request
{
  "code": "123456"
}

// Response
{
  "valid": true
}

POST /auth/2fa/disable

Desativa 2FA:

json
// Request
{
  "code": "123456"
}

// Response
{
  "message": "2FA disabled successfully"
}

Segurança

Armazenamento do Secret

typescript
// O secret é armazenado encriptado no banco
model User {
  twoFactorSecret       String?   @map("two_factor_secret") // Encriptado
  twoFactorEnabled      Boolean   @default(false)
  twoFactorBackupCodes  String[]  @default([]) // Hashed
}

Rate Limiting

typescript
// Limite tentativas de verificação
@Throttle({ default: { limit: 5, ttl: 300000 } }) // 5 tentativas / 5 min
@Post('2fa/verify')
async verify(@Body() dto: Verify2FADto) {
  // ...
}

Backup Codes

  • 10 códigos de uso único
  • Hasheados com SHA256 no banco
  • Cada código só funciona uma vez
  • Usuário é notificado quando restam poucos

Frontend Integration

typescript
// Componente de ativação 2FA
function Enable2FA() {
  const [qrCode, setQrCode] = useState<string | null>(null);
  const [code, setCode] = useState('');
  const [backupCodes, setBackupCodes] = useState<string[]>([]);

  const generateQR = async () => {
    const response = await api.auth.generate2FA();
    setQrCode(response.qrCode);
  };

  const enable = async () => {
    const response = await api.auth.enable2FA(code);
    setBackupCodes(response.backupCodes);
  };

  return (
    <div>
      {!qrCode && (
        <button onClick={generateQR}>Ativar 2FA</button>
      )}
      
      {qrCode && !backupCodes.length && (
        <>
          <img src={qrCode} alt="QR Code" />
          <p>Escaneie com Google Authenticator</p>
          <input 
            value={code} 
            onChange={e => setCode(e.target.value)}
            placeholder="Código de 6 dígitos"
          />
          <button onClick={enable}>Confirmar</button>
        </>
      )}
      
      {backupCodes.length > 0 && (
        <div>
          <h3>Códigos de Backup</h3>
          <p>Guarde em local seguro!</p>
          <ul>
            {backupCodes.map(code => (
              <li key={code}>{code}</li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

Arquivos Fonte

Principais Arquivos

  • src/infrastructure/auth/two-factor.service.ts - Serviço 2FA
  • src/presentation/controllers/auth.controller.ts - Endpoints
  • prisma/schema.prisma - Campos de 2FA no User

Documentação Técnica CSGOFlip