Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
424 changes: 424 additions & 0 deletions docs/oauth2-workflow.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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";
}
}
}
21 changes: 21 additions & 0 deletions src/main/java/org/nkcoder/shared/util/UrlUtils.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
@@ -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<OAuth2Connection> 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<User> 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<OAuth2Connection> 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<OAuth2Connection> 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(" "));
}
}
115 changes: 115 additions & 0 deletions src/main/java/org/nkcoder/user/domain/model/OAuth2Connection.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading