"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
SystemTimeProviderque 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
- TOTP Library: https://github.com/samstevens/totp
- RFC 6238 (TOTP): https://tools.ietf.org/html/rfc6238
- Google Authenticator: https://support.google.com/accounts/answer/1066447
- ZXing QR Code: https://github.com/zxing/zxing
🔗 Links
- Repositório: https://github.com/adelmonsouza/30DiasJava-Day18-MFA
Next episode → Day 19/30 — OAuth2 Social Login