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ção | 2FA 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 2FAsrc/presentation/controllers/auth.controller.ts- Endpointsprisma/schema.prisma- Campos de 2FA no User
