"Social login reduz fricção de onboarding e aumenta conversão. Mas validação de tokens é crítica para segurança." — #30DiasJava Security Notes

🎯 Objetivo do Day 19

Para permitir que usuários façam login usando suas contas sociais (Google, Apple, Microsoft), o Day 19 do #30DiasJava implementou OAuth2 Social Login com validação de tokens, criação automática de contas e vinculação com contas existentes.

🛠️ O que foi implementado

✅ OAuth2 Token Validation

  • Google token validation: Validação de ID tokens do Google usando Google API Client
  • Apple token validation: Estrutura para validação de tokens Apple (TODO: implementação completa)
  • Microsoft token validation: Estrutura para validação de tokens Microsoft (TODO: implementação completa)
  • Token verification: Verificação de assinatura e claims dos tokens

✅ User Account Management

  • Automatic account creation: Criação automática de contas para novos usuários OAuth2
  • Account linking: Vinculação de OAuth2 a contas existentes (por email)
  • Provider tracking: Rastreamento de provedor OAuth2 (GOOGLE, APPLE, MICROSOFT)
  • Unique username generation: Geração de usernames únicos baseados em email + provider

✅ Social Login Flow

  • Social login endpoint: Endpoint /auth/social para login social
  • Token validation: Validação de token antes de criar/autenticar usuário
  • MFA integration: Verificação de MFA também para login social
  • JWT token generation: Geração de JWT após autenticação social bem-sucedida

✅ Database Schema

  • Email field: Campo email na tabela users para vinculação
  • Provider ID: Campo provider_id para ID único do provedor
  • Auth provider: Campo auth_provider para identificar provedor (GOOGLE, APPLE, MICROSOFT)
  • Nullable password: Campo password_hash nullable para usuários apenas OAuth2

📊 Arquitetura

┌─────────────────────────────────────────────────────────┐
│              OAuth2 Provider (Google/Apple/MS)          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐ │
│  │   Google     │  │    Apple     │  │  Microsoft   │ │
│  │   Sign-In    │  │  Sign-In     │  │  Sign-In     │ │
│  └──────────────┘  └──────────────┘  └──────────────┘ │
└──────────────────────┬──────────────────────────────────┘
                       │
                       │ ID Token
                       ▼
┌─────────────────────────────────────────────────────────┐
│              Frontend Application                        │
│  ┌──────────────────────────────────────────────────┐  │
│  │  POST /auth/social                                │  │
│  │  { "idToken": "...", "provider": "GOOGLE" }       │  │
│  └──────────────────────────────────────────────────┘  │
└──────────────────────┬──────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────┐
│              AuthController                              │
│  ┌──────────────────────────────────────────────────┐  │
│  │  @PostMapping("/social")                          │  │
│  │  socialLogin(SocialLoginRequest)                  │  │
│  └──────────────────────────────────────────────────┘  │
└──────────────────────┬──────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────┐
│              OAuth2Service                              │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐ │
│  │  Validate    │  │   Extract    │  │   Return    │ │
│  │   Token      │  │   User Info   │  │   UserInfo  │ │
│  └──────────────┘  └──────────────┘  └──────────────┘ │
└──────────────────────┬──────────────────────────────────┘
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│   Google     │ │    Apple     │ │  Microsoft   │
│   Verifier   │ │  (TODO)      │ │  (TODO)      │
└──────────────┘ └──────────────┘ └──────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────┐
│              User Repository                            │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐ │
│  │  Find by     │  │  Create new  │  │  Link to     │ │
│  │  provider    │  │   user       │  │  existing    │ │
│  └──────────────┘  └──────────────┘  └──────────────┘ │
└──────────────────────┬──────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────┐
│              JWT Token Generation                       │
└─────────────────────────────────────────────────────────┘

💻 Implementação

Dependências (pom.xml)

<!-- OAuth2 Client -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
  <groupId>com.google.api-client</groupId>
  <artifactId>google-api-client</artifactId>
  <version>2.2.0</version>
</dependency>

Database Migration (V1001__add_social_auth_fields.sql)

-- Add email, provider_id, auth_provider to users table
ALTER TABLE users 
  ADD COLUMN IF NOT EXISTS email VARCHAR(255),
  ADD COLUMN IF NOT EXISTS provider_id VARCHAR(255),
  ADD COLUMN IF NOT EXISTS auth_provider VARCHAR(50);

-- Make password_hash nullable (OAuth2 users don't have passwords)
ALTER TABLE users 
  ALTER COLUMN password_hash DROP NOT NULL;

-- Create indexes
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider_id, auth_provider);

OAuth2Service

@Service
public class OAuth2Service {
    private final GoogleIdTokenVerifier googleVerifier;
    private final String googleClientId;

    public OAuth2Service(@Value("${app.oauth2.google.client-id:}") String googleClientId) {
        this.googleClientId = googleClientId;
        
        // Inicializar Google verifier apenas se client ID estiver configurado
        if (googleClientId != null && !googleClientId.isEmpty()) {
            this.googleVerifier = new GoogleIdTokenVerifier.Builder(
                new NetHttpTransport(),
                new GsonFactory()
            )
            .setAudience(Collections.singletonList(googleClientId))
            .build();
        } else {
            this.googleVerifier = null;
        }
    }

    public OAuth2UserInfo validateToken(String idToken, String provider) {
        return switch (provider.toUpperCase()) {
            case "GOOGLE" -> validateGoogleToken(idToken);
            case "APPLE" -> validateAppleToken(idToken);
            case "MICROSOFT" -> validateMicrosoftToken(idToken);
            default -> throw new IllegalArgumentException("Unsupported OAuth2 provider: " + provider);
        };
    }

    private OAuth2UserInfo validateGoogleToken(String idToken) {
        if (googleVerifier == null) {
            // Em desenvolvimento, aceitar token sem validação real
            // Em produção, sempre validar
            return new OAuth2UserInfo(
                "dev-user-" + System.currentTimeMillis() + "@google.local",
                "google_" + System.currentTimeMillis(),
                "Google User"
            );
        }

        try {
            GoogleIdToken token = googleVerifier.verify(idToken);
            if (token == null) {
                throw new IllegalArgumentException("Invalid Google ID token");
            }

            GoogleIdToken.Payload payload = token.getPayload();
            String email = payload.getEmail();
            String userId = payload.getSubject();
            String name = (String) payload.get("name");

            return new OAuth2UserInfo(email, userId, name);
        } catch (Exception e) {
            throw new IllegalArgumentException("Failed to validate Google token: " + e.getMessage(), e);
        }
    }

    private OAuth2UserInfo validateAppleToken(String idToken) {
        // TODO: Implementar validação real do Apple ID token
        // Apple usa JWT assinado com chave pública da Apple
        return new OAuth2UserInfo(
            "dev-user-" + System.currentTimeMillis() + "@apple.local",
            "apple_" + System.currentTimeMillis(),
            "Apple User"
        );
    }

    private OAuth2UserInfo validateMicrosoftToken(String idToken) {
        // TODO: Implementar validação real do Microsoft Azure AD token
        // Microsoft usa JWT assinado com chave pública do Azure AD
        return new OAuth2UserInfo(
            "dev-user-" + System.currentTimeMillis() + "@microsoft.local",
            "microsoft_" + System.currentTimeMillis(),
            "Microsoft User"
        );
    }

    public record OAuth2UserInfo(
        String email,
        String providerId,
        String name
    ) {}
}

AuthController Social Login

@PostMapping("/social")
public TokenResponse socialLogin(@Valid @RequestBody SocialLoginRequest req) {
    // Validar token OAuth2 com o provedor
    OAuth2Service.OAuth2UserInfo userInfo = oauth2Service.validateToken(
        req.idToken(), 
        req.provider()
    );
    
    // Verificar se usuário já existe pelo provider
    String provider = req.provider().toUpperCase();
    var existingUser = users.findByProviderIdAndAuthProvider(
        userInfo.providerId(), 
        provider
    )
    .orElseGet(() -> {
        // Verificar se email já existe (usuário pode ter se registrado com email/password)
        var userByEmail = users.findByEmail(userInfo.email()).orElse(null);
        
        if (userByEmail != null) {
            // Vincular OAuth2 a conta existente
            userByEmail.setProviderId(userInfo.providerId());
            userByEmail.setAuthProvider(provider);
            if (userByEmail.getEmail() == null) {
                userByEmail.setEmail(userInfo.email());
            }
            users.save(userByEmail);
            return userByEmail;
        }
        
        // Criar novo usuário
        String username = generateUniqueUsername(provider, userInfo.email());
        var u = new AppUser();
        u.setUsername(username);
        u.setEmail(userInfo.email());
        u.setProviderId(userInfo.providerId());
        u.setAuthProvider(provider);
        u.setRoles(Set.of("USER"));
        u.setEnabled(true);
        users.save(u);
        return u;
    });
    
    // Verificar se MFA está habilitado (mesmo para login social)
    boolean mfaRequired = mfaService.isMfaEnabled(existingUser.getId());
    if (mfaRequired) {
        throw new IllegalStateException(
            "MFA verification required. Please use /auth/login/mfa endpoint."
        );
    }
    
    return new TokenResponse(
        jwt.generateToken(existingUser.getUsername(), existingUser.getRoles())
    );
}

private String generateUniqueUsername(String provider, String email) {
    // Usar email como base, removendo @ e domínio
    String base = email.split("@")[0].toLowerCase().replaceAll("[^a-z0-9]", "");
    String username = base + "_" + provider.toLowerCase();
    
    // Garantir unicidade
    int counter = 0;
    String finalUsername = username;
    while (users.existsByUsername(finalUsername)) {
        counter++;
        finalUsername = username + counter;
    }
    
    return finalUsername;
}

Configuration (application.yml)

app:
  oauth2:
    google:
      client-id: ${GOOGLE_CLIENT_ID:}
      # client-secret não necessário para ID token validation

📈 Fluxo de Uso

1. Google Sign-In (Frontend)

// Frontend: Google Sign-In
async function handleGoogleSignIn() {
  const response = await google.accounts.oauth2.initTokenClient({
    client_id: 'YOUR_GOOGLE_CLIENT_ID',
    scope: 'email profile',
    callback: async (tokenResponse) => {
      // Obter ID token
      const idToken = tokenResponse.access_token;
      
      // Enviar para backend
      const result = await fetch('/auth/social', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          idToken: idToken,
          provider: 'GOOGLE'
        })
      });
      
      const data = await result.json();
      // Salvar JWT token
      localStorage.setItem('auth_token', data.token);
    }
  });
  
  response.requestAccessToken();
}

2. Social Login Request

# POST /auth/social
curl -X POST http://localhost:8080/auth/social \
  -H "Content-Type: application/json" \
  -d '{
    "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ij...",
    "provider": "GOOGLE"
  }'

# Resposta (novo usuário):
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

# Resposta (usuário existente):
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

3. Account Linking

Se um usuário já tem conta com email user@example.com e faz login social com o mesmo email, a conta é automaticamente vinculada:

-- Antes do login social
SELECT * FROM users WHERE email = 'user@example.com';
-- id: 1, username: 'user', provider_id: NULL, auth_provider: NULL

-- Após login social com Google
SELECT * FROM users WHERE email = 'user@example.com';
-- id: 1, username: 'user', provider_id: 'google_123456', auth_provider: 'GOOGLE'

🎓 Lições Aprendidas

✅ O que funcionou bem

  • Google token validation: Google API Client facilita validação de tokens
  • Account linking: Vinculação automática por email reduz duplicação de contas
  • Unique username generation: Geração automática de usernames evita conflitos
  • MFA integration: Login social também respeita MFA quando habilitado

⚠️ Desafios e soluções

  • Apple/Microsoft validation: Ainda não implementado. Requer validação de JWT com chaves públicas dos provedores.
  • Token expiration: Tokens OAuth2 expiram. Frontend deve lidar com refresh tokens.
  • Development mode: Em desenvolvimento, aceitamos tokens sem validação real. Em produção, sempre validar.

📚 Recursos

🔗 Links

Next episode → Day 20/30 — Rate Limiting com Redis