diff --git a/build.gradle.kts b/build.gradle.kts index 09ca0fa..29808d4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-jackson") diff --git a/docs/oauth2-workflow.md b/docs/oauth2-workflow.md new file mode 100644 index 0000000..5720c2b --- /dev/null +++ b/docs/oauth2-workflow.md @@ -0,0 +1,424 @@ +# OAuth2 Authentication Workflow + +This document describes the OAuth2 authentication flow for Google and GitHub login integration. + +## Overview + +The application supports OAuth2 login with Google and GitHub providers. After successful OAuth2 authentication, users +receive JWT tokens (access + refresh) identical to password-based login, enabling a unified authentication experience. + +## Supported Providers + +| Provider | Registration ID | Scopes | +|----------|-----------------|------------------------------| +| Google | `google` | `openid`, `profile`, `email` | +| GitHub | `github` | `read:user`, `user:email` | + +## Authentication Flow + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Frontend │ │ Backend │ │ Provider │ │ Backend │ │ Frontend │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ │ │ + │ 1. Click │ │ │ │ + │ "Login with │ │ │ │ + │ Google" │ │ │ │ + │───────────────>│ │ │ │ + │ │ │ │ │ + │ 2. Redirect to │ │ │ │ + │ /api/auth/ │ │ │ │ + │ oauth2/ │ │ │ │ + │ authorize/ │ │ │ │ + │ google │ │ │ │ + │<───────────────│ │ │ │ + │ │ │ │ │ + │ 3. Redirect to Google consent │ │ │ + │────────────────────────────────>│ │ │ + │ │ │ │ │ + │ 4. User authenticates & consents│ │ │ + │<────────────────────────────────│ │ │ + │ │ │ │ │ + │ 5. Redirect to callback with │ │ │ + │ authorization code │ │ │ + │────────────────────────────────────────────────>│ │ + │ │ │ │ │ + │ │ │ 6. Exchange │ │ + │ │ │ code for │ │ + │ │ │ tokens │ │ + │ │ │<───────────────│ │ + │ │ │ │ │ + │ │ │ 7. Fetch user │ │ + │ │ │ info │ │ + │ │ │<───────────────│ │ + │ │ │ │ │ + │ │ │ 8. Process │ │ + │ │ │ login: │ │ + │ │ │ - Find/ │ │ + │ │ │ create │ │ + │ │ │ user │ │ + │ │ │ - Generate │ │ + │ │ │ JWT │ │ + │ │ │───────────────>│ │ + │ │ │ │ │ + │ 9. Redirect to frontend with tokens in URL fragment │ + │<──────────────────────────────────────────────────────────────────│ + │ │ │ │ │ + │ 10. Extract tokens from fragment, store, use for API calls │ + │───────────────────────────────────────────────────────────────────> +``` + +## Step-by-Step Flow + +### 1. Initiate OAuth2 Login + +Frontend redirects user to the authorization endpoint: + +``` +GET /api/auth/oauth2/authorize/{provider} +``` + +Example: `/api/auth/oauth2/authorize/google` + +### 2. Provider Authentication + +Spring Security redirects to the OAuth2 provider's consent page where the user: + +- Authenticates with their provider account +- Grants permission for requested scopes + +### 3. Callback Processing + +Provider redirects to the callback URL with an authorization code: + +``` +GET /api/auth/oauth2/callback/{provider}?code=xxx&state=xxx +``` + +### 4. Token Exchange & User Processing + +The backend: + +1. Exchanges the authorization code for provider tokens +2. Fetches user info from the provider +3. Processes the OAuth2 login: + - **Existing OAuth2 user**: Logs in via existing OAuth2 connection + - **Email match**: Auto-links OAuth2 to existing account (if email verified) + - **New user**: Creates account with OAuth2 connection + +### 5. JWT Generation & Redirect + +Backend generates JWT tokens and redirects to frontend: + +``` +{successRedirectUrl}#accessToken={jwt}&refreshToken={refreshToken} +``` + +### 6. Frontend Token Handling + +Frontend extracts tokens from URL fragment and stores them for API calls. + +## User Matching Logic + +``` +┌─────────────────────────────────────────────────────────────┐ +│ OAuth2 Login Request │ +│ (provider, providerId, email) │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ Find by provider + │ + │ providerId │ + └───────────┬───────────┘ + │ + ┌─────────────┴─────────────┐ + │ │ + ▼ ▼ + ┌──────────┐ ┌──────────────┐ + │ Found │ │ Not Found │ + └────┬─────┘ └──────┬───────┘ + │ │ + ▼ ▼ + ┌──────────┐ ┌─────────────────────┐ + │ Login │ │ Email verified? │ + │ User │ └──────────┬──────────┘ + └──────────┘ │ + ┌───────────┴───────────┐ + │ │ + ▼ ▼ + ┌────────────┐ ┌────────────┐ + │ Yes │ │ No │ + └─────┬──────┘ └─────┬──────┘ + │ │ + ▼ │ + ┌─────────────────┐ │ + │ Find user by │ │ + │ email │ │ + └────────┬────────┘ │ + │ │ + ┌─────────────┴─────────────┐ │ + │ │ │ + ▼ ▼ │ + ┌──────────┐ ┌────────────┐ │ + │ Found │ │ Not Found │ │ + └────┬─────┘ └─────┬──────┘ │ + │ │ │ + ▼ ▼ │ + ┌───────────────┐ ┌─────────────────┐ │ + │ Auto-link │ │ Create new user │<─┘ + │ OAuth2 to │ │ + OAuth2 │ + │ existing user │ │ connection │ + └───────────────┘ └─────────────────┘ +``` + +## API Endpoints + +### Public Endpoints + +| Method | Endpoint | Description | Handler | +|--------|-----------------------------------------|---------------------------------|----------------------------| +| GET | `/api/auth/oauth2/providers` | List available OAuth2 providers | `OAuth2Controller` | +| GET | `/api/auth/oauth2/authorize/{provider}` | Initiate OAuth2 flow | Spring Security (built-in) | +| GET | `/api/auth/oauth2/callback/{provider}` | OAuth2 callback | Spring Security (built-in) | + +> **Note**: The `/authorize` and `/callback` endpoints are not custom REST controllers. They are built-in Spring +> Security OAuth2 endpoints configured in `SecurityConfig.java`: +> ```java +> http.oauth2Login(oauth2 -> oauth2 +> .authorizationEndpoint(auth -> auth.baseUri("/api/auth/oauth2/authorize")) +> .redirectionEndpoint(redirect -> redirect.baseUri("/api/auth/oauth2/callback/*")) +> ``` + +### Protected Endpoints (require JWT) + +| Method | Endpoint | Description | +|--------|-----------------------------------|---------------------------------| +| GET | `/api/users/me/oauth2` | List connected OAuth2 providers | +| DELETE | `/api/users/me/oauth2/{provider}` | Unlink OAuth2 provider | + +## Response Examples + +### List Available Providers + +```http +GET /api/auth/oauth2/providers +``` + +```json +{ + "message": "OAuth2 providers retrieved", + "data": [ + { + "provider": "google", + "authorizationUrl": "/api/auth/oauth2/authorize/google" + }, + { + "provider": "github", + "authorizationUrl": "/api/auth/oauth2/authorize/github" + } + ], + "timestamp": "2025-01-15T10:30:00.000" +} +``` + +### List Connected Providers + +```http +GET /api/users/me/oauth2 +Authorization: Bearer {accessToken} +``` + +```json +{ + "message": "OAuth2 connections retrieved", + "data": [ + { + "provider": "google", + "email": "user@gmail.com", + "name": "John Doe", + "avatarUrl": "https://lh3.googleusercontent.com/...", + "connectedAt": "2025-01-15T10:30:00.000" + } + ], + "timestamp": "2025-01-15T10:30:00.000" +} +``` + +### Unlink Provider + +```http +DELETE /api/users/me/oauth2/google +Authorization: Bearer {accessToken} +``` + +```json +{ + "message": "OAuth2 provider unlinked successfully", + "data": null, + "timestamp": "2025-01-15T10:30:00.000" +} +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Required | +|-------------------------------|-----------------------------|-----------------------------------------------------| +| `GOOGLE_CLIENT_ID` | Google OAuth2 client ID | Yes (for Google) | +| `GOOGLE_CLIENT_SECRET` | Google OAuth2 client secret | Yes (for Google) | +| `GITHUB_CLIENT_ID` | GitHub OAuth2 client ID | Yes (for GitHub) | +| `GITHUB_CLIENT_SECRET` | GitHub OAuth2 client secret | Yes (for GitHub) | +| `OAUTH2_SUCCESS_REDIRECT_URL` | Frontend callback URL | No (default: `http://localhost:3000/auth/callback`) | +| `OAUTH2_FAILURE_REDIRECT_URL` | Frontend error URL | No (default: `http://localhost:3000/auth/error`) | + +### Application Configuration + +```yaml +spring: + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID:} + client-secret: ${GOOGLE_CLIENT_SECRET:} + scope: openid, profile, email + redirect-uri: "{baseUrl}/api/auth/oauth2/callback/google" + github: + client-id: ${GITHUB_CLIENT_ID:} + client-secret: ${GITHUB_CLIENT_SECRET:} + scope: read:user, user:email + redirect-uri: "{baseUrl}/api/auth/oauth2/callback/github" + +app: + oauth2: + success-redirect-url: ${OAUTH2_SUCCESS_REDIRECT_URL:http://localhost:3000/auth/callback} + failure-redirect-url: ${OAUTH2_FAILURE_REDIRECT_URL:http://localhost:3000/auth/error} +``` + +## Provider Setup + +### Google OAuth2 + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select existing +3. Navigate to "APIs & Services" > "Credentials" +4. Create OAuth 2.0 Client ID (Web application) +5. Add authorized redirect URI: `{your-domain}/api/auth/oauth2/callback/google` +6. Copy Client ID and Client Secret + +### GitHub OAuth2 + +1. Go to [GitHub Developer Settings](https://github.com/settings/developers) +2. Create a new OAuth App +3. Set Authorization callback URL: `{your-domain}/api/auth/oauth2/callback/github` +4. Copy Client ID and generate Client Secret + +## Account Management Rules + +### Linking OAuth2 Accounts + +- Users can link multiple OAuth2 providers to their account +- Each provider account (provider + providerId) can only be linked to one user +- Attempting to link an already-linked provider account returns an error + +### Unlinking OAuth2 Accounts + +- Users can unlink OAuth2 providers from their account +- **Constraint**: Users must maintain at least one login method: + - If user has a password, any OAuth2 provider can be unlinked + - If user has no password, at least one OAuth2 provider must remain linked + +## Database Schema + +```sql +CREATE TABLE oauth2_connections +( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + provider VARCHAR(50) NOT NULL, -- GOOGLE, GITHUB + provider_id VARCHAR(255) NOT NULL, -- Provider's user ID + email VARCHAR(255), + name VARCHAR(255), + avatar_url VARCHAR(512), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_oauth2_provider_id UNIQUE (provider, provider_id) +); + +CREATE INDEX idx_oauth2_connections_user_id ON oauth2_connections (user_id); +``` + +## Error Handling + +### OAuth2 Authentication Failures + +- User denies consent +- Provider service unavailable +- Invalid/expired authorization code + +Failures redirect to: `{failureRedirectUrl}?error={encoded_error_message}` + +### API Errors + +| Error | HTTP Status | Message | +|---------------------------|-------------|-------------------------------------------------------------| +| Provider not linked | 400 | "Provider {provider} is not linked to this account" | +| Cannot unlink last method | 400 | "Cannot unlink the last login method" | +| Provider already linked | 400 | "This {provider} account is already linked to another user" | +| Invalid provider | 500 | "Unknown OAuth2 provider: {provider}" | +| Unauthenticated | 401 | "Unauthorized" | + +## Frontend Integration Example + +```javascript +// Initiate OAuth2 login +function loginWithGoogle() { + window.location.href = '/api/auth/oauth2/authorize/google'; +} + +// Handle callback (on /auth/callback page) +function handleOAuth2Callback() { + const hash = window.location.hash.substring(1); + const params = new URLSearchParams(hash); + + const accessToken = params.get('accessToken'); + const refreshToken = params.get('refreshToken'); + + if (accessToken && refreshToken) { + // Store tokens + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + + // Redirect to app + window.location.href = '/dashboard'; + } +} + +// Handle error (on /auth/error page) +function handleOAuth2Error() { + const params = new URLSearchParams(window.location.search); + const error = params.get('error'); + + if (error) { + console.error('OAuth2 error:', decodeURIComponent(error)); + // Show error to user + } +} +``` + +## Security Considerations + +1. **Token Delivery**: Tokens are delivered via URL fragment (`#`) rather than query parameters (`?`) to prevent them + from being logged in server access logs or browser history. + +2. **Auto-linking**: Only occurs when the OAuth2 provider confirms the email is verified, preventing account takeover + via unverified emails. + +3. **CSRF Protection**: Spring Security's OAuth2 client includes state parameter validation to prevent CSRF attacks. + +4. **Scope Limitation**: Only minimal scopes are requested (profile info and email). + +5. **Token Rotation**: After OAuth2 login, standard JWT token rotation applies for refresh tokens. diff --git a/src/main/java/org/nkcoder/infrastructure/config/OAuth2Properties.java b/src/main/java/org/nkcoder/infrastructure/config/OAuth2Properties.java new file mode 100644 index 0000000..ace77f0 --- /dev/null +++ b/src/main/java/org/nkcoder/infrastructure/config/OAuth2Properties.java @@ -0,0 +1,20 @@ +package org.nkcoder.infrastructure.config; + +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@ConfigurationProperties(prefix = "app.oauth2") +@Validated +public record OAuth2Properties( + @NotBlank String successRedirectUrl, @NotBlank String failureRedirectUrl) { + + public OAuth2Properties { + if (successRedirectUrl == null || successRedirectUrl.isBlank()) { + successRedirectUrl = "http://localhost:3000/auth/callback"; + } + if (failureRedirectUrl == null || failureRedirectUrl.isBlank()) { + failureRedirectUrl = "http://localhost:3000/auth/error"; + } + } +} diff --git a/src/main/java/org/nkcoder/shared/util/UrlUtils.java b/src/main/java/org/nkcoder/shared/util/UrlUtils.java new file mode 100644 index 0000000..c095986 --- /dev/null +++ b/src/main/java/org/nkcoder/shared/util/UrlUtils.java @@ -0,0 +1,21 @@ +package org.nkcoder.shared.util; + +import jakarta.servlet.http.HttpServletRequest; + +public final class UrlUtils { + + private UrlUtils() { + // Utility class + } + + public static String getBaseUrl(HttpServletRequest request) { + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int port = request.getServerPort(); + + if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) { + return scheme + "://" + serverName; + } + return scheme + "://" + serverName + ":" + port; + } +} diff --git a/src/main/java/org/nkcoder/user/application/dto/command/OAuth2LoginCommand.java b/src/main/java/org/nkcoder/user/application/dto/command/OAuth2LoginCommand.java new file mode 100644 index 0000000..e149d34 --- /dev/null +++ b/src/main/java/org/nkcoder/user/application/dto/command/OAuth2LoginCommand.java @@ -0,0 +1,13 @@ +package org.nkcoder.user.application.dto.command; + +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.nkcoder.user.domain.model.OAuth2ProviderId; + +/** Command for OAuth2 login containing user info from OAuth2 provider. */ +public record OAuth2LoginCommand( + OAuth2Provider provider, + OAuth2ProviderId providerId, + String email, + String name, + String avatarUrl, + boolean emailVerified) {} diff --git a/src/main/java/org/nkcoder/user/application/service/OAuth2ApplicationService.java b/src/main/java/org/nkcoder/user/application/service/OAuth2ApplicationService.java new file mode 100644 index 0000000..d89fe76 --- /dev/null +++ b/src/main/java/org/nkcoder/user/application/service/OAuth2ApplicationService.java @@ -0,0 +1,212 @@ +package org.nkcoder.user.application.service; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import org.nkcoder.shared.kernel.domain.event.DomainEventPublisher; +import org.nkcoder.shared.kernel.domain.event.UserRegisteredEvent; +import org.nkcoder.shared.kernel.exception.AuthenticationException; +import org.nkcoder.shared.kernel.exception.ValidationException; +import org.nkcoder.user.application.dto.command.OAuth2LoginCommand; +import org.nkcoder.user.application.dto.response.AuthResult; +import org.nkcoder.user.domain.model.Email; +import org.nkcoder.user.domain.model.OAuth2Connection; +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.nkcoder.user.domain.model.RefreshToken; +import org.nkcoder.user.domain.model.TokenFamily; +import org.nkcoder.user.domain.model.TokenPair; +import org.nkcoder.user.domain.model.User; +import org.nkcoder.user.domain.model.UserId; +import org.nkcoder.user.domain.model.UserName; +import org.nkcoder.user.domain.repository.OAuth2ConnectionRepository; +import org.nkcoder.user.domain.repository.RefreshTokenRepository; +import org.nkcoder.user.domain.repository.UserRepository; +import org.nkcoder.user.domain.service.TokenGenerator; +import org.nkcoder.user.domain.service.TokenRotationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class OAuth2ApplicationService { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2ApplicationService.class); + + private static final String USER_NOT_FOUND = "User not found"; + private static final String CANNOT_UNLINK_LAST_LOGIN_METHOD = "Cannot unlink the last login method"; + private static final String PROVIDER_NOT_LINKED = "OAuth2 provider is not linked to this account"; + + private final UserRepository userRepository; + private final OAuth2ConnectionRepository oauth2ConnectionRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final TokenGenerator tokenGenerator; + private final TokenRotationService tokenRotationService; + private final DomainEventPublisher eventPublisher; + + public OAuth2ApplicationService( + UserRepository userRepository, + OAuth2ConnectionRepository oauth2ConnectionRepository, + RefreshTokenRepository refreshTokenRepository, + TokenGenerator tokenGenerator, + TokenRotationService tokenRotationService, + DomainEventPublisher eventPublisher) { + this.userRepository = userRepository; + this.oauth2ConnectionRepository = oauth2ConnectionRepository; + this.refreshTokenRepository = refreshTokenRepository; + this.tokenGenerator = tokenGenerator; + this.tokenRotationService = tokenRotationService; + this.eventPublisher = eventPublisher; + } + + /** Process OAuth2 login. Finds existing user by provider ID or email, or creates a new user. */ + @Transactional + public AuthResult processOAuth2Login(OAuth2LoginCommand command) { + logger.debug("Processing OAuth2 login for provider: {}", command.provider()); + + // 1. Check if OAuth2 connection exists + Optional existingConnection = + oauth2ConnectionRepository.findByProviderAndProviderId(command.provider(), command.providerId()); + + if (existingConnection.isPresent()) { + // Login existing user via OAuth2 connection + User user = userRepository + .findById(existingConnection.get().getUserId()) + .orElseThrow(() -> new AuthenticationException(USER_NOT_FOUND)); + logger.debug( + "Found existing OAuth2 connection for user: {}", + user.getEmail().value()); + return loginUser(user); + } + + // 2. Check if user with same email exists (auto-link) + if (command.email() != null && command.emailVerified()) { + Email email = Email.of(command.email()); + Optional existingUser = userRepository.findByEmail(email); + + if (existingUser.isPresent()) { + // Link OAuth2 to existing user + User user = existingUser.get(); + createOAuth2Connection(user.getId(), command); + logger.debug("Linked OAuth2 provider {} to existing user: {}", command.provider(), email.value()); + return loginUser(user); + } + } + + // 3. Create new user + User newUser = registerNewUser(command); + createOAuth2Connection(newUser.getId(), command); + logger.debug("Created new user via OAuth2: {}", newUser.getEmail().value()); + + return loginUser(newUser); + } + + /** Link OAuth2 account to an existing authenticated user. */ + @Transactional + public void linkOAuth2Account(UserId userId, OAuth2LoginCommand command) { + logger.debug("Linking OAuth2 provider {} to user: {}", command.provider(), userId); + + User user = userRepository.findById(userId).orElseThrow(() -> new AuthenticationException(USER_NOT_FOUND)); + + // Check if this provider ID is already linked to another account + if (oauth2ConnectionRepository.existsByProviderAndProviderId(command.provider(), command.providerId())) { + throw new ValidationException("This OAuth2 account is already linked to another user"); + } + + createOAuth2Connection(userId, command); + logger.debug( + "Successfully linked OAuth2 provider {} to user: {}", + command.provider(), + user.getEmail().value()); + } + + /** Unlink OAuth2 account from user. Ensures user has another login method. */ + @Transactional + public void unlinkOAuth2Account(UserId userId, OAuth2Provider provider) { + logger.debug("Unlinking OAuth2 provider {} from user: {}", provider, userId); + + User user = userRepository.findById(userId).orElseThrow(() -> new AuthenticationException(USER_NOT_FOUND)); + + // Ensure user has another login method + boolean hasPassword = user.getPassword() != null; + int oAuth2ConnectionCount = oauth2ConnectionRepository.countByUserId(userId); + + if (!hasPassword && oAuth2ConnectionCount <= 1) { + throw new ValidationException(CANNOT_UNLINK_LAST_LOGIN_METHOD); + } + + // Check if the provider is actually linked + List connections = oauth2ConnectionRepository.findByUserId(userId); + boolean isProviderLinked = connections.stream().anyMatch(c -> c.getProvider() == provider); + + if (!isProviderLinked) { + throw new ValidationException(PROVIDER_NOT_LINKED); + } + + oauth2ConnectionRepository.deleteByUserIdAndProvider(userId, provider); + logger.debug( + "Successfully unlinked OAuth2 provider {} from user: {}", + provider, + user.getEmail().value()); + } + + /** Get connected OAuth2 providers for a user. */ + @Transactional(readOnly = true) + public List getConnectedProviders(UserId userId) { + return oauth2ConnectionRepository.findByUserId(userId); + } + + private User registerNewUser(OAuth2LoginCommand command) { + Email email = Email.of(command.email()); + UserName name = command.name() != null ? UserName.of(command.name()) : generateNameFromEmail(email); + + User newUser = User.registerWithOAuth2(email, name); + User savedUser = userRepository.save(newUser); + + eventPublisher.publish(new UserRegisteredEvent( + savedUser.getId().value(), + savedUser.getEmail().value(), + savedUser.getName().value())); + + return savedUser; + } + + private void createOAuth2Connection(UserId userId, OAuth2LoginCommand command) { + OAuth2Connection connection = OAuth2Connection.create( + userId, command.provider(), command.providerId(), command.email(), command.name(), command.avatarUrl()); + + oauth2ConnectionRepository.save(connection); + } + + private AuthResult loginUser(User user) { + userRepository.updateLastLoginAt(user.getId(), LocalDateTime.now()); + + TokenFamily tokenFamily = TokenFamily.generate(); + TokenPair tokens = tokenRotationService.generateTokens(user, tokenFamily); + + RefreshToken refreshToken = RefreshToken.create( + tokens.refreshToken(), tokenFamily, user.getId(), tokenGenerator.getRefreshTokenExpiry()); + refreshTokenRepository.save(refreshToken); + + return AuthResult.of(user.getId().value(), user.getEmail().value(), user.getRole(), tokens); + } + + private UserName generateNameFromEmail(Email email) { + return Optional.of(email.value()) + .map(e -> e.split("@")[0]) + .map(localPart -> localPart.replace(".", " ").replace("_", " ").replace("-", " ")) + .map(this::capitalizeWords) + .map(UserName::of) + .orElseGet(() -> UserName.of("User")); + } + + private String capitalizeWords(String input) { + return Arrays.stream(input.split("\\s+")) + .filter(part -> !part.isEmpty()) + .map(part -> + part.substring(0, 1).toUpperCase() + part.substring(1).toLowerCase()) + .collect(Collectors.joining(" ")); + } +} diff --git a/src/main/java/org/nkcoder/user/domain/model/OAuth2Connection.java b/src/main/java/org/nkcoder/user/domain/model/OAuth2Connection.java new file mode 100644 index 0000000..8b82473 --- /dev/null +++ b/src/main/java/org/nkcoder/user/domain/model/OAuth2Connection.java @@ -0,0 +1,115 @@ +package org.nkcoder.user.domain.model; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; + +/** Domain entity representing a connection between a user and an OAuth2 provider. */ +public class OAuth2Connection { + + private final UUID id; + private final UserId userId; + private final OAuth2Provider provider; + private final OAuth2ProviderId providerId; + private final String email; + private final String name; + private final String avatarUrl; + private final LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private OAuth2Connection( + UUID id, + UserId userId, + OAuth2Provider provider, + OAuth2ProviderId providerId, + String email, + String name, + String avatarUrl, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + this.id = Objects.requireNonNull(id, "id cannot be null"); + this.userId = Objects.requireNonNull(userId, "userId cannot be null"); + this.provider = Objects.requireNonNull(provider, "provider cannot be null"); + this.providerId = Objects.requireNonNull(providerId, "providerId cannot be null"); + this.email = email; + this.name = name; + this.avatarUrl = avatarUrl; + this.createdAt = Objects.requireNonNull(createdAt, "createdAt cannot be null"); + this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt cannot be null"); + } + + /** Factory method for creating a new OAuth2 connection. */ + public static OAuth2Connection create( + UserId userId, + OAuth2Provider provider, + OAuth2ProviderId providerId, + String email, + String name, + String avatarUrl) { + LocalDateTime now = LocalDateTime.now(); + return new OAuth2Connection(UUID.randomUUID(), userId, provider, providerId, email, name, avatarUrl, now, now); + } + + /** Factory method for reconstituting from persistence. */ + public static OAuth2Connection reconstitute( + UUID id, + UserId userId, + OAuth2Provider provider, + OAuth2ProviderId providerId, + String email, + String name, + String avatarUrl, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + return new OAuth2Connection(id, userId, provider, providerId, email, name, avatarUrl, createdAt, updatedAt); + } + + public UUID getId() { + return id; + } + + public UserId getUserId() { + return userId; + } + + public OAuth2Provider getProvider() { + return provider; + } + + public OAuth2ProviderId getProviderId() { + return providerId; + } + + public String getEmail() { + return email; + } + + public String getName() { + return name; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OAuth2Connection that = (OAuth2Connection) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/src/main/java/org/nkcoder/user/domain/model/OAuth2Provider.java b/src/main/java/org/nkcoder/user/domain/model/OAuth2Provider.java new file mode 100644 index 0000000..38436fa --- /dev/null +++ b/src/main/java/org/nkcoder/user/domain/model/OAuth2Provider.java @@ -0,0 +1,19 @@ +package org.nkcoder.user.domain.model; + +/** OAuth2 providers supported for authentication. */ +public enum OAuth2Provider { + GOOGLE, + GITHUB; + + public static OAuth2Provider fromString(String value) { + return switch (value.toLowerCase()) { + case "google" -> GOOGLE; + case "github" -> GITHUB; + default -> throw new IllegalArgumentException("Unknown OAuth2 provider: " + value); + }; + } + + public String getRegistrationId() { + return this.name().toLowerCase(); + } +} diff --git a/src/main/java/org/nkcoder/user/domain/model/OAuth2ProviderId.java b/src/main/java/org/nkcoder/user/domain/model/OAuth2ProviderId.java new file mode 100644 index 0000000..4617886 --- /dev/null +++ b/src/main/java/org/nkcoder/user/domain/model/OAuth2ProviderId.java @@ -0,0 +1,23 @@ +package org.nkcoder.user.domain.model; + +import java.util.Objects; + +/** Value object representing the user ID from an OAuth2 provider. */ +public record OAuth2ProviderId(String value) { + + public OAuth2ProviderId { + Objects.requireNonNull(value, "Provider ID cannot be null"); + if (value.isBlank()) { + throw new IllegalArgumentException("Provider ID cannot be blank"); + } + } + + public static OAuth2ProviderId of(String value) { + return new OAuth2ProviderId(value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/org/nkcoder/user/domain/model/User.java b/src/main/java/org/nkcoder/user/domain/model/User.java index 7c278b4..5c528a9 100644 --- a/src/main/java/org/nkcoder/user/domain/model/User.java +++ b/src/main/java/org/nkcoder/user/domain/model/User.java @@ -54,6 +54,12 @@ public static User registerWithOtp(Email email, UserName name) { return new User(UserId.generate(), email, null, name, UserRole.MEMBER, false, null, now, now); } + /** Factory method for creating a new user via OAuth2. Email is verified by the OAuth2 provider. */ + public static User registerWithOAuth2(Email email, UserName name) { + LocalDateTime now = LocalDateTime.now(); + return new User(UserId.generate(), email, null, name, UserRole.MEMBER, true, null, now, now); + } + /** Factory method for reconstituting from persistence. */ public static User reconstitute( UserId id, diff --git a/src/main/java/org/nkcoder/user/domain/repository/OAuth2ConnectionRepository.java b/src/main/java/org/nkcoder/user/domain/repository/OAuth2ConnectionRepository.java new file mode 100644 index 0000000..fe1a4d2 --- /dev/null +++ b/src/main/java/org/nkcoder/user/domain/repository/OAuth2ConnectionRepository.java @@ -0,0 +1,24 @@ +package org.nkcoder.user.domain.repository; + +import java.util.List; +import java.util.Optional; +import org.nkcoder.user.domain.model.OAuth2Connection; +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.nkcoder.user.domain.model.OAuth2ProviderId; +import org.nkcoder.user.domain.model.UserId; + +/** Repository port for OAuth2 connections. */ +public interface OAuth2ConnectionRepository { + + Optional findByProviderAndProviderId(OAuth2Provider provider, OAuth2ProviderId providerId); + + List findByUserId(UserId userId); + + OAuth2Connection save(OAuth2Connection connection); + + void deleteByUserIdAndProvider(UserId userId, OAuth2Provider provider); + + boolean existsByProviderAndProviderId(OAuth2Provider provider, OAuth2ProviderId providerId); + + int countByUserId(UserId userId); +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/entity/OAuth2ConnectionJpaEntity.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/entity/OAuth2ConnectionJpaEntity.java new file mode 100644 index 0000000..3fab73b --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/entity/OAuth2ConnectionJpaEntity.java @@ -0,0 +1,125 @@ +package org.nkcoder.user.infrastructure.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import java.time.LocalDateTime; +import java.util.UUID; +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.domain.Persistable; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Table(name = "oauth2_connections") +@EntityListeners(AuditingEntityListener.class) +public class OAuth2ConnectionJpaEntity implements Persistable { + + @Id + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OAuth2Provider provider; + + @Column(name = "provider_id", nullable = false) + private String providerId; + + @Column + private String email; + + @Column + private String name; + + @Column(name = "avatar_url") + private String avatarUrl; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Transient + private boolean isNew = false; + + protected OAuth2ConnectionJpaEntity() {} + + public OAuth2ConnectionJpaEntity( + UUID id, + UUID userId, + OAuth2Provider provider, + String providerId, + String email, + String name, + String avatarUrl, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + this.id = id; + this.userId = userId; + this.provider = provider; + this.providerId = providerId; + this.email = email; + this.name = name; + this.avatarUrl = avatarUrl; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + @Override + public UUID getId() { + return id; + } + + public UUID getUserId() { + return userId; + } + + public OAuth2Provider getProvider() { + return provider; + } + + public String getProviderId() { + return providerId; + } + + public String getEmail() { + return email; + } + + public String getName() { + return name; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + @Override + public boolean isNew() { + return isNew; + } + + public void markAsNew() { + this.isNew = true; + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/mapper/OAuth2ConnectionPersistenceMapper.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/mapper/OAuth2ConnectionPersistenceMapper.java new file mode 100644 index 0000000..8ea20d7 --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/mapper/OAuth2ConnectionPersistenceMapper.java @@ -0,0 +1,43 @@ +package org.nkcoder.user.infrastructure.persistence.mapper; + +import org.nkcoder.user.domain.model.OAuth2Connection; +import org.nkcoder.user.domain.model.OAuth2ProviderId; +import org.nkcoder.user.domain.model.UserId; +import org.nkcoder.user.infrastructure.persistence.entity.OAuth2ConnectionJpaEntity; +import org.springframework.stereotype.Component; + +@Component +public class OAuth2ConnectionPersistenceMapper { + + public OAuth2Connection toDomain(OAuth2ConnectionJpaEntity entity) { + return OAuth2Connection.reconstitute( + entity.getId(), + UserId.of(entity.getUserId()), + entity.getProvider(), + OAuth2ProviderId.of(entity.getProviderId()), + entity.getEmail(), + entity.getName(), + entity.getAvatarUrl(), + entity.getCreatedAt(), + entity.getUpdatedAt()); + } + + public OAuth2ConnectionJpaEntity toEntity(OAuth2Connection domain) { + return new OAuth2ConnectionJpaEntity( + domain.getId(), + domain.getUserId().value(), + domain.getProvider(), + domain.getProviderId().value(), + domain.getEmail(), + domain.getName(), + domain.getAvatarUrl(), + domain.getCreatedAt(), + domain.getUpdatedAt()); + } + + public OAuth2ConnectionJpaEntity toNewEntity(OAuth2Connection domain) { + OAuth2ConnectionJpaEntity entity = toEntity(domain); + entity.markAsNew(); + return entity; + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OAuth2ConnectionJpaRepository.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OAuth2ConnectionJpaRepository.java new file mode 100644 index 0000000..d742198 --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OAuth2ConnectionJpaRepository.java @@ -0,0 +1,28 @@ +package org.nkcoder.user.infrastructure.persistence.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.nkcoder.user.infrastructure.persistence.entity.OAuth2ConnectionJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface OAuth2ConnectionJpaRepository extends JpaRepository { + + Optional findByProviderAndProviderId(OAuth2Provider provider, String providerId); + + List findByUserId(UUID userId); + + @Modifying + @Query("DELETE FROM OAuth2ConnectionJpaEntity o WHERE o.userId = :userId AND o.provider = :provider") + void deleteByUserIdAndProvider(@Param("userId") UUID userId, @Param("provider") OAuth2Provider provider); + + boolean existsByProviderAndProviderId(OAuth2Provider provider, String providerId); + + int countByUserId(UUID userId); +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OAuth2ConnectionRepositoryAdapter.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OAuth2ConnectionRepositoryAdapter.java new file mode 100644 index 0000000..ff41f44 --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OAuth2ConnectionRepositoryAdapter.java @@ -0,0 +1,64 @@ +package org.nkcoder.user.infrastructure.persistence.repository; + +import java.util.List; +import java.util.Optional; +import org.nkcoder.user.domain.model.OAuth2Connection; +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.nkcoder.user.domain.model.OAuth2ProviderId; +import org.nkcoder.user.domain.model.UserId; +import org.nkcoder.user.domain.repository.OAuth2ConnectionRepository; +import org.nkcoder.user.infrastructure.persistence.mapper.OAuth2ConnectionPersistenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public class OAuth2ConnectionRepositoryAdapter implements OAuth2ConnectionRepository { + + private final OAuth2ConnectionJpaRepository jpaRepository; + private final OAuth2ConnectionPersistenceMapper mapper; + + public OAuth2ConnectionRepositoryAdapter( + OAuth2ConnectionJpaRepository jpaRepository, OAuth2ConnectionPersistenceMapper mapper) { + this.jpaRepository = jpaRepository; + this.mapper = mapper; + } + + @Override + public Optional findByProviderAndProviderId( + OAuth2Provider provider, OAuth2ProviderId providerId) { + return jpaRepository + .findByProviderAndProviderId(provider, providerId.value()) + .map(mapper::toDomain); + } + + @Override + public List findByUserId(UserId userId) { + return jpaRepository.findByUserId(userId.value()).stream() + .map(mapper::toDomain) + .toList(); + } + + @Override + @Transactional + public OAuth2Connection save(OAuth2Connection connection) { + var entity = mapper.toNewEntity(connection); + var savedEntity = jpaRepository.save(entity); + return mapper.toDomain(savedEntity); + } + + @Override + @Transactional + public void deleteByUserIdAndProvider(UserId userId, OAuth2Provider provider) { + jpaRepository.deleteByUserIdAndProvider(userId.value(), provider); + } + + @Override + public boolean existsByProviderAndProviderId(OAuth2Provider provider, OAuth2ProviderId providerId) { + return jpaRepository.existsByProviderAndProviderId(provider, providerId.value()); + } + + @Override + public int countByUserId(UserId userId) { + return jpaRepository.countByUserId(userId.value()); + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationFailureHandler.java b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationFailureHandler.java new file mode 100644 index 0000000..8fd8812 --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationFailureHandler.java @@ -0,0 +1,43 @@ +package org.nkcoder.user.infrastructure.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import org.nkcoder.infrastructure.config.OAuth2Properties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +/** Handles OAuth2 authentication failures by redirecting to the frontend error page. */ +@Component +@ConditionalOnBean(ClientRegistrationRepository.class) +public class OAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2AuthenticationFailureHandler.class); + + private final OAuth2Properties oAuth2Properties; + + public OAuth2AuthenticationFailureHandler(OAuth2Properties oAuth2Properties) { + this.oAuth2Properties = oAuth2Properties; + } + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) + throws IOException { + + logger.error("OAuth2 authentication failed: {}", exception.getMessage()); + + String errorMessage = URLEncoder.encode( + exception.getMessage() != null ? exception.getMessage() : "Authentication failed", + StandardCharsets.UTF_8); + + response.sendRedirect(oAuth2Properties.failureRedirectUrl() + "?error=" + errorMessage); + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationSuccessHandler.java b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationSuccessHandler.java new file mode 100644 index 0000000..9c5e13e --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,90 @@ +package org.nkcoder.user.infrastructure.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import org.nkcoder.infrastructure.config.OAuth2Properties; +import org.nkcoder.user.application.dto.command.OAuth2LoginCommand; +import org.nkcoder.user.application.dto.response.AuthResult; +import org.nkcoder.user.application.service.OAuth2ApplicationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +/** + * Handles successful OAuth2 authentication by processing the login, generating JWT tokens, and redirecting to the + * frontend with tokens in URL fragment. + */ +@Component +@ConditionalOnBean(ClientRegistrationRepository.class) +public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2AuthenticationSuccessHandler.class); + + private final OAuth2ApplicationService oAuth2ApplicationService; + private final OAuth2UserInfoExtractor userInfoExtractor; + private final OAuth2Properties oAuth2Properties; + + public OAuth2AuthenticationSuccessHandler( + OAuth2ApplicationService oAuth2ApplicationService, + OAuth2UserInfoExtractor userInfoExtractor, + OAuth2Properties oAuth2Properties) { + this.oAuth2ApplicationService = oAuth2ApplicationService; + this.userInfoExtractor = userInfoExtractor; + this.oAuth2Properties = oAuth2Properties; + } + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException { + + if (!(authentication instanceof OAuth2AuthenticationToken oAuth2Token)) { + logger.error("Authentication is not OAuth2AuthenticationToken"); + response.sendRedirect(oAuth2Properties.failureRedirectUrl() + "?error=invalid_authentication"); + return; + } + + try { + OAuth2User oAuth2User = oAuth2Token.getPrincipal(); + String registrationId = oAuth2Token.getAuthorizedClientRegistrationId(); + + logger.debug("Processing OAuth2 success for provider: {}", registrationId); + + // Extract user info from OAuth2 response + OAuth2LoginCommand command = userInfoExtractor.extractUserInfo(oAuth2User, registrationId); + + // Process OAuth2 login (find/create user, generate tokens) + AuthResult authResult = oAuth2ApplicationService.processOAuth2Login(command); + + // Build redirect URL with tokens in fragment + String redirectUrl = buildSuccessRedirectUrl(authResult); + + logger.debug("OAuth2 login successful, redirecting to frontend"); + response.sendRedirect(redirectUrl); + + } catch (Exception e) { + logger.error("OAuth2 authentication processing failed", e); + String errorMessage = URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8); + response.sendRedirect(oAuth2Properties.failureRedirectUrl() + "?error=" + errorMessage); + } + } + + private String buildSuccessRedirectUrl(AuthResult authResult) { + // Use URL fragment (#) for token delivery (more secure than query params) + return oAuth2Properties.successRedirectUrl() + + "#accessToken=" + authResult.accessToken() + + "&refreshToken=" + authResult.refreshToken() + + "&userId=" + authResult.userId() + + "&email=" + URLEncoder.encode(authResult.email(), StandardCharsets.UTF_8) + + "&role=" + authResult.role().name(); + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2UserInfoExtractor.java b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2UserInfoExtractor.java new file mode 100644 index 0000000..f097161 --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/security/OAuth2UserInfoExtractor.java @@ -0,0 +1,67 @@ +package org.nkcoder.user.infrastructure.security; + +import java.util.Map; +import org.nkcoder.user.application.dto.command.OAuth2LoginCommand; +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.nkcoder.user.domain.model.OAuth2ProviderId; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; + +/** Extracts standardized user info from different OAuth2 providers. */ +@Component +@ConditionalOnBean(ClientRegistrationRepository.class) +public class OAuth2UserInfoExtractor { + + /** Extract user info from OAuth2User based on the provider. */ + public OAuth2LoginCommand extractUserInfo(OAuth2User oAuth2User, String registrationId) { + return switch (registrationId.toLowerCase()) { + case "google" -> extractGoogleUserInfo(oAuth2User); + case "github" -> extractGitHubUserInfo(oAuth2User); + default -> throw new IllegalArgumentException("Unsupported OAuth2 provider: " + registrationId); + }; + } + + private OAuth2LoginCommand extractGoogleUserInfo(OAuth2User oAuth2User) { + Map attributes = oAuth2User.getAttributes(); + + String providerId = (String) attributes.get("sub"); + String email = (String) attributes.get("email"); + String name = (String) attributes.get("name"); + String avatarUrl = (String) attributes.get("picture"); + Boolean emailVerified = (Boolean) attributes.get("email_verified"); + + return new OAuth2LoginCommand( + OAuth2Provider.GOOGLE, + OAuth2ProviderId.of(providerId), + email, + name, + avatarUrl, + emailVerified != null && emailVerified); + } + + private OAuth2LoginCommand extractGitHubUserInfo(OAuth2User oAuth2User) { + Map attributes = oAuth2User.getAttributes(); + + // GitHub uses integer ID + Object idObj = attributes.get("id"); + String providerId = String.valueOf(idObj); + + String email = (String) attributes.get("email"); + String name = (String) attributes.get("name"); + String avatarUrl = (String) attributes.get("avatar_url"); + + // GitHub doesn't provide email_verified in user info + // Email is considered verified if present in the response + boolean emailVerified = email != null && !email.isBlank(); + + // If name is null, use login (username) as fallback + if (name == null || name.isBlank()) { + name = (String) attributes.get("login"); + } + + return new OAuth2LoginCommand( + OAuth2Provider.GITHUB, OAuth2ProviderId.of(providerId), email, name, avatarUrl, emailVerified); + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java b/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java index eb72da6..aa37582 100644 --- a/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java +++ b/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java @@ -29,15 +29,21 @@ public class SecurityConfig { private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CorsProperties corsProperties; + private final OAuth2AuthenticationSuccessHandler oAuth2SuccessHandler; + private final OAuth2AuthenticationFailureHandler oAuth2FailureHandler; @Autowired public SecurityConfig( JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAuthenticationFilter jwtAuthenticationFilter, - CorsProperties corsProperties) { + CorsProperties corsProperties, + @Autowired(required = false) OAuth2AuthenticationSuccessHandler oAuth2SuccessHandler, + @Autowired(required = false) OAuth2AuthenticationFailureHandler oAuth2FailureHandler) { this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.corsProperties = corsProperties; + this.oAuth2SuccessHandler = oAuth2SuccessHandler; + this.oAuth2FailureHandler = oAuth2FailureHandler; } @Bean @@ -52,11 +58,24 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestCache(RequestCacheConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'")) - .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)) - .authorizeHttpRequests(auth -> auth + .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)); + + // Configure OAuth2 login only if handlers are available + if (oAuth2SuccessHandler != null && oAuth2FailureHandler != null) { + http.oauth2Login(oauth2 -> oauth2.authorizationEndpoint(auth -> auth.baseUri("/api/auth/oauth2/authorize")) + .redirectionEndpoint(redirect -> redirect.baseUri("/api/auth/oauth2/callback/*")) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler)); + } + + http.authorizeHttpRequests(auth -> auth // Public auth endpoints .requestMatchers( - "/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/otp/**") + "/api/auth/register", + "/api/auth/login", + "/api/auth/refresh", + "/api/auth/otp/**", + "/api/auth/oauth2/**") .permitAll() // Actuator and health endpoints .requestMatchers("/actuator/health", "/actuator/info") diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/OAuth2Controller.java b/src/main/java/org/nkcoder/user/interfaces/rest/OAuth2Controller.java new file mode 100644 index 0000000..ed62d49 --- /dev/null +++ b/src/main/java/org/nkcoder/user/interfaces/rest/OAuth2Controller.java @@ -0,0 +1,84 @@ +package org.nkcoder.user.interfaces.rest; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.List; +import org.nkcoder.shared.local.rest.ApiResponse; +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.nkcoder.user.interfaces.rest.response.OAuth2ProviderResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth/oauth2") +@ConditionalOnBean(ClientRegistrationRepository.class) +public class OAuth2Controller { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2Controller.class); + + private final ClientRegistrationRepository clientRegistrationRepository; + + @Value("${server.port:3001}") + private int serverPort; + + @Autowired + public OAuth2Controller(ClientRegistrationRepository clientRegistrationRepository) { + this.clientRegistrationRepository = clientRegistrationRepository; + } + + /** Get list of available OAuth2 providers. */ + @GetMapping("/providers") + public ResponseEntity>> getAvailableProviders(HttpServletRequest request) { + logger.debug("Getting available OAuth2 providers"); + + String baseUrl = getBaseUrl(request); + List providers = new ArrayList<>(); + + for (OAuth2Provider provider : OAuth2Provider.values()) { + String registrationId = provider.getRegistrationId(); + try { + ClientRegistration registration = clientRegistrationRepository.findByRegistrationId(registrationId); + if (registration != null && isConfigured(registration)) { + providers.add(OAuth2ProviderResponse.of(provider.name(), getDisplayName(provider), baseUrl)); + } + } catch (Exception e) { + // Provider not configured, skip it + logger.debug("OAuth2 provider {} not configured: {}", registrationId, e.getMessage()); + } + } + + return ResponseEntity.ok(ApiResponse.success("Available OAuth2 providers", providers)); + } + + private boolean isConfigured(ClientRegistration registration) { + String clientId = registration.getClientId(); + return clientId != null && !clientId.isBlank(); + } + + private String getDisplayName(OAuth2Provider provider) { + return switch (provider) { + case GOOGLE -> "Google"; + case GITHUB -> "GitHub"; + }; + } + + private String getBaseUrl(HttpServletRequest request) { + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int port = request.getServerPort(); + + if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) { + return scheme + "://" + serverName; + } + return scheme + "://" + serverName + ":" + port; + } +} diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/UserController.java b/src/main/java/org/nkcoder/user/interfaces/rest/UserController.java index 05c4cbb..6419814 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/UserController.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/UserController.java @@ -1,19 +1,27 @@ package org.nkcoder.user.interfaces.rest; import jakarta.validation.Valid; +import java.util.List; import java.util.UUID; import org.nkcoder.shared.local.rest.ApiResponse; import org.nkcoder.user.application.dto.response.UserDto; +import org.nkcoder.user.application.service.OAuth2ApplicationService; import org.nkcoder.user.application.service.UserApplicationService; +import org.nkcoder.user.domain.model.OAuth2Connection; +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.nkcoder.user.domain.model.UserId; import org.nkcoder.user.interfaces.rest.mapper.UserRequestMapper; import org.nkcoder.user.interfaces.rest.request.ChangePasswordRequest; import org.nkcoder.user.interfaces.rest.request.UpdateProfileRequest; +import org.nkcoder.user.interfaces.rest.response.OAuth2ConnectionResponse; import org.nkcoder.user.interfaces.rest.response.UserResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,10 +35,15 @@ public class UserController { private static final Logger logger = LoggerFactory.getLogger(UserController.class); private final UserApplicationService userService; + private final OAuth2ApplicationService oAuth2ApplicationService; private final UserRequestMapper requestMapper; - public UserController(UserApplicationService userService, UserRequestMapper requestMapper) { + public UserController( + UserApplicationService userService, + OAuth2ApplicationService oAuth2ApplicationService, + UserRequestMapper requestMapper) { this.userService = userService; + this.oAuth2ApplicationService = oAuth2ApplicationService; this.requestMapper = requestMapper; } @@ -62,4 +75,27 @@ public ResponseEntity> changePassword( return ResponseEntity.ok(ApiResponse.success("Password changed successfully")); } + + @GetMapping("/oauth2") + public ResponseEntity>> getOAuth2Connections( + @RequestAttribute("userId") UUID userId) { + logger.debug("Getting OAuth2 connections for user: {}", userId); + + List connections = oAuth2ApplicationService.getConnectedProviders(UserId.of(userId)); + List response = + connections.stream().map(OAuth2ConnectionResponse::from).toList(); + + return ResponseEntity.ok(ApiResponse.success("OAuth2 connections retrieved", response)); + } + + @DeleteMapping("/oauth2/{provider}") + public ResponseEntity> unlinkOAuth2Provider( + @RequestAttribute("userId") UUID userId, @PathVariable String provider) { + logger.info("Unlinking OAuth2 provider {} for user: {}", provider, userId); + + OAuth2Provider oAuth2Provider = OAuth2Provider.fromString(provider); + oAuth2ApplicationService.unlinkOAuth2Account(UserId.of(userId), oAuth2Provider); + + return ResponseEntity.ok(ApiResponse.success("OAuth2 provider unlinked successfully")); + } } diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/response/OAuth2ConnectionResponse.java b/src/main/java/org/nkcoder/user/interfaces/rest/response/OAuth2ConnectionResponse.java new file mode 100644 index 0000000..135ebb9 --- /dev/null +++ b/src/main/java/org/nkcoder/user/interfaces/rest/response/OAuth2ConnectionResponse.java @@ -0,0 +1,18 @@ +package org.nkcoder.user.interfaces.rest.response; + +import java.time.LocalDateTime; +import org.nkcoder.user.domain.model.OAuth2Connection; + +/** Response DTO for OAuth2 connection information. */ +public record OAuth2ConnectionResponse( + String provider, String email, String name, String avatarUrl, LocalDateTime connectedAt) { + + public static OAuth2ConnectionResponse from(OAuth2Connection connection) { + return new OAuth2ConnectionResponse( + connection.getProvider().name().toLowerCase(), + connection.getEmail(), + connection.getName(), + connection.getAvatarUrl(), + connection.getCreatedAt()); + } +} diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/response/OAuth2ProviderResponse.java b/src/main/java/org/nkcoder/user/interfaces/rest/response/OAuth2ProviderResponse.java new file mode 100644 index 0000000..281b24c --- /dev/null +++ b/src/main/java/org/nkcoder/user/interfaces/rest/response/OAuth2ProviderResponse.java @@ -0,0 +1,10 @@ +package org.nkcoder.user.interfaces.rest.response; + +/** Response DTO for OAuth2 provider information. */ +public record OAuth2ProviderResponse(String name, String displayName, String authorizationUrl) { + + public static OAuth2ProviderResponse of(String name, String displayName, String baseUrl) { + String authorizationUrl = baseUrl + "/api/auth/oauth2/authorize/" + name.toLowerCase(); + return new OAuth2ProviderResponse(name.toLowerCase(), displayName, authorizationUrl); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4c57152..4a93dd9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -107,6 +107,33 @@ cors: allow-credentials: true max-age: 3600 +# ----------------------------------------------------------------------------- +# OAuth2 Configuration +# ----------------------------------------------------------------------------- +# Configure OAuth2 providers for social login (Google, GitHub) +# Credentials must be set via environment variables +spring.security.oauth2.client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID:} + client-secret: ${GOOGLE_CLIENT_SECRET:} + scope: openid, profile, email + redirect-uri: "{baseUrl}/api/auth/oauth2/callback/google" + github: + client-id: ${GITHUB_CLIENT_ID:} + client-secret: ${GITHUB_CLIENT_SECRET:} + scope: read:user, user:email + redirect-uri: "{baseUrl}/api/auth/oauth2/callback/github" + provider: + github: + user-info-uri: https://api.github.com/user + user-name-attribute: id + +# OAuth2 redirect URLs for frontend +app.oauth2: + success-redirect-url: ${OAUTH2_SUCCESS_REDIRECT_URL:http://localhost:3000/auth/callback} + failure-redirect-url: ${OAUTH2_FAILURE_REDIRECT_URL:http://localhost:3000/auth/error} + # ----------------------------------------------------------------------------- # Actuator Configuration # ----------------------------------------------------------------------------- diff --git a/src/main/resources/db/migration/V1.7__create_oauth2_connections_table.sql b/src/main/resources/db/migration/V1.7__create_oauth2_connections_table.sql new file mode 100644 index 0000000..bade159 --- /dev/null +++ b/src/main/resources/db/migration/V1.7__create_oauth2_connections_table.sql @@ -0,0 +1,17 @@ +-- OAuth2 connections table for linking external OAuth2 accounts to users +CREATE TABLE IF NOT EXISTS oauth2_connections +( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + provider VARCHAR(50) NOT NULL, + provider_id VARCHAR(255) NOT NULL, + email VARCHAR(255), + name VARCHAR(255), + avatar_url VARCHAR(512), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_oauth2_provider_id UNIQUE (provider, provider_id) +); + +-- Index for user lookups +CREATE INDEX IF NOT EXISTS idx_oauth2_connections_user_id ON oauth2_connections (user_id); diff --git a/src/test/java/org/nkcoder/user/application/service/OAuth2ApplicationServiceTest.java b/src/test/java/org/nkcoder/user/application/service/OAuth2ApplicationServiceTest.java new file mode 100644 index 0000000..2883c67 --- /dev/null +++ b/src/test/java/org/nkcoder/user/application/service/OAuth2ApplicationServiceTest.java @@ -0,0 +1,465 @@ +package org.nkcoder.user.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.nkcoder.shared.kernel.domain.event.DomainEventPublisher; +import org.nkcoder.shared.kernel.domain.event.UserRegisteredEvent; +import org.nkcoder.shared.kernel.exception.AuthenticationException; +import org.nkcoder.shared.kernel.exception.ValidationException; +import org.nkcoder.user.application.dto.command.OAuth2LoginCommand; +import org.nkcoder.user.application.dto.response.AuthResult; +import org.nkcoder.user.domain.model.Email; +import org.nkcoder.user.domain.model.HashedPassword; +import org.nkcoder.user.domain.model.OAuth2Connection; +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.nkcoder.user.domain.model.OAuth2ProviderId; +import org.nkcoder.user.domain.model.RefreshToken; +import org.nkcoder.user.domain.model.TokenFamily; +import org.nkcoder.user.domain.model.TokenPair; +import org.nkcoder.user.domain.model.User; +import org.nkcoder.user.domain.model.UserId; +import org.nkcoder.user.domain.model.UserName; +import org.nkcoder.user.domain.model.UserRole; +import org.nkcoder.user.domain.repository.OAuth2ConnectionRepository; +import org.nkcoder.user.domain.repository.RefreshTokenRepository; +import org.nkcoder.user.domain.repository.UserRepository; +import org.nkcoder.user.domain.service.TokenGenerator; +import org.nkcoder.user.domain.service.TokenRotationService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("OAuth2ApplicationService") +class OAuth2ApplicationServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private OAuth2ConnectionRepository oauth2ConnectionRepository; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private TokenGenerator tokenGenerator; + + @Mock + private TokenRotationService tokenRotationService; + + @Mock + private DomainEventPublisher eventPublisher; + + private OAuth2ApplicationService oauth2ApplicationService; + + @BeforeEach + void setUp() { + oauth2ApplicationService = new OAuth2ApplicationService( + userRepository, + oauth2ConnectionRepository, + refreshTokenRepository, + tokenGenerator, + tokenRotationService, + eventPublisher); + } + + @Nested + @DisplayName("processOAuth2Login") + class ProcessOAuth2Login { + + @Test + @DisplayName("logs in existing user via OAuth2 connection") + void logsInExistingUserViaOAuth2Connection() { + OAuth2LoginCommand command = createGoogleLoginCommand(); + User user = createTestUser(); + OAuth2Connection connection = createOAuth2Connection(user.getId()); + TokenPair tokenPair = new TokenPair("access-token", "refresh-token"); + + given(oauth2ConnectionRepository.findByProviderAndProviderId( + eq(OAuth2Provider.GOOGLE), any(OAuth2ProviderId.class))) + .willReturn(Optional.of(connection)); + given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); + given(tokenRotationService.generateTokens(any(User.class), any(TokenFamily.class))) + .willReturn(tokenPair); + given(tokenGenerator.getRefreshTokenExpiry()) + .willReturn(LocalDateTime.now().plusDays(7)); + + AuthResult result = oauth2ApplicationService.processOAuth2Login(command); + + assertThat(result.email()).isEqualTo("user@example.com"); + assertThat(result.accessToken()).isEqualTo("access-token"); + assertThat(result.refreshToken()).isEqualTo("refresh-token"); + verify(userRepository).updateLastLoginAt(eq(user.getId()), any(LocalDateTime.class)); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + verify(eventPublisher, never()).publish(any(UserRegisteredEvent.class)); + } + + @Test + @DisplayName("auto-links OAuth2 to existing user with same email") + void autoLinksOAuth2ToExistingUserWithSameEmail() { + OAuth2LoginCommand command = createGoogleLoginCommand(); + User user = createTestUser(); + TokenPair tokenPair = new TokenPair("access-token", "refresh-token"); + + given(oauth2ConnectionRepository.findByProviderAndProviderId( + eq(OAuth2Provider.GOOGLE), any(OAuth2ProviderId.class))) + .willReturn(Optional.empty()); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.of(user)); + given(tokenRotationService.generateTokens(any(User.class), any(TokenFamily.class))) + .willReturn(tokenPair); + given(tokenGenerator.getRefreshTokenExpiry()) + .willReturn(LocalDateTime.now().plusDays(7)); + + AuthResult result = oauth2ApplicationService.processOAuth2Login(command); + + assertThat(result.email()).isEqualTo("user@example.com"); + verify(oauth2ConnectionRepository).save(any(OAuth2Connection.class)); + verify(eventPublisher, never()).publish(any(UserRegisteredEvent.class)); + } + + @Test + @DisplayName("creates new user when no match found") + void createsNewUserWhenNoMatchFound() { + OAuth2LoginCommand command = createGoogleLoginCommand(); + TokenPair tokenPair = new TokenPair("access-token", "refresh-token"); + + given(oauth2ConnectionRepository.findByProviderAndProviderId( + eq(OAuth2Provider.GOOGLE), any(OAuth2ProviderId.class))) + .willReturn(Optional.empty()); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(inv -> inv.getArgument(0)); + given(tokenRotationService.generateTokens(any(User.class), any(TokenFamily.class))) + .willReturn(tokenPair); + given(tokenGenerator.getRefreshTokenExpiry()) + .willReturn(LocalDateTime.now().plusDays(7)); + + AuthResult result = oauth2ApplicationService.processOAuth2Login(command); + + assertThat(result.email()).isEqualTo("user@example.com"); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + User savedUser = userCaptor.getValue(); + assertThat(savedUser.getEmail().value()).isEqualTo("user@example.com"); + assertThat(savedUser.getName().value()).isEqualTo("Test User"); + assertThat(savedUser.getPassword()).isNull(); + assertThat(savedUser.isEmailVerified()).isTrue(); + + verify(oauth2ConnectionRepository).save(any(OAuth2Connection.class)); + verify(eventPublisher).publish(any(UserRegisteredEvent.class)); + } + + @Test + @DisplayName("generates name from email when name is not provided") + void generatesNameFromEmailWhenNameNotProvided() { + OAuth2LoginCommand command = new OAuth2LoginCommand( + OAuth2Provider.GOOGLE, OAuth2ProviderId.of("google-123"), "john.doe@example.com", null, null, true); + TokenPair tokenPair = new TokenPair("access-token", "refresh-token"); + + given(oauth2ConnectionRepository.findByProviderAndProviderId( + eq(OAuth2Provider.GOOGLE), any(OAuth2ProviderId.class))) + .willReturn(Optional.empty()); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(inv -> inv.getArgument(0)); + given(tokenRotationService.generateTokens(any(User.class), any(TokenFamily.class))) + .willReturn(tokenPair); + given(tokenGenerator.getRefreshTokenExpiry()) + .willReturn(LocalDateTime.now().plusDays(7)); + + oauth2ApplicationService.processOAuth2Login(command); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + assertThat(userCaptor.getValue().getName().value()).isEqualTo("John Doe"); + } + + @Test + @DisplayName("does not auto-link when email is not verified") + void doesNotAutoLinkWhenEmailNotVerified() { + OAuth2LoginCommand command = new OAuth2LoginCommand( + OAuth2Provider.GITHUB, + OAuth2ProviderId.of("github-123"), + "user@example.com", + "Test User", + null, + false); + TokenPair tokenPair = new TokenPair("access-token", "refresh-token"); + + given(oauth2ConnectionRepository.findByProviderAndProviderId( + eq(OAuth2Provider.GITHUB), any(OAuth2ProviderId.class))) + .willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(inv -> inv.getArgument(0)); + given(tokenRotationService.generateTokens(any(User.class), any(TokenFamily.class))) + .willReturn(tokenPair); + given(tokenGenerator.getRefreshTokenExpiry()) + .willReturn(LocalDateTime.now().plusDays(7)); + + oauth2ApplicationService.processOAuth2Login(command); + + verify(userRepository, never()).findByEmail(any(Email.class)); + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("throws AuthenticationException when user not found for existing connection") + void throwsWhenUserNotFoundForExistingConnection() { + OAuth2LoginCommand command = createGoogleLoginCommand(); + OAuth2Connection connection = createOAuth2Connection(UserId.generate()); + + given(oauth2ConnectionRepository.findByProviderAndProviderId( + eq(OAuth2Provider.GOOGLE), any(OAuth2ProviderId.class))) + .willReturn(Optional.of(connection)); + given(userRepository.findById(any(UserId.class))).willReturn(Optional.empty()); + + assertThatThrownBy(() -> oauth2ApplicationService.processOAuth2Login(command)) + .isInstanceOf(AuthenticationException.class) + .hasMessageContaining("User not found"); + } + } + + @Nested + @DisplayName("linkOAuth2Account") + class LinkOAuth2Account { + + @Test + @DisplayName("links OAuth2 account to user successfully") + void linksOAuth2AccountSuccessfully() { + UserId userId = UserId.generate(); + OAuth2LoginCommand command = createGoogleLoginCommand(); + User user = createTestUserWithId(userId); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(oauth2ConnectionRepository.existsByProviderAndProviderId( + eq(OAuth2Provider.GOOGLE), any(OAuth2ProviderId.class))) + .willReturn(false); + + oauth2ApplicationService.linkOAuth2Account(userId, command); + + verify(oauth2ConnectionRepository).save(any(OAuth2Connection.class)); + } + + @Test + @DisplayName("throws ValidationException when provider already linked to another user") + void throwsWhenProviderAlreadyLinkedToAnotherUser() { + UserId userId = UserId.generate(); + OAuth2LoginCommand command = createGoogleLoginCommand(); + User user = createTestUserWithId(userId); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(oauth2ConnectionRepository.existsByProviderAndProviderId( + eq(OAuth2Provider.GOOGLE), any(OAuth2ProviderId.class))) + .willReturn(true); + + assertThatThrownBy(() -> oauth2ApplicationService.linkOAuth2Account(userId, command)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("already linked to another user"); + + verify(oauth2ConnectionRepository, never()).save(any()); + } + + @Test + @DisplayName("throws AuthenticationException when user not found") + void throwsWhenUserNotFound() { + UserId userId = UserId.generate(); + OAuth2LoginCommand command = createGoogleLoginCommand(); + + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> oauth2ApplicationService.linkOAuth2Account(userId, command)) + .isInstanceOf(AuthenticationException.class) + .hasMessageContaining("User not found"); + } + } + + @Nested + @DisplayName("unlinkOAuth2Account") + class UnlinkOAuth2Account { + + @Test + @DisplayName("unlinks OAuth2 provider successfully when user has password") + void unlinksProviderSuccessfullyWhenUserHasPassword() { + UserId userId = UserId.generate(); + User user = createTestUserWithId(userId); + OAuth2Connection connection = createOAuth2Connection(userId); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(oauth2ConnectionRepository.countByUserId(userId)).willReturn(1); + given(oauth2ConnectionRepository.findByUserId(userId)).willReturn(List.of(connection)); + + oauth2ApplicationService.unlinkOAuth2Account(userId, OAuth2Provider.GOOGLE); + + verify(oauth2ConnectionRepository).deleteByUserIdAndProvider(userId, OAuth2Provider.GOOGLE); + } + + @Test + @DisplayName("unlinks OAuth2 provider successfully when user has multiple providers") + void unlinksProviderSuccessfullyWhenUserHasMultipleProviders() { + UserId userId = UserId.generate(); + User user = createTestUserWithoutPassword(userId); + OAuth2Connection googleConnection = createOAuth2Connection(userId); + OAuth2Connection githubConnection = OAuth2Connection.create( + userId, OAuth2Provider.GITHUB, OAuth2ProviderId.of("github-123"), "user@example.com", "Test", null); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(oauth2ConnectionRepository.countByUserId(userId)).willReturn(2); + given(oauth2ConnectionRepository.findByUserId(userId)) + .willReturn(List.of(googleConnection, githubConnection)); + + oauth2ApplicationService.unlinkOAuth2Account(userId, OAuth2Provider.GOOGLE); + + verify(oauth2ConnectionRepository).deleteByUserIdAndProvider(userId, OAuth2Provider.GOOGLE); + } + + @Test + @DisplayName("throws ValidationException when trying to unlink last login method") + void throwsWhenUnlinkingLastLoginMethod() { + UserId userId = UserId.generate(); + User user = createTestUserWithoutPassword(userId); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(oauth2ConnectionRepository.countByUserId(userId)).willReturn(1); + + assertThatThrownBy(() -> oauth2ApplicationService.unlinkOAuth2Account(userId, OAuth2Provider.GOOGLE)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Cannot unlink the last login method"); + + verify(oauth2ConnectionRepository, never()).deleteByUserIdAndProvider(any(), any()); + } + + @Test + @DisplayName("throws ValidationException when provider not linked") + void throwsWhenProviderNotLinked() { + UserId userId = UserId.generate(); + User user = createTestUserWithId(userId); + OAuth2Connection githubConnection = OAuth2Connection.create( + userId, OAuth2Provider.GITHUB, OAuth2ProviderId.of("github-123"), "user@example.com", "Test", null); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(oauth2ConnectionRepository.countByUserId(userId)).willReturn(1); + given(oauth2ConnectionRepository.findByUserId(userId)).willReturn(List.of(githubConnection)); + + assertThatThrownBy(() -> oauth2ApplicationService.unlinkOAuth2Account(userId, OAuth2Provider.GOOGLE)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("not linked"); + + verify(oauth2ConnectionRepository, never()).deleteByUserIdAndProvider(any(), any()); + } + + @Test + @DisplayName("throws AuthenticationException when user not found") + void throwsWhenUserNotFound() { + UserId userId = UserId.generate(); + + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> oauth2ApplicationService.unlinkOAuth2Account(userId, OAuth2Provider.GOOGLE)) + .isInstanceOf(AuthenticationException.class) + .hasMessageContaining("User not found"); + } + } + + @Nested + @DisplayName("getConnectedProviders") + class GetConnectedProviders { + + @Test + @DisplayName("returns connected providers for user") + void returnsConnectedProvidersForUser() { + UserId userId = UserId.generate(); + OAuth2Connection connection = createOAuth2Connection(userId); + + given(oauth2ConnectionRepository.findByUserId(userId)).willReturn(List.of(connection)); + + List result = oauth2ApplicationService.getConnectedProviders(userId); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getProvider()).isEqualTo(OAuth2Provider.GOOGLE); + } + + @Test + @DisplayName("returns empty list when no providers connected") + void returnsEmptyListWhenNoProvidersConnected() { + UserId userId = UserId.generate(); + + given(oauth2ConnectionRepository.findByUserId(userId)).willReturn(List.of()); + + List result = oauth2ApplicationService.getConnectedProviders(userId); + + assertThat(result).isEmpty(); + } + } + + private OAuth2LoginCommand createGoogleLoginCommand() { + return new OAuth2LoginCommand( + OAuth2Provider.GOOGLE, + OAuth2ProviderId.of("google-123456"), + "user@example.com", + "Test User", + "https://example.com/avatar.png", + true); + } + + private User createTestUser() { + return User.reconstitute( + UserId.generate(), + Email.of("user@example.com"), + HashedPassword.of("hashed"), + UserName.of("Test User"), + UserRole.MEMBER, + true, + null, + LocalDateTime.now(), + LocalDateTime.now()); + } + + private User createTestUserWithId(UserId userId) { + return User.reconstitute( + userId, + Email.of("user@example.com"), + HashedPassword.of("hashed"), + UserName.of("Test User"), + UserRole.MEMBER, + true, + null, + LocalDateTime.now(), + LocalDateTime.now()); + } + + private User createTestUserWithoutPassword(UserId userId) { + return User.reconstitute( + userId, + Email.of("user@example.com"), + null, + UserName.of("Test User"), + UserRole.MEMBER, + true, + null, + LocalDateTime.now(), + LocalDateTime.now()); + } + + private OAuth2Connection createOAuth2Connection(UserId userId) { + return OAuth2Connection.create( + userId, + OAuth2Provider.GOOGLE, + OAuth2ProviderId.of("google-123456"), + "user@example.com", + "Test User", + "https://example.com/avatar.png"); + } +} diff --git a/src/test/java/org/nkcoder/user/integration/OAuth2IntegrationTest.java b/src/test/java/org/nkcoder/user/integration/OAuth2IntegrationTest.java new file mode 100644 index 0000000..f319e5f --- /dev/null +++ b/src/test/java/org/nkcoder/user/integration/OAuth2IntegrationTest.java @@ -0,0 +1,365 @@ +package org.nkcoder.user.integration; + +import java.time.LocalDateTime; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.nkcoder.infrastructure.config.TestContainersConfiguration; +import org.nkcoder.user.domain.model.OAuth2Provider; +import org.nkcoder.user.infrastructure.persistence.entity.OAuth2ConnectionJpaEntity; +import org.nkcoder.user.infrastructure.persistence.repository.OAuth2ConnectionJpaRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.reactive.server.WebTestClient; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient +@Import(TestContainersConfiguration.class) +@ActiveProfiles("test") +@DisplayName("OAuth2 Integration Tests") +class OAuth2IntegrationTest { + + @Autowired + private WebTestClient webTestClient; + + @Autowired + private OAuth2ConnectionJpaRepository oauth2ConnectionRepository; + + @Nested + @DisplayName("GET /api/users/me/oauth2") + class GetOAuth2Connections { + + @Test + @DisplayName("returns empty list when user has no OAuth2 connections") + void returnsEmptyListWhenNoConnections() { + String accessToken = registerAndGetToken("oauth2-empty@example.com", "Password123", "OAuth2 Empty User"); + + webTestClient + .get() + .uri("/api/users/me/oauth2") + .header("Authorization", "Bearer " + accessToken) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data") + .isArray() + .jsonPath("$.data.length()") + .isEqualTo(0); + } + + @Test + @DisplayName("returns OAuth2 connections for authenticated user") + void returnsOAuth2ConnectionsForUser() { + // Register user and get tokens + var response = webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "oauth2-test@example.com", + "password": "Password123", + "name": "OAuth2 Test User", + "role": "MEMBER" + } + """) + .exchange() + .expectStatus() + .is2xxSuccessful() + .expectBody() + .returnResult(); + + String responseBody = new String(response.getResponseBody()); + String accessToken = extractJsonValue(responseBody, "data.tokens.accessToken"); + String userId = extractJsonValue(responseBody, "data.user.id"); + + // Manually create OAuth2 connection for the user + OAuth2ConnectionJpaEntity connection = createOAuth2Connection( + UUID.fromString(userId), + OAuth2Provider.GOOGLE, + "google-123456", + "oauth2-test@example.com", + "OAuth2 Test User", + "https://example.com/avatar.png"); + oauth2ConnectionRepository.save(connection); + + // Get OAuth2 connections + webTestClient + .get() + .uri("/api/users/me/oauth2") + .header("Authorization", "Bearer " + accessToken) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data") + .isArray() + .jsonPath("$.data.length()") + .isEqualTo(1) + .jsonPath("$.data[0].provider") + .isEqualTo("google") + .jsonPath("$.data[0].email") + .isEqualTo("oauth2-test@example.com"); + } + + @Test + @DisplayName("returns 401 when not authenticated") + void returnsUnauthorizedWhenNotAuthenticated() { + webTestClient + .get() + .uri("/api/users/me/oauth2") + .exchange() + .expectStatus() + .isUnauthorized(); + } + } + + @Nested + @DisplayName("DELETE /api/users/me/oauth2/{provider}") + class UnlinkOAuth2Provider { + + @Test + @DisplayName("unlinks OAuth2 provider when user has password") + void unlinksProviderWhenUserHasPassword() { + // Register user with password + var response = webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "oauth2-unlink@example.com", + "password": "Password123", + "name": "OAuth2 Unlink User", + "role": "MEMBER" + } + """) + .exchange() + .expectStatus() + .is2xxSuccessful() + .expectBody() + .returnResult(); + + String responseBody = new String(response.getResponseBody()); + String accessToken = extractJsonValue(responseBody, "data.tokens.accessToken"); + String userId = extractJsonValue(responseBody, "data.user.id"); + + // Create OAuth2 connection + OAuth2ConnectionJpaEntity connection = createOAuth2Connection( + UUID.fromString(userId), + OAuth2Provider.GOOGLE, + "google-unlink-123", + "oauth2-unlink@example.com", + null, + null); + oauth2ConnectionRepository.save(connection); + + // Unlink provider + webTestClient + .delete() + .uri("/api/users/me/oauth2/google") + .header("Authorization", "Bearer " + accessToken) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.message") + .isEqualTo("OAuth2 provider unlinked successfully"); + + // Verify connection is deleted + webTestClient + .get() + .uri("/api/users/me/oauth2") + .header("Authorization", "Bearer " + accessToken) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.length()") + .isEqualTo(0); + } + + @Test + @DisplayName("returns 400 when provider not linked") + void returnsBadRequestWhenProviderNotLinked() { + String accessToken = registerAndGetToken("oauth2-not-linked@example.com", "Password123", "Not Linked User"); + + webTestClient + .delete() + .uri("/api/users/me/oauth2/github") + .header("Authorization", "Bearer " + accessToken) + .exchange() + .expectStatus() + .isBadRequest() + .expectBody() + .jsonPath("$.message") + .value(msg -> { + String message = (String) msg; + assert message.contains("not linked") : "Expected message to contain 'not linked'"; + }); + } + + @Test + @DisplayName("returns error for invalid provider name") + void returnsErrorForInvalidProvider() { + String accessToken = + registerAndGetToken("oauth2-invalid@example.com", "Password123", "Invalid Provider User"); + + // IllegalArgumentException from fromString() returns 500 unless caught + webTestClient + .delete() + .uri("/api/users/me/oauth2/invalid") + .header("Authorization", "Bearer " + accessToken) + .exchange() + .expectStatus() + .is5xxServerError(); + } + + @Test + @DisplayName("returns 401 when not authenticated") + void returnsUnauthorizedWhenNotAuthenticated() { + webTestClient + .delete() + .uri("/api/users/me/oauth2/google") + .exchange() + .expectStatus() + .isUnauthorized(); + } + } + + @Nested + @DisplayName("OAuth2 with Multiple Providers") + class MultipleProviders { + + @Test + @DisplayName("can link multiple OAuth2 providers to same user") + void canLinkMultipleProviders() { + // Register user + var response = webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "oauth2-multi@example.com", + "password": "Password123", + "name": "Multi Provider User", + "role": "MEMBER" + } + """) + .exchange() + .expectStatus() + .is2xxSuccessful() + .expectBody() + .returnResult(); + + String responseBody = new String(response.getResponseBody()); + String accessToken = extractJsonValue(responseBody, "data.tokens.accessToken"); + String userId = extractJsonValue(responseBody, "data.user.id"); + + // Create Google connection + OAuth2ConnectionJpaEntity googleConnection = createOAuth2Connection( + UUID.fromString(userId), + OAuth2Provider.GOOGLE, + "google-multi-123", + "oauth2-multi@example.com", + null, + null); + oauth2ConnectionRepository.save(googleConnection); + + // Create GitHub connection + OAuth2ConnectionJpaEntity githubConnection = createOAuth2Connection( + UUID.fromString(userId), + OAuth2Provider.GITHUB, + "github-multi-456", + "oauth2-multi@example.com", + null, + null); + oauth2ConnectionRepository.save(githubConnection); + + // Verify both connections exist + webTestClient + .get() + .uri("/api/users/me/oauth2") + .header("Authorization", "Bearer " + accessToken) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.length()") + .isEqualTo(2); + } + } + + // Helper methods + + private String registerAndGetToken(String email, String password, String name) { + var response = webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "%s", + "password": "%s", + "name": "%s", + "role": "MEMBER" + } + """.formatted(email, password, name)) + .exchange() + .expectStatus() + .is2xxSuccessful() + .expectBody() + .returnResult(); + + String responseBody = new String(response.getResponseBody()); + return extractJsonValue(responseBody, "data.tokens.accessToken"); + } + + /** Simple JSON value extractor for dot-notation paths. */ + private String extractJsonValue(String json, String path) { + String[] parts = path.split("\\."); + String current = json; + + for (String part : parts) { + int keyIndex = current.indexOf("\"" + part + "\""); + if (keyIndex == -1) { + return null; + } + current = current.substring(keyIndex + part.length() + 2); + int colonIndex = current.indexOf(":"); + current = current.substring(colonIndex + 1).trim(); + + if (current.startsWith("\"")) { + // String value + int endQuote = current.indexOf("\"", 1); + if (endQuote == -1) { + return null; + } + if (parts[parts.length - 1].equals(part)) { + return current.substring(1, endQuote); + } + } else if (current.startsWith("{")) { + // Object - continue to next part + continue; + } + } + return null; + } + + private OAuth2ConnectionJpaEntity createOAuth2Connection( + UUID userId, OAuth2Provider provider, String providerId, String email, String name, String avatarUrl) { + LocalDateTime now = LocalDateTime.now(); + OAuth2ConnectionJpaEntity entity = new OAuth2ConnectionJpaEntity( + UUID.randomUUID(), userId, provider, providerId, email, name, avatarUrl, now, now); + entity.markAsNew(); + return entity; + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 8c95dc4..ea64236 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,6 +1,24 @@ # Test profile configuration # This file is automatically loaded when running with @ActiveProfiles("test") or when Spring detects the test classpath spring: + # Disable OAuth2 autoconfiguration in tests + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration + - org.springframework.boot.autoconfigure.security.oauth2.client.reactive.ReactiveOAuth2ClientAutoConfiguration + + # Provide placeholder OAuth2 values to avoid validation errors + security: + oauth2: + client: + registration: + google: + client-id: test-google-client-id + client-secret: test-google-client-secret + github: + client-id: test-github-client-id + client-secret: test-github-client-secret + jpa: # Show SQL in tests for debugging (disable in CI if too noisy) show-sql: true