Skip to content

Commit d74bd51

Browse files
authored
Merge pull request #19 from nkcoder/feature/oauth2
[Feature] Implement oauth2 with google and github
2 parents 46283c5 + 1b797a1 commit d74bd51

28 files changed

+2402
-5
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies {
3434
implementation("org.springframework.boot:spring-boot-starter-webmvc")
3535
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
3636
implementation("org.springframework.boot:spring-boot-starter-security")
37+
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
3738
implementation("org.springframework.boot:spring-boot-starter-validation")
3839
implementation("org.springframework.boot:spring-boot-starter-actuator")
3940
implementation("org.springframework.boot:spring-boot-starter-jackson")

docs/oauth2-workflow.md

Lines changed: 424 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package org.nkcoder.infrastructure.config;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import org.springframework.boot.context.properties.ConfigurationProperties;
5+
import org.springframework.validation.annotation.Validated;
6+
7+
@ConfigurationProperties(prefix = "app.oauth2")
8+
@Validated
9+
public record OAuth2Properties(
10+
@NotBlank String successRedirectUrl, @NotBlank String failureRedirectUrl) {
11+
12+
public OAuth2Properties {
13+
if (successRedirectUrl == null || successRedirectUrl.isBlank()) {
14+
successRedirectUrl = "http://localhost:3000/auth/callback";
15+
}
16+
if (failureRedirectUrl == null || failureRedirectUrl.isBlank()) {
17+
failureRedirectUrl = "http://localhost:3000/auth/error";
18+
}
19+
}
20+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.nkcoder.shared.util;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
5+
public final class UrlUtils {
6+
7+
private UrlUtils() {
8+
// Utility class
9+
}
10+
11+
public static String getBaseUrl(HttpServletRequest request) {
12+
String scheme = request.getScheme();
13+
String serverName = request.getServerName();
14+
int port = request.getServerPort();
15+
16+
if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) {
17+
return scheme + "://" + serverName;
18+
}
19+
return scheme + "://" + serverName + ":" + port;
20+
}
21+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package org.nkcoder.user.application.dto.command;
2+
3+
import org.nkcoder.user.domain.model.OAuth2Provider;
4+
import org.nkcoder.user.domain.model.OAuth2ProviderId;
5+
6+
/** Command for OAuth2 login containing user info from OAuth2 provider. */
7+
public record OAuth2LoginCommand(
8+
OAuth2Provider provider,
9+
OAuth2ProviderId providerId,
10+
String email,
11+
String name,
12+
String avatarUrl,
13+
boolean emailVerified) {}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package org.nkcoder.user.application.service;
2+
3+
import java.time.LocalDateTime;
4+
import java.util.Arrays;
5+
import java.util.List;
6+
import java.util.Optional;
7+
import java.util.stream.Collectors;
8+
import org.nkcoder.shared.kernel.domain.event.DomainEventPublisher;
9+
import org.nkcoder.shared.kernel.domain.event.UserRegisteredEvent;
10+
import org.nkcoder.shared.kernel.exception.AuthenticationException;
11+
import org.nkcoder.shared.kernel.exception.ValidationException;
12+
import org.nkcoder.user.application.dto.command.OAuth2LoginCommand;
13+
import org.nkcoder.user.application.dto.response.AuthResult;
14+
import org.nkcoder.user.domain.model.Email;
15+
import org.nkcoder.user.domain.model.OAuth2Connection;
16+
import org.nkcoder.user.domain.model.OAuth2Provider;
17+
import org.nkcoder.user.domain.model.RefreshToken;
18+
import org.nkcoder.user.domain.model.TokenFamily;
19+
import org.nkcoder.user.domain.model.TokenPair;
20+
import org.nkcoder.user.domain.model.User;
21+
import org.nkcoder.user.domain.model.UserId;
22+
import org.nkcoder.user.domain.model.UserName;
23+
import org.nkcoder.user.domain.repository.OAuth2ConnectionRepository;
24+
import org.nkcoder.user.domain.repository.RefreshTokenRepository;
25+
import org.nkcoder.user.domain.repository.UserRepository;
26+
import org.nkcoder.user.domain.service.TokenGenerator;
27+
import org.nkcoder.user.domain.service.TokenRotationService;
28+
import org.slf4j.Logger;
29+
import org.slf4j.LoggerFactory;
30+
import org.springframework.stereotype.Service;
31+
import org.springframework.transaction.annotation.Transactional;
32+
33+
@Service
34+
public class OAuth2ApplicationService {
35+
36+
private static final Logger logger = LoggerFactory.getLogger(OAuth2ApplicationService.class);
37+
38+
private static final String USER_NOT_FOUND = "User not found";
39+
private static final String CANNOT_UNLINK_LAST_LOGIN_METHOD = "Cannot unlink the last login method";
40+
private static final String PROVIDER_NOT_LINKED = "OAuth2 provider is not linked to this account";
41+
42+
private final UserRepository userRepository;
43+
private final OAuth2ConnectionRepository oauth2ConnectionRepository;
44+
private final RefreshTokenRepository refreshTokenRepository;
45+
private final TokenGenerator tokenGenerator;
46+
private final TokenRotationService tokenRotationService;
47+
private final DomainEventPublisher eventPublisher;
48+
49+
public OAuth2ApplicationService(
50+
UserRepository userRepository,
51+
OAuth2ConnectionRepository oauth2ConnectionRepository,
52+
RefreshTokenRepository refreshTokenRepository,
53+
TokenGenerator tokenGenerator,
54+
TokenRotationService tokenRotationService,
55+
DomainEventPublisher eventPublisher) {
56+
this.userRepository = userRepository;
57+
this.oauth2ConnectionRepository = oauth2ConnectionRepository;
58+
this.refreshTokenRepository = refreshTokenRepository;
59+
this.tokenGenerator = tokenGenerator;
60+
this.tokenRotationService = tokenRotationService;
61+
this.eventPublisher = eventPublisher;
62+
}
63+
64+
/** Process OAuth2 login. Finds existing user by provider ID or email, or creates a new user. */
65+
@Transactional
66+
public AuthResult processOAuth2Login(OAuth2LoginCommand command) {
67+
logger.debug("Processing OAuth2 login for provider: {}", command.provider());
68+
69+
// 1. Check if OAuth2 connection exists
70+
Optional<OAuth2Connection> existingConnection =
71+
oauth2ConnectionRepository.findByProviderAndProviderId(command.provider(), command.providerId());
72+
73+
if (existingConnection.isPresent()) {
74+
// Login existing user via OAuth2 connection
75+
User user = userRepository
76+
.findById(existingConnection.get().getUserId())
77+
.orElseThrow(() -> new AuthenticationException(USER_NOT_FOUND));
78+
logger.debug(
79+
"Found existing OAuth2 connection for user: {}",
80+
user.getEmail().value());
81+
return loginUser(user);
82+
}
83+
84+
// 2. Check if user with same email exists (auto-link)
85+
if (command.email() != null && command.emailVerified()) {
86+
Email email = Email.of(command.email());
87+
Optional<User> existingUser = userRepository.findByEmail(email);
88+
89+
if (existingUser.isPresent()) {
90+
// Link OAuth2 to existing user
91+
User user = existingUser.get();
92+
createOAuth2Connection(user.getId(), command);
93+
logger.debug("Linked OAuth2 provider {} to existing user: {}", command.provider(), email.value());
94+
return loginUser(user);
95+
}
96+
}
97+
98+
// 3. Create new user
99+
User newUser = registerNewUser(command);
100+
createOAuth2Connection(newUser.getId(), command);
101+
logger.debug("Created new user via OAuth2: {}", newUser.getEmail().value());
102+
103+
return loginUser(newUser);
104+
}
105+
106+
/** Link OAuth2 account to an existing authenticated user. */
107+
@Transactional
108+
public void linkOAuth2Account(UserId userId, OAuth2LoginCommand command) {
109+
logger.debug("Linking OAuth2 provider {} to user: {}", command.provider(), userId);
110+
111+
User user = userRepository.findById(userId).orElseThrow(() -> new AuthenticationException(USER_NOT_FOUND));
112+
113+
// Check if this provider ID is already linked to another account
114+
if (oauth2ConnectionRepository.existsByProviderAndProviderId(command.provider(), command.providerId())) {
115+
throw new ValidationException("This OAuth2 account is already linked to another user");
116+
}
117+
118+
createOAuth2Connection(userId, command);
119+
logger.debug(
120+
"Successfully linked OAuth2 provider {} to user: {}",
121+
command.provider(),
122+
user.getEmail().value());
123+
}
124+
125+
/** Unlink OAuth2 account from user. Ensures user has another login method. */
126+
@Transactional
127+
public void unlinkOAuth2Account(UserId userId, OAuth2Provider provider) {
128+
logger.debug("Unlinking OAuth2 provider {} from user: {}", provider, userId);
129+
130+
User user = userRepository.findById(userId).orElseThrow(() -> new AuthenticationException(USER_NOT_FOUND));
131+
132+
// Ensure user has another login method
133+
boolean hasPassword = user.getPassword() != null;
134+
int oAuth2ConnectionCount = oauth2ConnectionRepository.countByUserId(userId);
135+
136+
if (!hasPassword && oAuth2ConnectionCount <= 1) {
137+
throw new ValidationException(CANNOT_UNLINK_LAST_LOGIN_METHOD);
138+
}
139+
140+
// Check if the provider is actually linked
141+
List<OAuth2Connection> connections = oauth2ConnectionRepository.findByUserId(userId);
142+
boolean isProviderLinked = connections.stream().anyMatch(c -> c.getProvider() == provider);
143+
144+
if (!isProviderLinked) {
145+
throw new ValidationException(PROVIDER_NOT_LINKED);
146+
}
147+
148+
oauth2ConnectionRepository.deleteByUserIdAndProvider(userId, provider);
149+
logger.debug(
150+
"Successfully unlinked OAuth2 provider {} from user: {}",
151+
provider,
152+
user.getEmail().value());
153+
}
154+
155+
/** Get connected OAuth2 providers for a user. */
156+
@Transactional(readOnly = true)
157+
public List<OAuth2Connection> getConnectedProviders(UserId userId) {
158+
return oauth2ConnectionRepository.findByUserId(userId);
159+
}
160+
161+
private User registerNewUser(OAuth2LoginCommand command) {
162+
Email email = Email.of(command.email());
163+
UserName name = command.name() != null ? UserName.of(command.name()) : generateNameFromEmail(email);
164+
165+
User newUser = User.registerWithOAuth2(email, name);
166+
User savedUser = userRepository.save(newUser);
167+
168+
eventPublisher.publish(new UserRegisteredEvent(
169+
savedUser.getId().value(),
170+
savedUser.getEmail().value(),
171+
savedUser.getName().value()));
172+
173+
return savedUser;
174+
}
175+
176+
private void createOAuth2Connection(UserId userId, OAuth2LoginCommand command) {
177+
OAuth2Connection connection = OAuth2Connection.create(
178+
userId, command.provider(), command.providerId(), command.email(), command.name(), command.avatarUrl());
179+
180+
oauth2ConnectionRepository.save(connection);
181+
}
182+
183+
private AuthResult loginUser(User user) {
184+
userRepository.updateLastLoginAt(user.getId(), LocalDateTime.now());
185+
186+
TokenFamily tokenFamily = TokenFamily.generate();
187+
TokenPair tokens = tokenRotationService.generateTokens(user, tokenFamily);
188+
189+
RefreshToken refreshToken = RefreshToken.create(
190+
tokens.refreshToken(), tokenFamily, user.getId(), tokenGenerator.getRefreshTokenExpiry());
191+
refreshTokenRepository.save(refreshToken);
192+
193+
return AuthResult.of(user.getId().value(), user.getEmail().value(), user.getRole(), tokens);
194+
}
195+
196+
private UserName generateNameFromEmail(Email email) {
197+
return Optional.of(email.value())
198+
.map(e -> e.split("@")[0])
199+
.map(localPart -> localPart.replace(".", " ").replace("_", " ").replace("-", " "))
200+
.map(this::capitalizeWords)
201+
.map(UserName::of)
202+
.orElseGet(() -> UserName.of("User"));
203+
}
204+
205+
private String capitalizeWords(String input) {
206+
return Arrays.stream(input.split("\\s+"))
207+
.filter(part -> !part.isEmpty())
208+
.map(part ->
209+
part.substring(0, 1).toUpperCase() + part.substring(1).toLowerCase())
210+
.collect(Collectors.joining(" "));
211+
}
212+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package org.nkcoder.user.domain.model;
2+
3+
import java.time.LocalDateTime;
4+
import java.util.Objects;
5+
import java.util.UUID;
6+
7+
/** Domain entity representing a connection between a user and an OAuth2 provider. */
8+
public class OAuth2Connection {
9+
10+
private final UUID id;
11+
private final UserId userId;
12+
private final OAuth2Provider provider;
13+
private final OAuth2ProviderId providerId;
14+
private final String email;
15+
private final String name;
16+
private final String avatarUrl;
17+
private final LocalDateTime createdAt;
18+
private LocalDateTime updatedAt;
19+
20+
private OAuth2Connection(
21+
UUID id,
22+
UserId userId,
23+
OAuth2Provider provider,
24+
OAuth2ProviderId providerId,
25+
String email,
26+
String name,
27+
String avatarUrl,
28+
LocalDateTime createdAt,
29+
LocalDateTime updatedAt) {
30+
this.id = Objects.requireNonNull(id, "id cannot be null");
31+
this.userId = Objects.requireNonNull(userId, "userId cannot be null");
32+
this.provider = Objects.requireNonNull(provider, "provider cannot be null");
33+
this.providerId = Objects.requireNonNull(providerId, "providerId cannot be null");
34+
this.email = email;
35+
this.name = name;
36+
this.avatarUrl = avatarUrl;
37+
this.createdAt = Objects.requireNonNull(createdAt, "createdAt cannot be null");
38+
this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt cannot be null");
39+
}
40+
41+
/** Factory method for creating a new OAuth2 connection. */
42+
public static OAuth2Connection create(
43+
UserId userId,
44+
OAuth2Provider provider,
45+
OAuth2ProviderId providerId,
46+
String email,
47+
String name,
48+
String avatarUrl) {
49+
LocalDateTime now = LocalDateTime.now();
50+
return new OAuth2Connection(UUID.randomUUID(), userId, provider, providerId, email, name, avatarUrl, now, now);
51+
}
52+
53+
/** Factory method for reconstituting from persistence. */
54+
public static OAuth2Connection reconstitute(
55+
UUID id,
56+
UserId userId,
57+
OAuth2Provider provider,
58+
OAuth2ProviderId providerId,
59+
String email,
60+
String name,
61+
String avatarUrl,
62+
LocalDateTime createdAt,
63+
LocalDateTime updatedAt) {
64+
return new OAuth2Connection(id, userId, provider, providerId, email, name, avatarUrl, createdAt, updatedAt);
65+
}
66+
67+
public UUID getId() {
68+
return id;
69+
}
70+
71+
public UserId getUserId() {
72+
return userId;
73+
}
74+
75+
public OAuth2Provider getProvider() {
76+
return provider;
77+
}
78+
79+
public OAuth2ProviderId getProviderId() {
80+
return providerId;
81+
}
82+
83+
public String getEmail() {
84+
return email;
85+
}
86+
87+
public String getName() {
88+
return name;
89+
}
90+
91+
public String getAvatarUrl() {
92+
return avatarUrl;
93+
}
94+
95+
public LocalDateTime getCreatedAt() {
96+
return createdAt;
97+
}
98+
99+
public LocalDateTime getUpdatedAt() {
100+
return updatedAt;
101+
}
102+
103+
@Override
104+
public boolean equals(Object o) {
105+
if (this == o) return true;
106+
if (o == null || getClass() != o.getClass()) return false;
107+
OAuth2Connection that = (OAuth2Connection) o;
108+
return Objects.equals(id, that.id);
109+
}
110+
111+
@Override
112+
public int hashCode() {
113+
return Objects.hash(id);
114+
}
115+
}

0 commit comments

Comments
 (0)