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:
- Setup: gera secret + QR code
- Enable: verifica token e ativa
- Disable: verifica token e desativa
- Verify: verifica validade de um token
Arquitetura de Arquivos
| Camada | Arquivo | Responsabilidade |
|---|---|---|
| Presentation | src/presentation/controllers/payment.controller.ts | Endpoints HTTP (4 rotas 2FA) |
| Presentation | src/presentation/modules/payment.module.ts | Modulo NestJS |
| Application | src/application/use-cases/payment/generate-2fa-secret.use-case.ts | Gerar secret + QR |
| Application | src/application/use-cases/payment/enable-2fa.use-case.ts | Habilitar 2FA |
| Application | src/application/use-cases/payment/disable-2fa.use-case.ts | Desabilitar 2FA |
| Application | src/application/dto/payment.dto.ts | DTOs (Enable2FADto) |
| Infrastructure | src/infrastructure/auth/two-factor.service.ts | Servico TOTP (speakeasy) |
TwoFactorService
Servico de infraestrutura que encapsula toda a logica TOTP:
@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
| Parametro | Valor | Descricao |
|---|---|---|
issuer | "ProCases" | Nome do emissor exibido no app autenticador |
name | "ProCases ({username})" | Identificador da conta no app |
length | 32 | Tamanho do secret em bytes (256 bits) |
encoding | base32 | Formato do secret (padrao TOTP) |
window | 1 | Tolerancia 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 aceitoIsso 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=ProCasesEsse 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:
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:
{
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:
{
"secret": "JBSWY3DPEHPK3PXPGEZDGNBVGY3TQOJQGM4A",
"qrCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...",
"otpauthUrl": "otpauth://totp/ProCases%20(PlayerName)?secret=JBSWY3DPEHPK3PXPGEZDGNBVGY3TQOJQGM4A&issuer=ProCases"
}Implementacao:
@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:
- Busca o usuario no banco
- Gera um novo secret TOTP com 32 bytes de entropia
- Salva o
twoFactorSecretna tabela User (campo criptografado) - Gera o QR code como data URL
- 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:
{
token: string // Codigo de 6 digitos do app autenticador
}Response (200):
{
success: true,
message: "Two-factor authentication enabled successfully"
}Erros:
| Status | Mensagem | Causa |
|---|---|---|
| 400 | Two-factor authentication already enabled | 2FA ja esta ativo |
| 400 | Two-factor secret not generated | Chamou enable sem chamar setup antes |
| 400 | Invalid token | Token errado ou expirado |
| 404 | User not found | userId invalido |
| 429 | Too Many Requests | Excedeu 5 tentativas em 60 segundos |
Implementacao:
@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:
- Usuario existe
- 2FA nao esta ja habilitado (
twoFactorEnabled === false) - Secret foi gerado previamente (
twoFactorSecret !== null) - 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:
{
token: string // Codigo de 6 digitos do app autenticador
}Response (200):
{
success: true,
message: "Two-factor authentication disabled successfully"
}Erros:
| Status | Mensagem | Causa |
|---|---|---|
| 400 | Two-factor authentication not enabled | 2FA nao esta ativo |
| 400 | Two-factor secret not found | Secret corrompido/ausente |
| 400 | Invalid token | Token errado ou expirado |
| 404 | User not found | userId invalido |
| 429 | Too Many Requests | Excedeu 5 tentativas em 60 segundos |
Implementacao:
@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:
{
token: string // Codigo de 6 digitos
}Response (200):
{
valid: boolean // true se o token e valido
}Implementacao no Controller:
@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 saquesDesativar 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 deletadoUsar 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 debitaEste 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
model User {
twoFactorEnabled Boolean @default(false) @map("two_factor_enabled")
twoFactorSecret String? @map("two_factor_secret")
}| Campo | Tipo | Descricao |
|---|---|---|
twoFactorEnabled | Boolean | Se o 2FA esta ativo |
twoFactorSecret | String? | Secret base32 (null quando 2FA desabilitado) |
Estados possiveis:
twoFactorEnabled | twoFactorSecret | Estado |
|---|---|---|
false | null | 2FA nunca configurado |
false | "JBSWY..." | Setup feito, mas nao habilitado |
true | "JBSWY..." | 2FA ativo e funcionando |
true | null | Estado invalido (nao deveria existir) |
Rate Limiting
Todos os endpoints 2FA (exceto setup) possuem rate limiting para prevenir brute force:
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } })| Configuracao | Valor |
|---|---|
| Limite | 5 requisicoes |
| Janela | 60 segundos |
| Escopo | Por 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
- Rate limiting: 5 tentativas por minuto em enable/disable/verify
- Window = 1: aceita apenas 3 periodos de 30 segundos (90 segundos total)
- Secret de 32 bytes: 256 bits de entropia
- Limpeza do secret ao desabilitar: previne reuso de QR codes antigos
- Validacao antes de ativar: garante que o app esta configurado corretamente
Vulnerabilidades Conhecidas
- Setup nao tem rate limit: um atacante poderia chamar setup repetidamente (mas so sobrescreve o secret, nao compromete seguranca)
- Verify no controller acessa service diretamente:
this.generate2FASecretUseCase['twoFactorService']-- acesso nao ideal, mas funcional
Dependencias
| Pacote | Versao | Uso |
|---|---|---|
speakeasy | ^2.0.0 | Geracao e verificacao TOTP |
qrcode | ^1.5.0 | Geracao de QR code como data URL |
@nestjs/throttler | ^5.0.0 | Rate limiting |
Debugging
Gerar token manualmente (para testes)
const speakeasy = require('speakeasy');
const token = speakeasy.totp({
secret: 'JBSWY3DPEHPK3PXPGEZDGNBVGY3TQOJQGM4A',
encoding: 'base32',
});
console.log('Token atual:', token); // ex: "123456"Verificar token manualmente
const isValid = speakeasy.totp.verify({
secret: 'JBSWY3DPEHPK3PXPGEZDGNBVGY3TQOJQGM4A',
encoding: 'base32',
token: '123456',
window: 1,
});
console.log('Valid:', isValid);Verificar estado do usuario no banco
SELECT id, username, two_factor_enabled, two_factor_secret
FROM users
WHERE id = 123456789;Problemas Comuns
| Problema | Causa | Solucao |
|---|---|---|
| Token sempre invalido | Relogio do servidor dessincronizado | Sincronizar via NTP |
| QR code nao funciona | URL com caracteres especiais no username | Verificar encoding do username |
| "Two-factor secret not generated" | Chamou enable sem chamar setup | Chamar setup primeiro |
| "Two-factor authentication already enabled" | Tentando ativar novamente | Ja esta ativo, nao precisa ativar |
| Rate limit atingido | 5+ tentativas em 60s | Aguardar 60 segundos |
| Secret null mas enabled true | Estado inconsistente | Corrigir manualmente no banco |
Regras Importantes
- Setup gera um NOVO secret a cada chamada - o anterior e sobrescrito
- Enable requer token valido - prova que o app foi configurado corretamente
- Disable LIMPA o secret (
twoFactorSecret = null) - impossibilita reuso - Rate limit em 3 dos 4 endpoints - 5 tentativas por minuto
- Window = 1 significa tolerancia de 30 segundos para frente e para tras
- Issuer e "ProCases" em todos os secrets gerados
- Secret tem 32 bytes (256 bits) de entropia - padrao de seguranca alto
- O endpoint verify NAO altera estado - apenas verifica validade
- 2FA e obrigatorio para saques acima do threshold configurado
- Endpoints 2FA ficam no PaymentController (nao no AuthController) porque sao usados no contexto de pagamentos
