"MFA não é opcional em 2025. É a diferença entre uma conta comprometida e uma conta protegida." — #30DiasJava Security Notes

🎯 Objetivo do Day 18

Para aumentar a segurança das contas de usuários, o Day 18 do #30DiasJava implementou Multi-Factor Authentication (MFA) usando TOTP (Time-based One-Time Password), permitindo que usuários configurem autenticação de dois fatores via aplicativos autenticadores (Google Authenticator, Authy, etc.).

🛠️ O que foi implementado

✅ TOTP Secret Generation

  • Secret generation: Geração de secrets Base32 únicos por usuário
  • Secret storage: Armazenamento seguro em banco de dados
  • Secret reuse: Reutilização de secrets não verificados
  • Secret rotation: Geração de novos secrets quando necessário

✅ QR Code Generation

  • QR code generation: Geração de QR codes em PNG (base64)
  • TOTP URI format: Formato padrão otpauth://totp/...
  • App name configuration: Nome da aplicação configurável
  • Username labeling: Labels personalizados por usuário

✅ MFA Verification

  • Code verification: Verificação de códigos TOTP de 6 dígitos
  • Time window: Janela de tempo de 30 segundos
  • SHA1 algorithm: Algoritmo de hash SHA1 (padrão TOTP)
  • Enabled state: Estado de habilitação/desabilitação por usuário

✅ Login Flow Integration

  • MFA check on login: Verificação de MFA habilitado durante login
  • Two-step login: Login em duas etapas (senha + código MFA)
  • MFA verification endpoint: Endpoint dedicado para verificação MFA
  • JWT token generation: Geração de token apenas após verificação MFA

✅ MFA Management

  • Setup endpoint: Endpoint para configurar MFA (gera secret + QR code)
  • Verify endpoint: Endpoint para verificar e habilitar MFA
  • Status endpoint: Endpoint para verificar status MFA do usuário
  • Disable endpoint: Endpoint para desabilitar MFA

📊 Arquitetura

┌─────────────────────────────────────────────────────────┐
│                    User Request                         │
└──────────────────────┬──────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────┐
│              AuthController / MfaController              │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐ │
│  │   Login      │  │   Setup MFA   │  │  Verify MFA   │ │
│  │   (check)    │  │  (generate)   │  │  (enable)     │ │
│  └──────────────┘  └──────────────┘  └──────────────┘ │
└──────────────────────┬──────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────┐
│                    MfaService                            │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐ │
│  │  Generate    │  │   Generate   │  │   Verify     │ │
│  │   Secret     │  │   QR Code     │  │   Code       │ │
│  └──────────────┘  └──────────────┘  └──────────────┘ │
└──────────────────────┬──────────────────────────────────┘
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│  TOTP Lib    │ │  ZXing QR     │ │  PostgreSQL  │
│  (samstevens)│ │  Generator    │ │  (mfa_secrets)│
└──────────────┘ └──────────────┘ └──────────────┘

💻 Implementação

Dependências (pom.xml)

<!-- MFA / TOTP -->
<dependency>
  <groupId>dev.samstevens.totp</groupId>
  <artifactId>totp</artifactId>
  <version>1.7.1</version>
</dependency>
<dependency>
  <groupId>com.google.zxing</groupId>
  <artifactId>core</artifactId>
  <version>3.5.3</version>
</dependency>
<dependency>
  <groupId>com.google.zxing</groupId>
  <artifactId>javase</artifactId>
  <version>3.5.3</version>
</dependency>

Database Migration (V1005__create_mfa_secrets.sql)

CREATE TABLE IF NOT EXISTS mfa_secrets (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL UNIQUE,
    secret VARCHAR(32) NOT NULL,
    enabled BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    verified_at TIMESTAMPTZ,
    CONSTRAINT fk_mfa_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_mfa_user_id ON mfa_secrets(user_id);

MfaService

@Service
public class MfaService {
    private final MfaSecretRepository mfaSecretRepository;
    private final SecretGenerator secretGenerator;
    private final QrGenerator qrGenerator;
    private final CodeVerifier codeVerifier;
    private final String appName;

    public MfaService(
            MfaSecretRepository mfaSecretRepository,
            @Value("${app.mfa.app-name:E-Nouveau}") String appName) {
        this.mfaSecretRepository = mfaSecretRepository;
        this.appName = appName;
        this.secretGenerator = new DefaultSecretGenerator();
        this.qrGenerator = new ZxingPngQrGenerator();
        
        TimeProvider timeProvider = new SystemTimeProvider();
        CodeGenerator codeGenerator = new DefaultCodeGenerator(HashingAlgorithm.SHA1);
        this.codeVerifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
    }

    public MfaSetupResponse generateSecret(Long userId, String username) {
        MfaSecret existing = mfaSecretRepository.findByUserId(userId)
            .orElse(null);

        String secret;
        if (existing != null && !existing.isEnabled()) {
            // Reutilizar secret não verificado
            secret = existing.getSecret();
        } else {
            // Gerar novo secret
            secret = secretGenerator.generate();
            
            if (existing != null) {
                existing.setSecret(secret);
                existing.setEnabled(false);
                existing.setVerifiedAt(null);
                mfaSecretRepository.save(existing);
            } else {
                MfaSecret mfaSecret = new MfaSecret();
                mfaSecret.setUserId(userId);
                mfaSecret.setSecret(secret);
                mfaSecret.setEnabled(false);
                mfaSecretRepository.save(mfaSecret);
            }
        }

        // Gerar QR code
        QrData qrData = generateQrCodeData(username, secret);
        byte[] qrCodeImage = qrGenerator.generate(qrData);
        String qrCodeBase64 = Base64.getEncoder().encodeToString(qrCodeImage);

        return new MfaSetupResponse(secret, qrCodeBase64);
    }

    public boolean verifyCode(Long userId, String code) {
        MfaSecret mfaSecret = mfaSecretRepository.findByUserId(userId)
            .orElseThrow(() -> new IllegalArgumentException("MFA not set up for user"));

        if (!mfaSecret.isEnabled()) {
            throw new IllegalStateException("MFA is not enabled for this user");
        }

        return codeVerifier.isValidCode(mfaSecret.getSecret(), code);
    }

    public void enableMfa(Long userId, String verificationCode) {
        MfaSecret mfaSecret = mfaSecretRepository.findByUserId(userId)
            .orElseThrow(() -> new IllegalArgumentException("MFA secret not found"));

        if (!codeVerifier.isValidCode(mfaSecret.getSecret(), verificationCode)) {
            throw new IllegalArgumentException("Invalid verification code");
        }

        mfaSecret.setEnabled(true);
        mfaSecret.setVerifiedAt(LocalDateTime.now());
        mfaSecretRepository.save(mfaSecret);
    }

    private QrData generateQrCodeData(String username, String secret) {
        return new QrData.Builder()
            .label(username)
            .secret(secret)
            .issuer(appName)
            .algorithm(HashingAlgorithm.SHA1)
            .digits(6)
            .period(30)
            .build();
    }

    public record MfaSetupResponse(String secret, String qrCodeBase64) {}
}

MfaController

@RestController
@RequestMapping({"/auth/mfa", "/api/auth/mfa"})
public class MfaController {
    private final MfaService mfaService;
    private final AppUserRepository userRepository;
    private final JwtUtil jwtUtil;

    @PostMapping("/setup")
    public ResponseEntity<MfaService.MfaSetupResponse> setupMfa() {
        String username = getCurrentUsername();
        AppUser user = userRepository.findByUsername(username)
            .orElseThrow(() -> new IllegalArgumentException("User not found"));

        MfaService.MfaSetupResponse response = mfaService.generateSecret(
            user.getId(), 
            user.getUsername()
        );
        return ResponseEntity.ok(response);
    }

    @PostMapping("/verify")
    public ResponseEntity<Map<String, Object>> verifyAndEnable(
            @Valid @RequestBody VerifyMfaRequest request) {
        String username = getCurrentUsername();
        AppUser user = userRepository.findByUsername(username)
            .orElseThrow(() -> new IllegalArgumentException("User not found"));

        mfaService.enableMfa(user.getId(), request.code());

        return ResponseEntity.ok(Map.of(
            "message", "MFA enabled successfully",
            "enabled", true
        ));
    }

    @GetMapping("/status")
    public ResponseEntity<Map<String, Boolean>> getMfaStatus() {
        String username = getCurrentUsername();
        AppUser user = userRepository.findByUsername(username)
            .orElseThrow(() -> new IllegalArgumentException("User not found"));

        boolean enabled = mfaService.isMfaEnabled(user.getId());
        return ResponseEntity.ok(Map.of("enabled", enabled));
    }

    @PostMapping("/verify-login")
    public ResponseEntity<Map<String, Object>> verifyLoginCode(
            @Valid @RequestBody VerifyLoginMfaRequest request) {
        AppUser user = userRepository.findByUsername(request.username())
            .orElseThrow(() -> new IllegalArgumentException("Invalid credentials"));

        boolean isValid = mfaService.verifyCode(user.getId(), request.code());
        
        if (!isValid) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("error", "Invalid MFA code"));
        }

        String token = jwtUtil.generateToken(user.getUsername(), user.getRoles());
        return ResponseEntity.ok(Map.of("token", token));
    }
}

AuthController Integration

@PostMapping("/login")
public LoginResponse login(@Valid @RequestBody LoginRequest req) {
    var u = users.findByUsername(req.username().trim().toLowerCase())
        .orElseThrow(() -> new IllegalArgumentException("Invalid credentials"));
    if (!encoder.matches(req.password(), u.getPasswordHash())) {
        throw new IllegalArgumentException("Invalid credentials");
    }
    
    // Verificar se MFA está habilitado
    boolean mfaRequired = mfaService.isMfaEnabled(u.getId());
    
    if (mfaRequired) {
        // Retornar indicador de que MFA é necessário (sem token ainda)
        return new LoginResponse(null, true);
    }
    
    // Gerar token normalmente se MFA não estiver habilitado
    String token = jwt.generateToken(u.getUsername(), u.getRoles());
    return new LoginResponse(token, false);
}

@PostMapping("/login/mfa")
public TokenResponse verifyMfaAndLogin(@Valid @RequestBody MfaVerificationRequest req) {
    var u = users.findByUsername(req.username().trim().toLowerCase())
        .orElseThrow(() -> new IllegalArgumentException("Invalid credentials"));
    
    // Verificar código MFA
    boolean isValid = mfaService.verifyCode(u.getId(), req.code());
    if (!isValid) {
        throw new IllegalArgumentException("Invalid MFA code");
    }
    
    // Gerar token após verificação MFA bem-sucedida
    return new TokenResponse(jwt.generateToken(u.getUsername(), u.getRoles()));
}

📈 Fluxo de Uso

1. Setup MFA

# 1. Usuário autenticado faz POST /auth/mfa/setup
curl -X POST http://localhost:8080/auth/mfa/setup \
  -H "Authorization: Bearer <token>"

# Resposta:
{
  "secret": "JBSWY3DPEHPK3PXP",
  "qrCodeBase64": "iVBORw0KGgoAAAANSUhEUgAA..."
}

# 2. Frontend exibe QR code e usuário escaneia com app autenticador
# 3. Usuário insere código de 6 dígitos do app

2. Verify & Enable MFA

# POST /auth/mfa/verify com código do app
curl -X POST http://localhost:8080/auth/mfa/verify \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'

# Resposta:
{
  "message": "MFA enabled successfully",
  "enabled": true
}

3. Login com MFA

# 1. Login normal (retorna mfaRequired: true)
curl -X POST http://localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "user", "password": "pass"}'

# Resposta:
{
  "token": null,
  "mfaRequired": true
}

# 2. Verificar código MFA
curl -X POST http://localhost:8080/auth/login/mfa \
  -H "Content-Type: application/json" \
  -d '{"username": "user", "code": "123456"}'

# Resposta:
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

🎓 Lições Aprendidas

✅ O que funcionou bem

  • TOTP é padrão da indústria: Compatível com Google Authenticator, Authy, 1Password, etc.
  • QR code facilita setup: Usuários não precisam digitar secret manualmente
  • Reutilização de secrets não verificados: Evita gerar múltiplos secrets desnecessários
  • Integração transparente com login: Fluxo de login existente não precisa mudar drasticamente

⚠️ Desafios e soluções

  • Time drift: Códigos TOTP dependem de tempo sincronizado. Usamos SystemTimeProvider que assume servidor com NTP.
  • Backup codes: Não implementamos ainda, mas seria útil para recuperação de acesso.
  • Rate limiting em verificação: Códigos TOTP podem ser bruteforced. Considerar rate limiting no endpoint de verificação.

📚 Recursos

🔗 Links

Next episode → Day 19/30 — OAuth2 Social Login