Skip to content

Commit c80067c

Browse files
authored
fix(auth): require verified email before OAuth account merge (#11)
Prevent OAuth auto-link by email when the IdP has not verified the address or the tenant account has not completed email confirmation; align GitHub provider with platform login by selecting only verified emails.
1 parent ff843ae commit c80067c

4 files changed

Lines changed: 151 additions & 14 deletions

File tree

src/main/java/ai/nubase/auth/oauth/GitHubOAuthProvider.java

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ private OAuthUserInfo fetchUserInfo(String accessToken) throws Exception {
109109

110110
JsonNode userInfo = objectMapper.readTree(userResponse.getBody());
111111

112-
// Get user emails (GitHub may not include email in basic info)
112+
// 与 PlatformOAuthService 一致:只采用 GitHub /user/emails 中 verified 的地址,不信任公开 profile email。
113113
String email = null;
114114
boolean emailVerified = false;
115115
try {
@@ -122,28 +122,30 @@ private OAuthUserInfo fetchUserInfo(String accessToken) throws Exception {
122122

123123
JsonNode emails = objectMapper.readTree(emailResponse.getBody());
124124
if (emails.isArray()) {
125+
String firstVerified = null;
125126
for (JsonNode emailNode : emails) {
127+
if (!emailNode.has("verified") || !emailNode.get("verified").asBoolean()) {
128+
continue;
129+
}
130+
String candidate = emailNode.get("email").asText();
126131
if (emailNode.has("primary") && emailNode.get("primary").asBoolean()) {
127-
email = emailNode.get("email").asText();
128-
emailVerified = emailNode.has("verified") && emailNode.get("verified").asBoolean();
132+
email = candidate;
133+
emailVerified = true;
129134
break;
130135
}
136+
if (firstVerified == null) {
137+
firstVerified = candidate;
138+
}
131139
}
132-
// If no primary email, use the first one
133-
if (email == null && emails.size() > 0) {
134-
email = emails.get(0).get("email").asText();
135-
emailVerified = emails.get(0).has("verified") && emails.get(0).get("verified").asBoolean();
140+
if (email == null && firstVerified != null) {
141+
email = firstVerified;
142+
emailVerified = true;
136143
}
137144
}
138145
} catch (Exception e) {
139146
log.warn("Failed to fetch GitHub user emails: {}", e.getMessage());
140147
}
141148

142-
// If still no email, try to get from basic user info
143-
if (email == null && userInfo.has("email") && !userInfo.get("email").isNull()) {
144-
email = userInfo.get("email").asText();
145-
}
146-
147149
return OAuthUserInfo.builder()
148150
.providerId(String.valueOf(userInfo.get("id").asLong()))
149151
.provider("github")

src/main/java/ai/nubase/auth/service/OAuthService.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public class OAuthService {
4747
private final TokenService tokenService;
4848
private final UserMapper userMapper;
4949
private final AuthConfig authConfig;
50+
private final EffectiveAuthConfig effectiveAuthConfig;
5051
private final AuthResponseFactory authResponseFactory;
5152
private final ObjectMapper objectMapper = new ObjectMapper();
5253

@@ -192,8 +193,17 @@ private User findOrCreateUser(OAuthUserInfo oauthUserInfo) {
192193
}
193194

194195
if (existingUser.isPresent()) {
195-
log.debug("Found existing user with email: {}", oauthUserInfo.getEmail());
196+
// 与新建用户路径一致:仅 IdP 已验证邮箱才允许按 email 自动合并,避免未验证邮箱绑到他人账号。
197+
// 租户开启邮箱确认时,还要求目标账号已完成确认,防止 OAuth 绕过密码注册的确认流程。
198+
if (!oauthUserInfo.isEmailVerified()) {
199+
throw new RuntimeException(
200+
"OAuth email is not verified by the provider; cannot link to an existing account");
201+
}
196202
User user = existingUser.get();
203+
if (effectiveAuthConfig.emailConfirmationRequired() && user.getEmailConfirmedAt() == null) {
204+
throw new RuntimeException("Existing account email is not confirmed");
205+
}
206+
log.debug("Linking OAuth identity to confirmed user by verified email: {}", oauthUserInfo.getEmail());
197207
mergeProviderMetadata(user, oauthUserInfo.getProvider());
198208
return userRepository.save(user);
199209
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package ai.nubase.auth.service;
2+
3+
import ai.nubase.auth.dto.oauth.OAuthUserInfo;
4+
import ai.nubase.auth.entity.User;
5+
import ai.nubase.auth.oauth.OAuthProvider;
6+
import ai.nubase.auth.repository.IdentityRepository;
7+
import ai.nubase.auth.repository.SessionRepository;
8+
import ai.nubase.auth.repository.UserRepository;
9+
import ai.nubase.auth.util.UserMapper;
10+
import ai.nubase.common.config.AuthConfig;
11+
import org.junit.jupiter.api.BeforeEach;
12+
import org.junit.jupiter.api.DisplayName;
13+
import org.junit.jupiter.api.Test;
14+
15+
import java.time.Instant;
16+
import java.util.Map;
17+
import java.util.Optional;
18+
import java.util.UUID;
19+
20+
import static org.assertj.core.api.Assertions.assertThat;
21+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
22+
import static org.mockito.ArgumentMatchers.any;
23+
import static org.mockito.Mockito.mock;
24+
import static org.mockito.Mockito.never;
25+
import static org.mockito.Mockito.verify;
26+
import static org.mockito.Mockito.when;
27+
28+
@DisplayName("OAuthService email auto-link guards")
29+
class OAuthServiceEmailLinkTest {
30+
31+
private UserRepository userRepository;
32+
private IdentityRepository identityRepository;
33+
private EffectiveAuthConfig effectiveAuthConfig;
34+
private OAuthService svc;
35+
36+
private final UUID victimId = UUID.randomUUID();
37+
38+
@BeforeEach
39+
void setUp() {
40+
userRepository = mock(UserRepository.class);
41+
identityRepository = mock(IdentityRepository.class);
42+
effectiveAuthConfig = mock(EffectiveAuthConfig.class);
43+
OAuthProvider provider = mock(OAuthProvider.class);
44+
45+
svc = new OAuthService(
46+
Map.of("github", provider),
47+
userRepository,
48+
identityRepository,
49+
mock(SessionRepository.class),
50+
mock(JwtSecretService.class),
51+
mock(TokenService.class),
52+
new UserMapper(),
53+
new AuthConfig(),
54+
effectiveAuthConfig,
55+
mock(AuthResponseFactory.class));
56+
57+
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
58+
when(identityRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
59+
when(identityRepository.findByProviderAndProviderId(any(), any())).thenReturn(Optional.empty());
60+
when(effectiveAuthConfig.emailConfirmationRequired()).thenReturn(true);
61+
}
62+
63+
@Test
64+
@DisplayName("rejects auto-link when OAuth email is not provider-verified")
65+
void rejectsUnverifiedProviderEmail() {
66+
User victim = confirmedUser("victim@x.com");
67+
when(userRepository.findByEmail("victim@x.com")).thenReturn(Optional.of(victim));
68+
69+
OAuthUserInfo info = OAuthUserInfo.builder()
70+
.provider("github").providerId("gh-1")
71+
.email("victim@x.com").emailVerified(false).build();
72+
73+
assertThatThrownBy(() -> svc.signInWithProviderInfo(info, false))
74+
.isInstanceOf(RuntimeException.class)
75+
.hasMessageContaining("not verified by the provider");
76+
77+
verify(userRepository, never()).save(any());
78+
}
79+
80+
@Test
81+
@DisplayName("rejects auto-link when existing account email is not confirmed")
82+
void rejectsUnconfirmedTenantEmail() {
83+
User victim = User.builder()
84+
.id(victimId)
85+
.email("victim@x.com")
86+
.emailConfirmedAt(null)
87+
.build();
88+
when(userRepository.findByEmail("victim@x.com")).thenReturn(Optional.of(victim));
89+
90+
OAuthUserInfo info = OAuthUserInfo.builder()
91+
.provider("github").providerId("gh-1")
92+
.email("victim@x.com").emailVerified(true).build();
93+
94+
assertThatThrownBy(() -> svc.signInWithProviderInfo(info, false))
95+
.isInstanceOf(RuntimeException.class)
96+
.hasMessageContaining("not confirmed");
97+
}
98+
99+
@Test
100+
@DisplayName("allows auto-link when provider email is verified and tenant email is confirmed")
101+
void allowsVerifiedLink() {
102+
User victim = confirmedUser("victim@x.com");
103+
when(userRepository.findByEmail("victim@x.com")).thenReturn(Optional.of(victim));
104+
105+
OAuthUserInfo info = OAuthUserInfo.builder()
106+
.provider("github").providerId("gh-1")
107+
.email("victim@x.com").emailVerified(true).build();
108+
109+
OAuthService.ProviderSignIn result = svc.signInWithProviderInfo(info, false);
110+
111+
assertThat(result.user().getId()).isEqualTo(victimId);
112+
verify(userRepository, org.mockito.Mockito.atLeastOnce()).save(victim);
113+
verify(identityRepository, org.mockito.Mockito.atLeastOnce()).save(any());
114+
}
115+
116+
private User confirmedUser(String email) {
117+
return User.builder()
118+
.id(victimId)
119+
.email(email)
120+
.emailConfirmedAt(Instant.now())
121+
.build();
122+
}
123+
}

src/test/java/ai/nubase/auth/service/OAuthServiceTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class OAuthServiceTest {
3232
private UserRepository userRepository;
3333
private IdentityRepository identityRepository;
3434
private AuthResponseFactory authResponseFactory;
35+
private EffectiveAuthConfig effectiveAuthConfig;
3536
private OAuthProvider provider;
3637
private OAuthService svc;
3738

@@ -43,6 +44,7 @@ void setUp() {
4344
userRepository = mock(UserRepository.class);
4445
identityRepository = mock(IdentityRepository.class);
4546
authResponseFactory = mock(AuthResponseFactory.class);
47+
effectiveAuthConfig = mock(EffectiveAuthConfig.class);
4648
provider = mock(OAuthProvider.class);
4749
when(provider.getProviderName()).thenReturn("google");
4850
when(provider.getUserInfo("code", "uri")).thenReturn(OAuthUserInfo.builder()
@@ -52,7 +54,7 @@ void setUp() {
5254
Map.of("google", provider), userRepository, identityRepository,
5355
mock(ai.nubase.auth.repository.SessionRepository.class), mock(JwtSecretService.class),
5456
mock(TokenService.class), new ai.nubase.auth.util.UserMapper(), new AuthConfig(),
55-
authResponseFactory);
57+
effectiveAuthConfig, authResponseFactory);
5658

5759
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
5860
when(identityRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

0 commit comments

Comments
 (0)