mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
fix: 钉钉OAuth2安全加固与身份标识优化
1. 身份标识: openId → unionId (跨应用唯一) - DingTalkClaimsExtractor: subject使用unionId, 新增null校验 - DingTalkOAuth2UserService: nameAttribute改为unionId - application.yml: user-name-attribute改为unionId 2. Scope配置: dingtalk → openid (钉钉OAuth2正确scope) - application.yml: scope改为openid - .env.release.example: 新增scope说明注释 3. RestTemplate超时配置 (5s connect + 10s read) - DingTalkTokenResponseClient: 防止无限阻塞 - DingTalkOAuth2UserService: 同步配置 4. NPE风险修复 - DingTalkTokenResponseClient: JsonNode null检查 5. 敏感信息泄露修复 - DingTalkTokenResponseClient: 移除raw_response, 只保留expireIn 6. 硬编码URL修复 - DingTalkOAuth2UserService: userInfoUri从ClientRegistration配置读取 7. 单元测试覆盖 (12个测试全部通过) - DingTalkClaimsExtractorTest: 4个 - DingTalkTokenResponseClientTest: 6个 - DingTalkOAuth2UserServiceTest: 2个 Signed-off-by: konglong87 <38234954+konglong87@users.noreply.github.com>
This commit is contained in:
parent
cf746827d9
commit
0216cdfdee
8 changed files with 514 additions and 17 deletions
|
|
@ -94,6 +94,8 @@ OAUTH2_GITLAB_DISPLAY_NAME=GitLab
|
|||
|
||||
# Optional: configure DingTalk (钉钉) OAuth2 login.
|
||||
# Register your app at https://open-dev.dingtalk.com and request the Contact.User.Read scope.
|
||||
# The scope must be "openid" (not "dingtalk") — DingTalk uses openid for OAuth2 authorization.
|
||||
# Add "openid corpid" if you also need corporate identity information.
|
||||
OAUTH2_DINGTALK_CLIENT_ID=
|
||||
OAUTH2_DINGTALK_CLIENT_SECRET=
|
||||
OAUTH2_DINGTALK_DISPLAY_NAME=钉钉
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ spring:
|
|||
client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder}
|
||||
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder}
|
||||
scope:
|
||||
- dingtalk
|
||||
- openid
|
||||
authorization-grant-type: authorization_code
|
||||
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
|
||||
client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉}
|
||||
|
|
@ -89,7 +89,7 @@ spring:
|
|||
authorization-uri: https://login.dingtalk.com/oauth2/auth
|
||||
token-uri: https://api.dingtalk.com/v1.0/oauth2/userAccessToken
|
||||
user-info-uri: https://api.dingtalk.com/v1.0/contact/users/me
|
||||
user-name-attribute: openId
|
||||
user-name-attribute: unionId
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 100MB
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
|
|
@ -14,11 +16,16 @@ import java.util.Map;
|
|||
*
|
||||
* <p>Field mapping:
|
||||
* <ul>
|
||||
* <li>subject → openId</li>
|
||||
* <li>subject → unionId (unique across all apps under the same developer account)</li>
|
||||
* <li>email → unionId@dingtalk.local (synthetic, DingTalk users may not have email)</li>
|
||||
* <li>emailVerified → true (synthetic)</li>
|
||||
* <li>providerLogin → nick</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Note: unionId is used instead of openId because openId is only unique within
|
||||
* a single DingTalk application. If a user logs in through different DingTalk apps
|
||||
* under the same developer account, openId would differ, causing duplicate accounts.
|
||||
* unionId remains stable across all apps under the same developer.
|
||||
*/
|
||||
@Component
|
||||
public class DingTalkClaimsExtractor implements OAuthClaimsExtractor {
|
||||
|
|
@ -32,18 +39,25 @@ public class DingTalkClaimsExtractor implements OAuthClaimsExtractor {
|
|||
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
|
||||
Map<String, Object> attrs = oAuth2User.getAttributes();
|
||||
|
||||
String openId = (String) attrs.get("openId");
|
||||
String unionId = (String) attrs.get("unionId");
|
||||
String openId = (String) attrs.get("openId");
|
||||
String nick = (String) attrs.get("nick");
|
||||
|
||||
// unionId is required — it is the cross-app stable identity for DingTalk users
|
||||
if (unionId == null || unionId.isEmpty()) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("missing_union_id",
|
||||
"DingTalk response missing required unionId field. "
|
||||
+ "Ensure the 'openid' scope is configured and the DingTalk app "
|
||||
+ "has the Contact.User.Read permission.", null));
|
||||
}
|
||||
|
||||
// DingTalk users may not have email; synthesize one for downstream compatibility
|
||||
String syntheticEmail = (unionId != null && !unionId.isEmpty())
|
||||
? unionId + "@dingtalk.local"
|
||||
: (openId != null ? openId + "@dingtalk.local" : null);
|
||||
String syntheticEmail = unionId + "@dingtalk.local";
|
||||
|
||||
return new OAuthClaims(
|
||||
"dingtalk",
|
||||
openId,
|
||||
unionId, // Use unionId (cross-app unique) instead of openId (single-app only)
|
||||
syntheticEmail,
|
||||
true,
|
||||
nick,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
|
|
@ -10,6 +11,7 @@ import org.springframework.http.HttpEntity;
|
|||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
|
|
@ -41,14 +43,32 @@ public class DingTalkOAuth2UserService implements OAuth2UserService<OAuth2UserRe
|
|||
|
||||
public DingTalkOAuth2UserService(DingTalkClaimsExtractor claimsExtractor,
|
||||
OAuthLoginFlowService oauthLoginFlowService) {
|
||||
this.restTemplate = new RestTemplate();
|
||||
this.restTemplate = buildRestTemplate();
|
||||
this.claimsExtractor = claimsExtractor;
|
||||
this.oauthLoginFlowService = oauthLoginFlowService;
|
||||
}
|
||||
|
||||
/** Package-visible constructor for unit testing with a mock RestTemplate. */
|
||||
DingTalkOAuth2UserService(DingTalkClaimsExtractor claimsExtractor,
|
||||
OAuthLoginFlowService oauthLoginFlowService,
|
||||
RestTemplate restTemplate) {
|
||||
this.restTemplate = restTemplate;
|
||||
this.claimsExtractor = claimsExtractor;
|
||||
this.oauthLoginFlowService = oauthLoginFlowService;
|
||||
}
|
||||
|
||||
private static RestTemplate buildRestTemplate() {
|
||||
var factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofSeconds(5));
|
||||
factory.setReadTimeout(Duration.ofSeconds(10));
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
|
||||
String accessToken = userRequest.getAccessToken().getTokenValue();
|
||||
String userInfoUri = userRequest.getClientRegistration().getProviderDetails()
|
||||
.getUserInfoEndpoint().getUri();
|
||||
|
||||
// Fetch user info using DingTalk's custom header
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
|
|
@ -56,7 +76,7 @@ public class DingTalkOAuth2UserService implements OAuth2UserService<OAuth2UserRe
|
|||
HttpEntity<Void> requestEntity = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<Map> response = restTemplate.exchange(
|
||||
"https://api.dingtalk.com/v1.0/contact/users/me",
|
||||
userInfoUri,
|
||||
HttpMethod.GET,
|
||||
requestEntity,
|
||||
Map.class
|
||||
|
|
@ -70,9 +90,9 @@ public class DingTalkOAuth2UserService implements OAuth2UserService<OAuth2UserRe
|
|||
userAttributes.putIfAbsent("nickName", attributes.get("nick"));
|
||||
userAttributes.putIfAbsent("avatarUrl", attributes.get("avatarUrl"));
|
||||
|
||||
// Extract claims
|
||||
// Extract claims — use unionId as the name attribute (cross-app unique identity)
|
||||
OAuthClaims claims = claimsExtractor.extract(userRequest, new DefaultOAuth2User(
|
||||
java.util.Collections.emptyList(), userAttributes, "openId"));
|
||||
java.util.Collections.emptyList(), userAttributes, "unionId"));
|
||||
|
||||
log.info("DingTalk OAuth2 login: subject={}, providerLogin={}", claims.subject(), claims.providerLogin());
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import org.springframework.http.HttpEntity;
|
|||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
|
|
@ -15,6 +16,7 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenRespon
|
|||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
|
|
@ -31,7 +33,23 @@ import java.util.Map;
|
|||
public class DingTalkTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public DingTalkTokenResponseClient() {
|
||||
this.restTemplate = buildRestTemplate();
|
||||
}
|
||||
|
||||
/** Package-visible constructor for unit testing with a mock RestTemplate. */
|
||||
DingTalkTokenResponseClient(RestTemplate restTemplate) {
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
private static RestTemplate buildRestTemplate() {
|
||||
var factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofSeconds(5));
|
||||
factory.setReadTimeout(Duration.ofSeconds(10));
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest)
|
||||
|
|
@ -65,15 +83,30 @@ public class DingTalkTokenResponseClient implements OAuth2AccessTokenResponseCli
|
|||
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
||||
try {
|
||||
JsonNode json = MAPPER.readTree(response.getBody());
|
||||
String accessToken = json.get("accessToken").asText();
|
||||
if (accessToken == null || accessToken.isEmpty()) {
|
||||
|
||||
JsonNode accessTokenNode = json.get("accessToken");
|
||||
if (accessTokenNode == null || accessTokenNode.isNull()) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("token_response_missing_field",
|
||||
"DingTalk token response missing accessToken", null));
|
||||
"DingTalk token response missing accessToken field", null));
|
||||
}
|
||||
String accessToken = accessTokenNode.asText();
|
||||
if (accessToken.isEmpty()) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error("token_response_missing_field",
|
||||
"DingTalk token response has empty accessToken", null));
|
||||
}
|
||||
|
||||
// Only include non-sensitive fields in additional parameters
|
||||
Map<String, Object> safeParams = new java.util.LinkedHashMap<>();
|
||||
JsonNode expireInNode = json.get("expireIn");
|
||||
if (expireInNode != null && !expireInNode.isNull()) {
|
||||
safeParams.put("expireIn", expireInNode.asLong());
|
||||
}
|
||||
|
||||
return OAuth2AccessTokenResponse.withToken(accessToken)
|
||||
.tokenType(OAuth2AccessToken.TokenType.BEARER)
|
||||
.additionalParameters(Collections.singletonMap("raw_response", response.getBody()))
|
||||
.additionalParameters(safeParams)
|
||||
.build();
|
||||
} catch (OAuth2AuthenticationException e) {
|
||||
throw e;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
|
||||
class DingTalkClaimsExtractorTest {
|
||||
|
||||
private final DingTalkClaimsExtractor extractor = new DingTalkClaimsExtractor();
|
||||
|
||||
@Test
|
||||
void extract_usesUnionIdAsSubject() {
|
||||
OAuthClaims claims = extractor.extract(
|
||||
userRequest(),
|
||||
new DefaultOAuth2User(
|
||||
java.util.List.of(),
|
||||
Map.of(
|
||||
"unionId", "union123",
|
||||
"openId", "open456",
|
||||
"nick", "测试用户"
|
||||
),
|
||||
"unionId"
|
||||
)
|
||||
);
|
||||
|
||||
assertThat(claims.provider()).isEqualTo("dingtalk");
|
||||
assertThat(claims.subject()).isEqualTo("union123");
|
||||
assertThat(claims.email()).isEqualTo("union123@dingtalk.local");
|
||||
assertThat(claims.emailVerified()).isTrue();
|
||||
assertThat(claims.providerLogin()).isEqualTo("测试用户");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_throwsWhenUnionIdIsMissing() {
|
||||
assertThatThrownBy(() -> extractor.extract(
|
||||
userRequest(),
|
||||
new DefaultOAuth2User(
|
||||
java.util.List.of(),
|
||||
Map.of(
|
||||
"openId", "open456",
|
||||
"nick", "测试用户"
|
||||
),
|
||||
"openId"
|
||||
)
|
||||
)).isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("missing_union_id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extract_throwsWhenUnionIdIsEmpty() {
|
||||
assertThatThrownBy(() -> extractor.extract(
|
||||
userRequest(),
|
||||
new DefaultOAuth2User(
|
||||
java.util.List.of(),
|
||||
Map.of(
|
||||
"unionId", "",
|
||||
"openId", "open456",
|
||||
"nick", "测试用户"
|
||||
),
|
||||
"openId"
|
||||
)
|
||||
)).isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("missing_union_id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProvider_returnsDingtalk() {
|
||||
assertThat(extractor.getProvider()).isEqualTo("dingtalk");
|
||||
}
|
||||
|
||||
private OAuth2UserRequest userRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("dingzgzf3b9k7jv74iq2")
|
||||
.clientSecret("test-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope("openid")
|
||||
.authorizationUri("https://login.dingtalk.com/oauth2/auth")
|
||||
.tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
|
||||
.userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me")
|
||||
.userNameAttributeName("unionId")
|
||||
.clientName("钉钉")
|
||||
.build();
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"test-access-token",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600)
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
|
||||
class DingTalkOAuth2UserServiceTest {
|
||||
|
||||
private DingTalkOAuth2UserService service;
|
||||
private DingTalkClaimsExtractor claimsExtractor;
|
||||
private OAuthLoginFlowService oauthLoginFlowService;
|
||||
private MockRestServiceServer mockServer;
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
claimsExtractor = new DingTalkClaimsExtractor();
|
||||
oauthLoginFlowService = mock(OAuthLoginFlowService.class);
|
||||
restTemplate = new RestTemplate();
|
||||
mockServer = MockRestServiceServer.createServer(restTemplate);
|
||||
service = new DingTalkOAuth2UserService(claimsExtractor, oauthLoginFlowService, restTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_fetchesUserInfoWithCustomHeaderAndReturnsOAuth2User() {
|
||||
// Mock DingTalk user info API response
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header("x-acs-dingtalk-access-token", "test-access-token"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"unionId": "union123",
|
||||
"openId": "open456",
|
||||
"nick": "测试用户",
|
||||
"avatarUrl": "https://example.com/avatar.jpg"
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
// Mock OAuthLoginFlowService to return a principal
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-union123", "测试用户", "union123@dingtalk.local",
|
||||
"https://example.com/avatar.jpg", "dingtalk", Set.of("USER")
|
||||
);
|
||||
when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal);
|
||||
|
||||
OAuth2User oauth2User = service.loadUser(userRequest());
|
||||
|
||||
assertThat(oauth2User.getName()).isEqualTo("user-union123");
|
||||
assertThat(oauth2User.getAttributes().get("unionId")).isEqualTo("union123");
|
||||
assertThat(oauth2User.getAttributes().get("platformPrincipal")).isEqualTo(principal);
|
||||
assertThat(oauth2User.getAttributes().get("providerLogin")).isEqualTo("user-union123");
|
||||
assertThat(oauth2User.getAuthorities().stream()
|
||||
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"))).isTrue();
|
||||
mockServer.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUser_readsUserInfoUriFromClientRegistration() {
|
||||
// Use a custom userInfoUri to verify it's read from config, not hardcoded
|
||||
String customUri = "https://custom-api.example.com/v1.0/contact/users/me";
|
||||
|
||||
mockServer.expect(requestTo(customUri))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header("x-acs-dingtalk-access-token", "test-access-token"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"unionId": "union789",
|
||||
"openId": "open012",
|
||||
"nick": "自定义用户"
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-union789", "自定义用户", "union789@dingtalk.local",
|
||||
null, "dingtalk", Set.of("USER")
|
||||
);
|
||||
when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal);
|
||||
|
||||
OAuth2User oauth2User = service.loadUser(userRequestWithCustomUri(customUri));
|
||||
|
||||
assertThat(oauth2User.getName()).isEqualTo("user-union789");
|
||||
mockServer.verify();
|
||||
}
|
||||
|
||||
private OAuth2UserRequest userRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("dingzgzf3b9k7jv74iq2")
|
||||
.clientSecret("test-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope("openid")
|
||||
.authorizationUri("https://login.dingtalk.com/oauth2/auth")
|
||||
.tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
|
||||
.userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me")
|
||||
.userNameAttributeName("unionId")
|
||||
.clientName("钉钉")
|
||||
.build();
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"test-access-token",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600)
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
|
||||
private OAuth2UserRequest userRequestWithCustomUri(String userInfoUri) {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("dingzgzf3b9k7jv74iq2")
|
||||
.clientSecret("test-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope("openid")
|
||||
.authorizationUri("https://login.dingtalk.com/oauth2/auth")
|
||||
.tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
|
||||
.userInfoUri(userInfoUri)
|
||||
.userNameAttributeName("unionId")
|
||||
.clientName("钉钉")
|
||||
.build();
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"test-access-token",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(3600)
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
class DingTalkTokenResponseClientTest {
|
||||
|
||||
private DingTalkTokenResponseClient client;
|
||||
private MockRestServiceServer mockServer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
mockServer = MockRestServiceServer.createServer(restTemplate);
|
||||
client = new DingTalkTokenResponseClient(restTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_returnsAccessTokenOnSuccess() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"accessToken": "dt_access_token_123",
|
||||
"expireIn": 7200
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
OAuth2AccessTokenResponse response = client.getTokenResponse(authorizationCodeGrantRequest());
|
||||
|
||||
assertThat(response.getAccessToken().getTokenValue()).isEqualTo("dt_access_token_123");
|
||||
assertThat(response.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER);
|
||||
assertThat(response.getAdditionalParameters().get("expireIn")).isEqualTo(7200L);
|
||||
// Verify raw_response is NOT included (sensitive data leak fix)
|
||||
assertThat(response.getAdditionalParameters().containsKey("raw_response")).isFalse();
|
||||
mockServer.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_throwsWhenAccessTokenFieldMissing() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"expireIn": 7200
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_throwsWhenAccessTokenIsNull() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"accessToken": null,
|
||||
"expireIn": 7200
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_throwsWhenAccessTokenIsEmpty() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"accessToken": "",
|
||||
"expireIn": 7200
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class)
|
||||
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_throwsOnHttpError() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withServerError());
|
||||
|
||||
assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest()))
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTokenResponse_doesNotIncludeExpireInWhenMissing() {
|
||||
mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"accessToken": "dt_access_token_123"
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON
|
||||
));
|
||||
|
||||
OAuth2AccessTokenResponse response = client.getTokenResponse(authorizationCodeGrantRequest());
|
||||
|
||||
assertThat(response.getAccessToken().getTokenValue()).isEqualTo("dt_access_token_123");
|
||||
assertThat(response.getAdditionalParameters().containsKey("expireIn")).isFalse();
|
||||
assertThat(response.getAdditionalParameters().containsKey("raw_response")).isFalse();
|
||||
}
|
||||
|
||||
private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("dingzgzf3b9k7jv74iq2")
|
||||
.clientSecret("test-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope("openid")
|
||||
.authorizationUri("https://login.dingtalk.com/oauth2/auth")
|
||||
.tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
|
||||
.userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me")
|
||||
.userNameAttributeName("unionId")
|
||||
.clientName("钉钉")
|
||||
.build();
|
||||
|
||||
OAuth2AuthorizationRequest authRequest = OAuth2AuthorizationRequest.authorizationCode()
|
||||
.clientId(registration.getClientId())
|
||||
.authorizationUri(registration.getProviderDetails().getAuthorizationUri())
|
||||
.redirectUri(registration.getRedirectUri())
|
||||
.scopes(registration.getScopes())
|
||||
.state("test-state")
|
||||
.build();
|
||||
|
||||
OAuth2AuthorizationResponse authResponse = OAuth2AuthorizationResponse.success("test-code")
|
||||
.redirectUri(registration.getRedirectUri())
|
||||
.state("test-state")
|
||||
.build();
|
||||
|
||||
return new OAuth2AuthorizationCodeGrantRequest(
|
||||
registration,
|
||||
new OAuth2AuthorizationExchange(authRequest, authResponse)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue