From 321d2b4d94b8ca4bada6751a3e98b545a4478a5e Mon Sep 17 00:00:00 2001 From: konglong87 <38234954+konglong87@users.noreply.github.com> Date: Thu, 28 May 2026 14:56:20 +0800 Subject: [PATCH 1/8] feat(auth): add DingTalk OAuth2 login support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DingTalk (钉钉) uses a non-standard OAuth2 flow that requires: - JSON body for token exchange (instead of form-urlencoded) - Custom header (x-acs-dingtalk-access-token) for user info requests This PR integrates DingTalk by leveraging the existing OAuthClaimsExtractor strategy pattern, adding three provider-specific components: - DingTalkClaimsExtractor: maps DingTalk user fields to normalized OAuthClaims - DingTalkTokenResponseClient: handles DingTalk's JSON token exchange - DingTalkOAuth2UserService: fetches user info via DingTalk's custom header SecurityConfig uses delegating wrappers to route DingTalk requests to these custom components while preserving standard behavior for all other providers (GitHub, GitLab, OIDC). No changes needed to OAuthLoginFlowService, IdentityBindingService, AuthMethodCatalog, or frontend LoginButton — all are provider-agnostic. Signed-off-by: konglong87 <38234954+konglong87@users.noreply.github.com> --- .env.release.example | 6 ++ .../src/main/resources/application.yml | 13 +++ .../skillhub/auth/config/SecurityConfig.java | 66 +++++++++++++- .../auth/oauth/DingTalkClaimsExtractor.java | 53 +++++++++++ .../auth/oauth/DingTalkOAuth2UserService.java | 86 ++++++++++++++++++ .../oauth/DingTalkTokenResponseClient.java | 91 +++++++++++++++++++ web/public/dingtalk-logo.svg | 4 + 7 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java create mode 100644 web/public/dingtalk-logo.svg diff --git a/.env.release.example b/.env.release.example index 59387e0f8..5b2fbc9d8 100644 --- a/.env.release.example +++ b/.env.release.example @@ -92,6 +92,12 @@ OAUTH2_GITLAB_CLIENT_SECRET= OAUTH2_GITLAB_BASE_URI=https://gitlab.com 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. +OAUTH2_DINGTALK_CLIENT_ID= +OAUTH2_DINGTALK_CLIENT_SECRET= +OAUTH2_DINGTALK_DISPLAY_NAME=钉钉 + # Optional: OIDC login (e.g. Keycloak, Okta, Azure AD). # Replace "OIDC" in variable names with your registration id (uppercase). # The registration id becomes identity_binding.provider_code — keep it stable. diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 421e27f68..5621c8c92 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -69,6 +69,14 @@ spring: authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab} + dingtalk: + client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder} + client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder} + scope: + - dingtalk + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉} provider: github: user-info-uri: https://api.github.com/user @@ -77,6 +85,11 @@ spring: token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user user-name-attribute: username + dingtalk: + 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 servlet: multipart: max-file-size: 100MB diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 8c2ff2dca..c58a718c6 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -2,6 +2,8 @@ import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; import com.iflytek.skillhub.auth.oauth.CustomOidcUserService; +import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2UserService; +import com.iflytek.skillhub.auth.oauth.DingTalkTokenResponseClient; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; @@ -19,6 +21,12 @@ import org.springframework.http.MediaType; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; @@ -57,6 +65,8 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final CustomOidcUserService customOidcUserService; + private final DingTalkOAuth2UserService dingTalkOAuth2UserService; + private final DingTalkTokenResponseClient dingTalkTokenResponseClient; private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver; private final OAuth2LoginSuccessHandler successHandler; private final OAuth2LoginFailureHandler failureHandler; @@ -69,6 +79,8 @@ public class SecurityConfig { public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomOidcUserService customOidcUserService, + DingTalkOAuth2UserService dingTalkOAuth2UserService, + DingTalkTokenResponseClient dingTalkTokenResponseClient, SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, OAuth2LoginSuccessHandler successHandler, OAuth2LoginFailureHandler failureHandler, @@ -80,6 +92,8 @@ public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) { this.customOAuth2UserService = customOAuth2UserService; this.customOidcUserService = customOidcUserService; + this.dingTalkOAuth2UserService = dingTalkOAuth2UserService; + this.dingTalkTokenResponseClient = dingTalkTokenResponseClient; this.authorizationRequestResolver = authorizationRequestResolver; this.successHandler = successHandler; this.failureHandler = failureHandler; @@ -120,8 +134,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { }) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver)) + .tokenEndpoint(token -> token.accessTokenResponseClient( + new DelegatingAccessTokenResponseClient(dingTalkTokenResponseClient, new DefaultAuthorizationCodeTokenResponseClient()))) .userInfoEndpoint(userInfo -> userInfo - .userService(customOAuth2UserService) + .userService(new DelegatingOAuth2UserService(customOAuth2UserService, dingTalkOAuth2UserService)) .oidcUserService(customOidcUserService)) .successHandler(successHandler) .failureHandler(failureHandler) @@ -186,7 +202,7 @@ private void configureRoutePolicies(AuthorizeHttpRequestsConfigurer { + private final CustomOAuth2UserService defaultService; + private final DingTalkOAuth2UserService dingTalkService; + + DelegatingOAuth2UserService(CustomOAuth2UserService defaultService, DingTalkOAuth2UserService dingTalkService) { + this.defaultService = defaultService; + this.dingTalkService = dingTalkService; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) { + if ("dingtalk".equals(userRequest.getClientRegistration().getRegistrationId())) { + return dingTalkService.loadUser(userRequest); + } + return defaultService.loadUser(userRequest); + } + } + + /** + * Delegates token exchange to the appropriate client based on the + * registrationId. DingTalk requires a JSON body instead of form-urlencoded; + * all other providers use the standard client. + */ + private static class DelegatingAccessTokenResponseClient implements OAuth2AccessTokenResponseClient { + private final DingTalkTokenResponseClient dingTalkClient; + private final DefaultAuthorizationCodeTokenResponseClient defaultClient; + + DelegatingAccessTokenResponseClient(DingTalkTokenResponseClient dingTalkClient, DefaultAuthorizationCodeTokenResponseClient defaultClient) { + this.dingTalkClient = dingTalkClient; + this.defaultClient = defaultClient; + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + if ("dingtalk".equals(authorizationCodeGrantRequest.getClientRegistration().getRegistrationId())) { + return dingTalkClient.getTokenResponse(authorizationCodeGrantRequest); + } + return defaultClient.getTokenResponse(authorizationCodeGrantRequest); + } + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java new file mode 100644 index 000000000..51f6d27b1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.auth.oauth; + +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * Provider-specific claims extractor for DingTalk (钉钉). + * + *

Maps DingTalk's non-standard user info fields into normalized {@link OAuthClaims} + * for downstream account provisioning and access policy evaluation. + * + *

Field mapping: + *

+ */ +@Component +public class DingTalkClaimsExtractor implements OAuthClaimsExtractor { + + @Override + public String getProvider() { + return "dingtalk"; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { + Map attrs = oAuth2User.getAttributes(); + + String openId = (String) attrs.get("openId"); + String unionId = (String) attrs.get("unionId"); + String nick = (String) attrs.get("nick"); + + // 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); + + return new OAuthClaims( + "dingtalk", + openId, + syntheticEmail, + true, + nick, + attrs + ); + } +} \ No newline at end of file diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java new file mode 100644 index 000000000..d22f4ad4a --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -0,0 +1,86 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import com.iflytek.skillhub.auth.identity.IdentityBindingService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.user.UserStatus; + +/** + * OAuth2UserService for DingTalk — handles DingTalk's non-standard user info + * endpoint which uses a custom header {@code x-acs-dingtalk-access-token} + * instead of the standard {@code Authorization: Bearer} header. + */ +@Component +public class DingTalkOAuth2UserService implements OAuth2UserService { + + private static final Logger log = LoggerFactory.getLogger(DingTalkOAuth2UserService.class); + + private final RestTemplate restTemplate; + private final IdentityBindingService identityBindingService; + private final DingTalkClaimsExtractor claimsExtractor; + + public DingTalkOAuth2UserService(IdentityBindingService identityBindingService, + DingTalkClaimsExtractor claimsExtractor) { + this.restTemplate = new RestTemplate(); + this.identityBindingService = identityBindingService; + this.claimsExtractor = claimsExtractor; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) { + String accessToken = userRequest.getAccessToken().getTokenValue(); + + // Fetch user info using DingTalk's custom header + HttpHeaders headers = new HttpHeaders(); + headers.set("x-acs-dingtalk-access-token", accessToken); + HttpEntity requestEntity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + "https://api.dingtalk.com/v1.0/contact/users/me", + HttpMethod.GET, + requestEntity, + Map.class + ); + + Map attributes = response.getBody() != null ? response.getBody() : Map.of(); + + // Map DingTalk response to standard attributes + Map userAttributes = new HashMap<>(attributes); + userAttributes.putIfAbsent("openId", attributes.get("openId")); + userAttributes.putIfAbsent("nickName", attributes.get("nick")); + userAttributes.putIfAbsent("avatarUrl", attributes.get("avatarUrl")); + + // Extract claims and create PlatformPrincipal + OAuthClaims claims = claimsExtractor.extract(userRequest, new DefaultOAuth2User( + java.util.Collections.emptyList(), userAttributes, "openId")); + + log.info("DingTalk OAuth2 login: subject={}, providerLogin={}", claims.subject(), claims.providerLogin()); + + // Bind or create user account + PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE); + + // Put platformPrincipal in attributes for OAuth2LoginSuccessHandler + userAttributes.put("platformPrincipal", principal); + + return new DefaultOAuth2User( + java.util.Collections.emptyList(), + userAttributes, + "openId" + ); + } +} \ No newline at end of file diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java new file mode 100644 index 000000000..0fe402a19 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java @@ -0,0 +1,91 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Collections; +import java.util.Map; + +/** + * Custom token response client for DingTalk (钉钉). + * + *

DingTalk requires a JSON body for token exchange instead of the standard + * form-urlencoded format. This client adapts the request accordingly. + * + *

Request body format: + *

{ "clientId": "...", "clientSecret": "...", "code": "...", "grantType": "authorization_code" }
+ */ +@Component +public class DingTalkTokenResponseClient implements OAuth2AccessTokenResponseClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final RestTemplate restTemplate = new RestTemplate(); + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) + throws OAuth2AuthenticationException { + String tokenUri = authorizationCodeGrantRequest.getClientRegistration().getProviderDetails().getTokenUri(); + String clientId = authorizationCodeGrantRequest.getClientRegistration().getClientId(); + String clientSecret = authorizationCodeGrantRequest.getClientRegistration().getClientSecret(); + String code = authorizationCodeGrantRequest.getAuthorizationExchange() + .getAuthorizationResponse() + .getCode(); + + Map tokenRequest = Map.of( + "clientId", clientId, + "clientSecret", clientSecret, + "code", code, + "grantType", "authorization_code" + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response; + try { + response = restTemplate.postForEntity(tokenUri, new HttpEntity<>(tokenRequest, headers), String.class); + } catch (Exception e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_exchange_io_error", + "Failed to exchange code for DingTalk access token: " + e.getMessage(), null), e); + } + + 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()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_response_missing_field", + "DingTalk token response missing accessToken", null)); + } + return OAuth2AccessTokenResponse.withToken(accessToken) + .tokenType(OAuth2AccessToken.TokenType.BEARER) + .additionalParameters(Collections.singletonMap("raw_response", response.getBody())) + .build(); + } catch (OAuth2AuthenticationException e) { + throw e; + } catch (Exception e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_parse_error", + "Failed to parse DingTalk token response", null), e); + } + } + + throw new OAuth2AuthenticationException( + new OAuth2Error("token_exchange_failed", + "DingTalk token exchange failed: HTTP " + response.getStatusCode(), null)); + } +} \ No newline at end of file diff --git a/web/public/dingtalk-logo.svg b/web/public/dingtalk-logo.svg new file mode 100644 index 000000000..e71bfbbc3 --- /dev/null +++ b/web/public/dingtalk-logo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file From 6cff5fb921ff63caaa948b170f25d6e21ea83c77 Mon Sep 17 00:00:00 2001 From: konglong87 <38234954+konglong87@users.noreply.github.com> Date: Thu, 28 May 2026 15:01:58 +0800 Subject: [PATCH 2/8] fix(auth): route DingTalk login through OAuthLoginFlowService for access policy DingTalkOAuth2UserService was directly calling IdentityBindingService.bindOrCreate(), bypassing access policy evaluation. Refactored to delegate to OAuthLoginFlowService.authenticate() for consistent policy + binding, matching the pattern used by CustomOAuth2UserService. Also aligned the returned DefaultOAuth2User structure (providerLogin attribute key, authorities from platformRoles) with CustomOAuth2UserService so OAuth2LoginSuccessHandler works uniformly across all providers. Signed-off-by: konglong87 <38234954+konglong87@users.noreply.github.com> --- .../auth/oauth/DingTalkOAuth2UserService.java | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java index d22f4ad4a..6280972a3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.oauth; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.Map; import org.slf4j.Logger; @@ -9,6 +10,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; @@ -16,14 +19,16 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; -import com.iflytek.skillhub.auth.identity.IdentityBindingService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.domain.user.UserStatus; /** * OAuth2UserService for DingTalk — handles DingTalk's non-standard user info * endpoint which uses a custom header {@code x-acs-dingtalk-access-token} * instead of the standard {@code Authorization: Bearer} header. + * + *

After fetching user info, this service delegates to + * {@link OAuthLoginFlowService#authenticate(OAuthClaims)} for access policy + * evaluation and identity binding, consistent with the standard OAuth2 flow. */ @Component public class DingTalkOAuth2UserService implements OAuth2UserService { @@ -31,14 +36,14 @@ public class DingTalkOAuth2UserService implements OAuth2UserService(); + principal.platformRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .forEach(authorities::add); return new DefaultOAuth2User( - java.util.Collections.emptyList(), + authorities, userAttributes, - "openId" + "providerLogin" ); } } \ No newline at end of file From e25fd7a348398528f01926d7dd924a26ce783c92 Mon Sep 17 00:00:00 2001 From: konglong87 <38234954+konglong87@users.noreply.github.com> Date: Thu, 28 May 2026 15:21:43 +0800 Subject: [PATCH 3/8] fix: update test expectation for 4 OAuth providers and add missing import Signed-off-by: konglong87 <38234954+konglong87@users.noreply.github.com> --- .../skillhub/controller/AuthControllerTest.java | 11 ++++++++--- .../iflytek/skillhub/auth/config/SecurityConfig.java | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index 8e4118c6c..d7a13d019 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -150,9 +150,14 @@ void providersShouldExposeGithubLoginEntry() throws Exception { mockMvc.perform(get("/api/v1/auth/providers")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.length()").value(1)) - .andExpect(jsonPath("$.data[*].id", hasItems("github"))) - .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems("/oauth2/authorization/github"))) + .andExpect(jsonPath("$.data.length()").value(4)) + .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab", "dingtalk"))) + .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( + "/oauth2/authorization/github", + "/oauth2/authorization/gitee", + "/oauth2/authorization/gitlab", + "/oauth2/authorization/dingtalk" + ))) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index c58a718c6..529b294b5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -24,6 +24,7 @@ import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.user.OAuth2User; From cf746827d9d8662a0c1f17d7fb1db3a8bdf1ed8d Mon Sep 17 00:00:00 2001 From: konglong87 <38234954+konglong87@users.noreply.github.com> Date: Thu, 28 May 2026 16:04:28 +0800 Subject: [PATCH 4/8] fix: make SkillStorageDeletionCompensationJpaRepository public for Spring Data JPA proxy Package-private JPA repository interfaces cannot be proxied by Spring Data's JpaRepositoryFactory when using Spring Boot devtools or certain class loader configurations, causing UnsatisfiedDependencyException at startup. Signed-off-by: konglong87 <38234954+konglong87@users.noreply.github.com> --- .../jpa/SkillStorageDeletionCompensationJpaRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillStorageDeletionCompensationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillStorageDeletionCompensationJpaRepository.java index 72411f44a..853aa4643 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillStorageDeletionCompensationJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillStorageDeletionCompensationJpaRepository.java @@ -5,7 +5,7 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -interface SkillStorageDeletionCompensationJpaRepository +public interface SkillStorageDeletionCompensationJpaRepository extends JpaRepository { List findTop100ByStatusOrderByCreatedAtAsc( From 0216cdfdee80d0d1cac7a7417d1b32ac928c7f1c Mon Sep 17 00:00:00 2001 From: konglong87 <38234954+konglong87@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:49:40 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=E9=92=89=E9=92=89OAuth2=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=8A=A0=E5=9B=BA=E4=B8=8E=E8=BA=AB=E4=BB=BD=E6=A0=87?= =?UTF-8?q?=E8=AF=86=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .env.release.example | 2 + .../src/main/resources/application.yml | 4 +- .../auth/oauth/DingTalkClaimsExtractor.java | 26 ++- .../auth/oauth/DingTalkOAuth2UserService.java | 28 ++- .../oauth/DingTalkTokenResponseClient.java | 43 ++++- .../oauth/DingTalkClaimsExtractorTest.java | 101 ++++++++++ .../oauth/DingTalkOAuth2UserServiceTest.java | 155 ++++++++++++++++ .../DingTalkTokenResponseClientTest.java | 172 ++++++++++++++++++ 8 files changed, 514 insertions(+), 17 deletions(-) create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java diff --git a/.env.release.example b/.env.release.example index 5b2fbc9d8..126c0035a 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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=钉钉 diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 5621c8c92..3f85d0e4c 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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 diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java index 51f6d27b1..1a5956f3c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java @@ -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 @@ * *

Field mapping: *

    - *
  • subject → openId
  • + *
  • subject → unionId (unique across all apps under the same developer account)
  • *
  • email → unionId@dingtalk.local (synthetic, DingTalk users may not have email)
  • *
  • emailVerified → true (synthetic)
  • *
  • providerLogin → nick
  • *
+ * + *

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 String getProvider() { public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { Map 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, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java index 6280972a3..ba929aac7 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -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.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 requestEntity = new HttpEntity<>(headers); ResponseEntity response = restTemplate.exchange( - "https://api.dingtalk.com/v1.0/contact/users/me", + userInfoUri, HttpMethod.GET, requestEntity, Map.class @@ -70,9 +90,9 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) { 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()); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java index 0fe402a19..6d4b724db 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java @@ -6,6 +6,7 @@ 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.stereotype.Component; import org.springframework.web.client.RestTemplate; +import java.time.Duration; import java.util.Collections; import java.util.Map; @@ -31,7 +33,23 @@ public class DingTalkTokenResponseClient implements OAuth2AccessTokenResponseClient { 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 OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRe 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 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; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java new file mode 100644 index 000000000..645851e11 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java @@ -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); + } +} \ No newline at end of file diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java new file mode 100644 index 000000000..40b2c44bb --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java @@ -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); + } +} \ No newline at end of file diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java new file mode 100644 index 000000000..47dbb09c5 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java @@ -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) + ); + } +} \ No newline at end of file From d89fa32c2dbc455f4d34f2d1ebd92f8d6c84ca4b Mon Sep 17 00:00:00 2001 From: konglong87 <38234954+konglong87@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:24:42 +0800 Subject: [PATCH 6/8] fix(auth): resolve DingTalk OAuth2 login failures - Change SecurityConfig to @Configuration(proxyBeanMethods=false) to avoid CGLIB proxy issues with constructor-injected beans - Add @Autowired to DingTalkOAuth2UserService public constructor so Spring resolves the correct constructor when multiple constructors exist - Change DingTalk scope from openid to corpid: DingTalk does not return id_token in its token response, so openid scope causes Spring Security to fail with invalid_id_token error. corpid scope works correctly with DingTalk's authorization endpoint - Add error logging to OAuth2LoginFailureHandler for easier debugging - Add DingTalk client-id/client-secret env vars to application-local.yml Signed-off-by: konglong87 <38234954+konglong87@users.noreply.github.com> --- .../skillhub-app/src/main/resources/application-local.yml | 6 ++++-- server/skillhub-app/src/main/resources/application.yml | 2 +- .../com/iflytek/skillhub/auth/config/SecurityConfig.java | 2 +- .../skillhub/auth/oauth/DingTalkOAuth2UserService.java | 2 ++ .../skillhub/auth/oauth/OAuth2LoginFailureHandler.java | 5 +++++ .../oauth/SkillHubOAuth2AuthorizationRequestResolver.java | 4 ++-- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index 0e390aa55..3432e67c0 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -22,6 +22,9 @@ spring: github: client-id: ${OAUTH2_GITHUB_CLIENT_ID:local-placeholder} client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:local-placeholder} + dingtalk: + client-id: ${OAUTH2_DINGTALK_CLIENT_ID:local-placeholder} + client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:local-placeholder} skillhub: auth: @@ -54,5 +57,4 @@ skillhub: logging: level: - com.iflytek.skillhub: INFO - org.springframework.security: WARN + com.iflytek.skillhub.auth: DEBUG diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 3f85d0e4c..201e751e5 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -73,7 +73,7 @@ spring: client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder} client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder} scope: - - openid + - corpid authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 529b294b5..8208b0477 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -48,7 +48,7 @@ * Central Spring Security configuration for browser sessions, API tokens, and * public versus protected endpoints. */ -@Configuration +@Configuration(proxyBeanMethods = false) @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java index ba929aac7..a3427ced9 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -18,6 +18,7 @@ import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; @@ -41,6 +42,7 @@ public class DingTalkOAuth2UserService implements OAuth2UserService Date: Tue, 2 Jun 2026 16:43:42 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E9=92=89?= =?UTF-8?q?=E9=92=89=20OAuth2=20=E9=83=A8=E7=BD=B2=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: konglong87 <38234954+konglong87@users.noreply.github.com> --- .../02-administration/deployment/configuration.md | 3 +++ .../02-administration/security/authentication.md | 14 ++++++++++++++ .../02-administration/deployment/configuration.md | 3 +++ .../02-administration/security/authentication.md | 14 ++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/document/docs/02-administration/deployment/configuration.md b/document/docs/02-administration/deployment/configuration.md index f04387bb9..53ae33daa 100644 --- a/document/docs/02-administration/deployment/configuration.md +++ b/document/docs/02-administration/deployment/configuration.md @@ -51,6 +51,9 @@ SkillHub 通过环境变量进行配置,主要配置项如下: |---------|------|--------| | `OAUTH2_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | - | | `OAUTH2_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | - | +| `OAUTH2_DINGTALK_CLIENT_ID` | 钉钉 OAuth AppKey | - | +| `OAUTH2_DINGTALK_CLIENT_SECRET` | 钉钉 OAuth AppSecret | - | +| `OAUTH2_DINGTALK_DISPLAY_NAME` | 钉钉登录按钮显示名 | `钉钉` | ### 首登管理员配置 diff --git a/document/docs/02-administration/security/authentication.md b/document/docs/02-administration/security/authentication.md index 923878eeb..0f6246b92 100644 --- a/document/docs/02-administration/security/authentication.md +++ b/document/docs/02-administration/security/authentication.md @@ -19,6 +19,20 @@ SkillHub 支持多种认证方式,满足不同企业的安全需求。 OAUTH2_GITHUB_CLIENT_SECRET=your-client-secret ``` +### 钉钉 OAuth2 + +1. 在[钉钉开放平台](https://open-dev.dingtalk.com/)创建 H5 微应用,获取 AppKey 和 AppSecret +2. 开通 `Contact.User.Read` 权限(获取用户信息) +3. 发布应用版本以激活 OAuth2 凭证 +4. 回调地址填写 `{baseUrl}/login/oauth2/code/dingtalk` +5. 配置环境变量: + ```bash + OAUTH2_DINGTALK_CLIENT_ID=你的AppKey + OAUTH2_DINGTALK_CLIENT_SECRET=你的AppSecret + ``` + +> 钉钉使用 `corpid` scope(非标准 OIDC `openid`),用户以 `unionId` 作为唯一标识。 + ### 扩展 OAuth Provider 架构支持扩展其他 OAuth Provider,如 GitLab、Gitee 等。 diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md index 68055f249..c70754f2b 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md @@ -51,6 +51,9 @@ SkillHub is configured through environment variables. The main configuration ite |---------------------|-------------|---------------| | `OAUTH2_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | - | | `OAUTH2_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | - | +| `OAUTH2_DINGTALK_CLIENT_ID` | DingTalk OAuth AppKey | - | +| `OAUTH2_DINGTALK_CLIENT_SECRET` | DingTalk OAuth AppSecret | - | +| `OAUTH2_DINGTALK_DISPLAY_NAME` | DingTalk login button display name | `钉钉` | ### Bootstrap Admin Configuration diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authentication.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authentication.md index 148252f74..94d49df22 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authentication.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authentication.md @@ -19,6 +19,20 @@ SkillHub supports multiple authentication methods to meet different enterprise s OAUTH2_GITHUB_CLIENT_SECRET=your-client-secret ``` +### DingTalk OAuth2 + +1. Create an H5 micro-app on [DingTalk Open Platform](https://open-dev.dingtalk.com/) and obtain AppKey and AppSecret +2. Enable the `Contact.User.Read` permission (required for fetching user info) +3. Publish the app version to activate OAuth2 credentials +4. Set the callback URL to `{baseUrl}/login/oauth2/code/dingtalk` +5. Configure environment variables: + ```bash + OAUTH2_DINGTALK_CLIENT_ID=your-appkey + OAUTH2_DINGTALK_CLIENT_SECRET=your-appsecret + ``` + +> DingTalk uses `corpid` scope (not standard OIDC `openid`). Users are identified by `unionId`. + ### Extend OAuth Provider The architecture supports extending to other OAuth providers like GitLab, Gitee, etc. From 0b21fe2f34c6901abf5bc017e37f1d432e66fe33 Mon Sep 17 00:00:00 2001 From: konglong87 <38234954+konglong87@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:34:14 +0800 Subject: [PATCH 8/8] fix(auth): address DingTalk OAuth2 review feedback Signed-off-by: konglong87 <38234954+konglong87@users.noreply.github.com> --- .env.release.example | 5 +- compose.release.yml | 5 +- deploy/k8s/README.md | 19 ++ deploy/k8s/base/backend-deployment.yaml | 24 ++- deploy/k8s/base/configmap.yaml | 6 + deploy/k8s/base/secret.yaml.example | 4 + docs/03-authentication-design.md | 20 +++ docs/09-deployment.md | 29 ++- docs/skillhub/en/guide/kubernetes.md | 15 ++ docs/skillhub/guide/kubernetes.md | 13 ++ .../deployment/configuration.md | 3 - .../security/authentication.md | 14 -- .../deployment/configuration.md | 3 - .../security/authentication.md | 14 -- scripts/tests/validate-release-config-test.sh | 32 ++++ scripts/validate-release-config.sh | 17 ++ .../main/resources/application-dingtalk.yml | 22 +++ .../src/main/resources/application-local.yml | 6 +- .../src/main/resources/application.yml | 13 -- ...DingTalkOAuth2CallbackIntegrationTest.java | 169 ++++++++++++++++++ .../controller/AuthControllerTest.java | 12 +- .../skillhub/auth/config/SecurityConfig.java | 11 +- .../auth/oauth/DingTalkClaimsExtractor.java | 77 +++++--- .../auth/oauth/DingTalkOAuth2Constants.java | 16 ++ .../auth/oauth/DingTalkOAuth2UserService.java | 58 +++--- .../oauth/DingTalkTokenResponseClient.java | 40 +++-- .../auth/oauth/OAuth2LoginFailureHandler.java | 14 +- ...HubOAuth2AuthorizationRequestResolver.java | 32 +++- .../oauth/DingTalkClaimsExtractorTest.java | 61 +++++-- .../oauth/DingTalkOAuth2UserServiceTest.java | 50 +++++- .../DingTalkTokenResponseClientTest.java | 45 ++++- ...Auth2AuthorizationRequestResolverTest.java | 78 ++++++-- .../auth/oauth/OAuth2LoginHandlersTest.java | 29 +++ ...rageDeletionCompensationJpaRepository.java | 2 +- web/public/dingtalk-logo.svg | 7 +- 35 files changed, 783 insertions(+), 182 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/application-dingtalk.yml create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2CallbackIntegrationTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java diff --git a/.env.release.example b/.env.release.example index 126c0035a..86a30b612 100644 --- a/.env.release.example +++ b/.env.release.example @@ -7,6 +7,7 @@ SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web SKILLHUB_SCANNER_IMAGE=ghcr.io/iflytek/skillhub-scanner POSTGRES_IMAGE=postgres:16-alpine REDIS_IMAGE=redis:7-alpine +SPRING_PROFILES_ACTIVE=docker # Public entrypoint seen by browsers/CLI, no trailing slash. # Default to localhost so `runtime.sh up` works as a zero-config quickstart. @@ -93,9 +94,9 @@ OAUTH2_GITLAB_BASE_URI=https://gitlab.com OAUTH2_GITLAB_DISPLAY_NAME=GitLab # Optional: configure DingTalk (钉钉) OAuth2 login. +# Add dingtalk to SPRING_PROFILES_ACTIVE (for example: docker,dingtalk) to enable it. # 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. +# SkillHub uses the official minimal authorization scope "openid". OAUTH2_DINGTALK_CLIENT_ID= OAUTH2_DINGTALK_CLIENT_SECRET= OAUTH2_DINGTALK_DISPLAY_NAME=钉钉 diff --git a/compose.release.yml b/compose.release.yml index 733187688..613d2024c 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -49,7 +49,7 @@ services: ports: - "${API_PORT:-8080}:8080" environment: - SPRING_PROFILES_ACTIVE: docker + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-docker} SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-skillhub} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-skillhub} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo} @@ -96,6 +96,9 @@ services: BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin@skillhub.local} OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder} OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder} + OAUTH2_DINGTALK_CLIENT_ID: ${OAUTH2_DINGTALK_CLIENT_ID:-} + OAUTH2_DINGTALK_CLIENT_SECRET: ${OAUTH2_DINGTALK_CLIENT_SECRET:-} + OAUTH2_DINGTALK_DISPLAY_NAME: ${OAUTH2_DINGTALK_DISPLAY_NAME:-钉钉} SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-} SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25} SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-} diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index 0ed6db14f..2dfa31266 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -64,6 +64,8 @@ cp secret.yaml.example secret.yaml | bootstrap-admin-password | 管理员密码 | 是 | | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | +| oauth2-dingtalk-client-id | 钉钉 OAuth AppKey | 否 | +| oauth2-dingtalk-client-secret | 钉钉 OAuth AppSecret | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | | skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | | skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 | @@ -213,6 +215,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ | redis-connect-timeout | 未设置 | Redis 建连超时 | | redis-timeout | 未设置 | Redis 命令超时 | | redis-client-name | 未设置 | Redis 客户端名称 | +| spring-profiles-active | docker | Spring profile;启用钉钉时改为 `docker,dingtalk` | | storage-base-path | /var/lib/skillhub/storage | 技能存储路径 | | skillhub-storage-provider | local | 存储类型(local/s3) | | skill-scanner-enabled | true | 是否启用扫描器 | @@ -224,6 +227,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ | bootstrap-admin-display-name | Platform Admin | 管理员显示名称 | | bootstrap-admin-email | admin@example.com | 管理员邮箱 | | session-cookie-secure | false | HTTPS 环境设为 true | +| oauth2-dingtalk-display-name | 钉钉 | 钉钉登录入口显示名称 | ### Secret 配置项 @@ -237,10 +241,25 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ | bootstrap-admin-password | 管理员密码 | 是 | | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | +| oauth2-dingtalk-client-id | 钉钉 OAuth AppKey | 否 | +| oauth2-dingtalk-client-secret | 钉钉 OAuth AppSecret | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | | skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | | skill-scanner-llm-model | LLM 模型名称 | 否 | +### 钉钉 OAuth2 + +钉钉登录默认关闭。启用时: + +1. 将 `base/configmap.yaml` 的 `spring-profiles-active` 改为 `docker,dingtalk` +2. 在 `base/secret.yaml` 填写 `oauth2-dingtalk-client-id` 和 + `oauth2-dingtalk-client-secret` +3. 在钉钉开放平台将回调地址配置为 + `{站点公网地址}/login/oauth2/code/dingtalk` + +授权 scope 固定为 `openid`。详细契约参见 +[钉钉官方教程](https://developers.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information)。 + ### 存储配置 **本地存储(默认)** diff --git a/deploy/k8s/base/backend-deployment.yaml b/deploy/k8s/base/backend-deployment.yaml index 52ddc1858..a2d47a98e 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -23,7 +23,10 @@ spec: name: http env: - name: SPRING_PROFILES_ACTIVE - value: docker + valueFrom: + configMapKeyRef: + name: skillhub-config + key: spring-profiles-active # Database - name: SPRING_DATASOURCE_URL @@ -217,6 +220,25 @@ spec: key: oauth2-github-client-secret optional: true + # DingTalk OAuth2 (optional; requires the dingtalk Spring profile) + - name: OAUTH2_DINGTALK_CLIENT_ID + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-dingtalk-client-id + optional: true + - name: OAUTH2_DINGTALK_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-dingtalk-client-secret + optional: true + - name: OAUTH2_DINGTALK_DISPLAY_NAME + valueFrom: + configMapKeyRef: + name: skillhub-config + key: oauth2-dingtalk-display-name + volumeMounts: - name: skillhub-storage mountPath: /var/lib/skillhub/storage diff --git a/deploy/k8s/base/configmap.yaml b/deploy/k8s/base/configmap.yaml index 06dbbaa5e..227ddd45f 100644 --- a/deploy/k8s/base/configmap.yaml +++ b/deploy/k8s/base/configmap.yaml @@ -3,6 +3,9 @@ kind: ConfigMap metadata: name: skillhub-config data: + # Add dingtalk to enable DingTalk OAuth2, for example: docker,dingtalk + spring-profiles-active: docker + # Redis 配置 # 使用外部 Redis:修改为外部主机地址 # 使用内置 Redis(overlays/with-infra):保持 redis @@ -46,6 +49,9 @@ data: # Session 配置 # HTTP 环境设为 false,HTTPS 环境设为 true session-cookie-secure: "false" + + # DingTalk OAuth2 display name (credentials are stored in Secret) + oauth2-dingtalk-display-name: 钉钉 --- apiVersion: v1 kind: PersistentVolumeClaim diff --git a/deploy/k8s/base/secret.yaml.example b/deploy/k8s/base/secret.yaml.example index c2b93d45a..36892c96f 100644 --- a/deploy/k8s/base/secret.yaml.example +++ b/deploy/k8s/base/secret.yaml.example @@ -27,6 +27,10 @@ stringData: oauth2-github-client-id: "" oauth2-github-client-secret: "" + # DingTalk OAuth(可选;同时在 ConfigMap 中启用 dingtalk profile) + oauth2-dingtalk-client-id: "" + oauth2-dingtalk-client-secret: "" + # LLM 配置(可选,用于技能扫描) skill-scanner-llm-api-key: "" skill-scanner-llm-base-url: "" diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 2f4b77052..dda25b9f8 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -283,6 +283,26 @@ Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 2. `CustomOAuth2UserService` 中按 `registrationId` 分支处理用户属性映射 3. 前端登录页增加对应按钮(通过 `/api/v1/auth/providers` 自动发现) +### 3.7 钉钉 OAuth2 契约 + +钉钉接入遵循[获取用户个人信息教程](https://developers.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information)中的新版 OAuth2 契约:授权地址使用 +`https://login.dingtalk.com/oauth2/auth`,授权 scope 固定为最小可用值 +`openid`,token 与用户信息端点分别使用 `/v1.0/oauth2/userAccessToken` 和 +`/v1.0/contact/users/me`。`corpid` 不能单独作为授权 scope。 + +钉钉的 `openid` 是 OAuth2 授权参数,不表示其 token 响应是 OIDC。适配器在外发 +授权 URL 中保留 `scope=openid`,但在 Spring Security 内部将该 registration 按 +普通 OAuth2 处理,避免框架转入要求 `id_token` 的 OIDC 分支。其他真正的 OIDC +registration 仍保留 `openid` 和 nonce。 + +身份映射遵循以下约束: + +- 稳定 subject 按 `unionId -> openId -> userId` 回退 +- identity binding 始终使用 `provider=dingtalk` 与稳定 subject,不依赖邮箱 +- 用户信息端点没有返回真实邮箱时传 `null`,且 `emailVerified=false` +- 即使端点返回邮箱,也不能视为钉钉已验证邮箱,`emailVerified` 仍为 `false` +- provider 默认关闭,仅在显式启用 `dingtalk` Spring profile 并配置凭证时注册 + ## 4. 核心接口设计 ```java diff --git a/docs/09-deployment.md b/docs/09-deployment.md index bcf362894..8d3072f7d 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -48,6 +48,7 @@ |---------|------|------| | `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 | | `docker` | 容器运行时能力 | 启用容器运行时相关能力,不会自动打开首登管理员 | +| `dingtalk` | 钉钉 OAuth2 登录 | 默认关闭;必须与运行 profile 组合并配置 AppKey/AppSecret | 单机交付环境使用 `SPRING_PROFILES_ACTIVE=docker`,原因如下: @@ -249,7 +250,33 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se - 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` - 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md` -## 8 OIDC 登录配置 +## 8 外部身份源配置 + +### 8.1 钉钉 OAuth2 + +钉钉 Provider 默认不注册。启用时在 `.env.release` 中设置: + +```bash +SPRING_PROFILES_ACTIVE=docker,dingtalk +OAUTH2_DINGTALK_CLIENT_ID=your-app-key +OAUTH2_DINGTALK_CLIENT_SECRET=your-app-secret +OAUTH2_DINGTALK_DISPLAY_NAME=钉钉 +``` + +在钉钉开放平台将回调地址配置为 +`{SKILLHUB_PUBLIC_BASE_URL}/login/oauth2/code/dingtalk`,开通读取个人信息所需权限并 +发布应用。授权 scope 固定为官方新版 OAuth2 契约的 `openid`;不要改为单独的 +`corpid`。契约参见[钉钉官方教程](https://developers.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information)。 + +Compose 会将 profile 与三个 `OAUTH2_DINGTALK_*` 变量传给 Server 容器。 +`make validate-release-config` 会拒绝“启用 profile 但缺少凭证”和“配置凭证但未启用 +profile”两类不完整配置。 + +Kubernetes 部署需要将 ConfigMap 的 `spring-profiles-active` 改为 +`docker,dingtalk`,并在 Secret 中填写 `oauth2-dingtalk-client-id` 与 +`oauth2-dingtalk-client-secret`。Deployment 已将这些配置映射到相同的运行时环境变量。 + +### 8.2 OIDC 登录 SkillHub 复用 Spring Security OAuth2 Client 的 OIDC 支持。前端不需要单独 配置回调页;登录页会从 `/api/v1/auth/methods` 读取后端暴露的 diff --git a/docs/skillhub/en/guide/kubernetes.md b/docs/skillhub/en/guide/kubernetes.md index bcb2c1d48..67ea0d5fc 100644 --- a/docs/skillhub/en/guide/kubernetes.md +++ b/docs/skillhub/en/guide/kubernetes.md @@ -62,6 +62,8 @@ cp secret.yaml.example secret.yaml | bootstrap-admin-password | Admin password | Yes | | oauth2-github-client-id | GitHub OAuth ID | No | | oauth2-github-client-secret | GitHub OAuth secret | No | +| oauth2-dingtalk-client-id | DingTalk OAuth AppKey | No | +| oauth2-dingtalk-client-secret | DingTalk OAuth AppSecret | No | | skill-scanner-llm-api-key | LLM API key | No | | skill-scanner-llm-base-url | Local/custom LLM service base URL | No | | skill-scanner-llm-model | LLM model name used by the scanner | No | @@ -171,6 +173,7 @@ kubectl apply -k overlays/with-infra/ # or overlays/external/ |---|---|---| | redis-host | redis | Redis host address | | redis-port | 6379 | Redis port | +| spring-profiles-active | docker | Set to `docker,dingtalk` to enable DingTalk login | | storage-base-path | /var/lib/skillhub/storage | Skill storage path | | skillhub-storage-provider | local | Storage type (local/s3) | | skill-scanner-enabled | true | Enable scanner | @@ -182,6 +185,18 @@ kubectl apply -k overlays/with-infra/ # or overlays/external/ | bootstrap-admin-display-name | Platform Admin | Admin display name | | bootstrap-admin-email | admin@example.com | Admin email | | session-cookie-secure | false | Set to true for HTTPS | +| oauth2-dingtalk-display-name | 钉钉 | DingTalk login display name | + +### DingTalk OAuth2 + +DingTalk login is disabled by default. Set `spring-profiles-active` in the +ConfigMap to `docker,dingtalk`, then provide `oauth2-dingtalk-client-id` and +`oauth2-dingtalk-client-secret` in the Secret. Configure the callback URL in +DingTalk Open Platform as `{public-site-url}/login/oauth2/code/dingtalk`. The +authorization scope is fixed to `openid`. + +See the [official DingTalk tutorial](https://developers.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information) +for the authorization contract. ### Storage Configuration diff --git a/docs/skillhub/guide/kubernetes.md b/docs/skillhub/guide/kubernetes.md index 2135b9a3b..bde88025e 100644 --- a/docs/skillhub/guide/kubernetes.md +++ b/docs/skillhub/guide/kubernetes.md @@ -62,6 +62,8 @@ cp secret.yaml.example secret.yaml | bootstrap-admin-password | 管理员密码 | 是 | | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | +| oauth2-dingtalk-client-id | 钉钉 OAuth AppKey | 否 | +| oauth2-dingtalk-client-secret | 钉钉 OAuth AppSecret | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | | skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | | skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 | @@ -171,6 +173,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ |---|---|---| | redis-host | redis | Redis 主机地址 | | redis-port | 6379 | Redis 端口 | +| spring-profiles-active | docker | 启用钉钉登录时改为 `docker,dingtalk` | | storage-base-path | /var/lib/skillhub/storage | 技能存储路径 | | skillhub-storage-provider | local | 存储类型(local/s3) | | skill-scanner-enabled | true | 是否启用扫描器 | @@ -182,6 +185,16 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ | bootstrap-admin-display-name | Platform Admin | 管理员显示名称 | | bootstrap-admin-email | admin@example.com | 管理员邮箱 | | session-cookie-secure | false | HTTPS 环境设为 true | +| oauth2-dingtalk-display-name | 钉钉 | 钉钉登录入口显示名称 | + +### 钉钉 OAuth2 + +钉钉登录默认关闭。将 ConfigMap 的 `spring-profiles-active` 改为 +`docker,dingtalk`,并在 Secret 中填写 `oauth2-dingtalk-client-id` 与 +`oauth2-dingtalk-client-secret` 后才会注册登录入口。钉钉开放平台的回调地址应为 +`{站点公网地址}/login/oauth2/code/dingtalk`,授权 scope 固定为 `openid`。 + +完整授权契约参见[钉钉官方教程](https://developers.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information)。 ### 存储配置 diff --git a/document/docs/02-administration/deployment/configuration.md b/document/docs/02-administration/deployment/configuration.md index 53ae33daa..f04387bb9 100644 --- a/document/docs/02-administration/deployment/configuration.md +++ b/document/docs/02-administration/deployment/configuration.md @@ -51,9 +51,6 @@ SkillHub 通过环境变量进行配置,主要配置项如下: |---------|------|--------| | `OAUTH2_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | - | | `OAUTH2_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | - | -| `OAUTH2_DINGTALK_CLIENT_ID` | 钉钉 OAuth AppKey | - | -| `OAUTH2_DINGTALK_CLIENT_SECRET` | 钉钉 OAuth AppSecret | - | -| `OAUTH2_DINGTALK_DISPLAY_NAME` | 钉钉登录按钮显示名 | `钉钉` | ### 首登管理员配置 diff --git a/document/docs/02-administration/security/authentication.md b/document/docs/02-administration/security/authentication.md index 0f6246b92..923878eeb 100644 --- a/document/docs/02-administration/security/authentication.md +++ b/document/docs/02-administration/security/authentication.md @@ -19,20 +19,6 @@ SkillHub 支持多种认证方式,满足不同企业的安全需求。 OAUTH2_GITHUB_CLIENT_SECRET=your-client-secret ``` -### 钉钉 OAuth2 - -1. 在[钉钉开放平台](https://open-dev.dingtalk.com/)创建 H5 微应用,获取 AppKey 和 AppSecret -2. 开通 `Contact.User.Read` 权限(获取用户信息) -3. 发布应用版本以激活 OAuth2 凭证 -4. 回调地址填写 `{baseUrl}/login/oauth2/code/dingtalk` -5. 配置环境变量: - ```bash - OAUTH2_DINGTALK_CLIENT_ID=你的AppKey - OAUTH2_DINGTALK_CLIENT_SECRET=你的AppSecret - ``` - -> 钉钉使用 `corpid` scope(非标准 OIDC `openid`),用户以 `unionId` 作为唯一标识。 - ### 扩展 OAuth Provider 架构支持扩展其他 OAuth Provider,如 GitLab、Gitee 等。 diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md index c70754f2b..68055f249 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md @@ -51,9 +51,6 @@ SkillHub is configured through environment variables. The main configuration ite |---------------------|-------------|---------------| | `OAUTH2_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | - | | `OAUTH2_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | - | -| `OAUTH2_DINGTALK_CLIENT_ID` | DingTalk OAuth AppKey | - | -| `OAUTH2_DINGTALK_CLIENT_SECRET` | DingTalk OAuth AppSecret | - | -| `OAUTH2_DINGTALK_DISPLAY_NAME` | DingTalk login button display name | `钉钉` | ### Bootstrap Admin Configuration diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authentication.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authentication.md index 94d49df22..148252f74 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authentication.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authentication.md @@ -19,20 +19,6 @@ SkillHub supports multiple authentication methods to meet different enterprise s OAUTH2_GITHUB_CLIENT_SECRET=your-client-secret ``` -### DingTalk OAuth2 - -1. Create an H5 micro-app on [DingTalk Open Platform](https://open-dev.dingtalk.com/) and obtain AppKey and AppSecret -2. Enable the `Contact.User.Read` permission (required for fetching user info) -3. Publish the app version to activate OAuth2 credentials -4. Set the callback URL to `{baseUrl}/login/oauth2/code/dingtalk` -5. Configure environment variables: - ```bash - OAUTH2_DINGTALK_CLIENT_ID=your-appkey - OAUTH2_DINGTALK_CLIENT_SECRET=your-appsecret - ``` - -> DingTalk uses `corpid` scope (not standard OIDC `openid`). Users are identified by `unionId`. - ### Extend OAuth Provider The architecture supports extending to other OAuth providers like GitLab, Gitee, etc. diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index bee780fbc..491ee6843 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -150,6 +150,31 @@ write_env "$invalid_redis_sentinel_check_env" "release-download-secret-32-bytes- printf '%s\n' "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=yes" >>"$invalid_redis_sentinel_check_env" expect_fail "$invalid_redis_sentinel_check_env" "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST must be true or false" +dingtalk_env="$tmp/dingtalk.env" +write_env "$dingtalk_env" "release-download-secret-32-bytes-minimum" +cat >>"$dingtalk_env" </dev/null + +dingtalk_missing_secret_env="$tmp/dingtalk-missing-secret.env" +write_env "$dingtalk_missing_secret_env" "release-download-secret-32-bytes-minimum" +cat >>"$dingtalk_missing_secret_env" <>"$dingtalk_disabled_env" <"$draft_env" expect_fail "$draft_env" "POSTGRES_PASSWORD" +grep -Fq 'OAUTH2_DINGTALK_CLIENT_ID: ${OAUTH2_DINGTALK_CLIENT_ID:-}' "$REPO_ROOT/compose.release.yml" \ + || fail "compose.release.yml does not pass OAUTH2_DINGTALK_CLIENT_ID" +grep -Fq 'key: oauth2-dingtalk-client-secret' "$REPO_ROOT/deploy/k8s/base/backend-deployment.yaml" \ + || fail "Kubernetes deployment does not pass the DingTalk client secret" +grep -Fq 'spring-profiles-active: docker' "$REPO_ROOT/deploy/k8s/base/configmap.yaml" \ + || fail "Kubernetes config does not expose Spring profile activation" + echo "validate-release-config-test passed" diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 7c7940a39..50b5aa48f 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -290,6 +290,23 @@ if [ -n "$oauth_secret" ] && [ -z "$oauth_id" ]; then error "OAUTH2_GITHUB_CLIENT_ID is required when OAUTH2_GITHUB_CLIENT_SECRET is set" fi +dingtalk_profiles=",${SPRING_PROFILES_ACTIVE:-docker}," +dingtalk_id="${OAUTH2_DINGTALK_CLIENT_ID:-}" +dingtalk_secret="${OAUTH2_DINGTALK_CLIENT_SECRET:-}" +case "$dingtalk_profiles" in + *,dingtalk,*) + require_non_empty OAUTH2_DINGTALK_CLIENT_ID + require_non_empty OAUTH2_DINGTALK_CLIENT_SECRET + reject_values OAUTH2_DINGTALK_CLIENT_ID "placeholder" "local-placeholder" + reject_values OAUTH2_DINGTALK_CLIENT_SECRET "placeholder" "local-placeholder" + ;; + *) + if [ -n "$dingtalk_id" ] || [ -n "$dingtalk_secret" ]; then + error "SPRING_PROFILES_ACTIVE must include dingtalk when DingTalk OAuth2 credentials are set" + fi + ;; +esac + if [ "$errors" -gt 0 ]; then echo "Release config validation failed: $errors error(s), $warnings warning(s)." >&2 exit 1 diff --git a/server/skillhub-app/src/main/resources/application-dingtalk.yml b/server/skillhub-app/src/main/resources/application-dingtalk.yml new file mode 100644 index 000000000..c30ee773a --- /dev/null +++ b/server/skillhub-app/src/main/resources/application-dingtalk.yml @@ -0,0 +1,22 @@ +spring: + config: + activate: + on-profile: dingtalk + security: + oauth2: + client: + registration: + dingtalk: + client-id: ${OAUTH2_DINGTALK_CLIENT_ID} + client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET} + scope: + - openid + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉} + provider: + dingtalk: + 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: unionId diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index 3432e67c0..0e390aa55 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -22,9 +22,6 @@ spring: github: client-id: ${OAUTH2_GITHUB_CLIENT_ID:local-placeholder} client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:local-placeholder} - dingtalk: - client-id: ${OAUTH2_DINGTALK_CLIENT_ID:local-placeholder} - client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:local-placeholder} skillhub: auth: @@ -57,4 +54,5 @@ skillhub: logging: level: - com.iflytek.skillhub.auth: DEBUG + com.iflytek.skillhub: INFO + org.springframework.security: WARN diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 201e751e5..421e27f68 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -69,14 +69,6 @@ spring: authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab} - dingtalk: - client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder} - client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder} - scope: - - corpid - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉} provider: github: user-info-uri: https://api.github.com/user @@ -85,11 +77,6 @@ spring: token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user user-name-attribute: username - dingtalk: - 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: unionId servlet: multipart: max-file-size: 100MB diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2CallbackIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2CallbackIntegrationTest.java new file mode 100644 index 000000000..a245a3466 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2CallbackIntegrationTest.java @@ -0,0 +1,169 @@ +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.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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"test", "dingtalk"}) +@TestPropertySource(properties = { + "OAUTH2_DINGTALK_CLIENT_ID=test-dingtalk-client", + "OAUTH2_DINGTALK_CLIENT_SECRET=test-dingtalk-secret", + "spring.security.oauth2.client.registration.oidc.client-id=test-oidc-client", + "spring.security.oauth2.client.registration.oidc.client-secret=test-oidc-secret", + "spring.security.oauth2.client.registration.oidc.provider=oidc", + "spring.security.oauth2.client.registration.oidc.authorization-grant-type=authorization_code", + "spring.security.oauth2.client.registration.oidc.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}", + "spring.security.oauth2.client.registration.oidc.scope=openid,profile,email", + "spring.security.oauth2.client.provider.oidc.authorization-uri=https://idp.example.test/oauth2/authorize", + "spring.security.oauth2.client.provider.oidc.token-uri=https://idp.example.test/oauth2/token", + "spring.security.oauth2.client.provider.oidc.jwk-set-uri=https://idp.example.test/oauth2/jwks", + "spring.security.oauth2.client.provider.oidc.user-info-uri=https://idp.example.test/userinfo", + "spring.security.oauth2.client.provider.oidc.user-name-attribute=sub" +}) +class DingTalkOAuth2CallbackIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ClientRegistrationRepository clientRegistrationRepository; + + @Autowired + private DingTalkTokenResponseClient tokenResponseClient; + + @Autowired + private DingTalkOAuth2UserService userService; + + @MockBean + private OAuthLoginFlowService oauthLoginFlowService; + + private MockRestServiceServer tokenServer; + private MockRestServiceServer userInfoServer; + + @BeforeEach + void setUp() { + RestTemplate tokenRestTemplate = (RestTemplate) ReflectionTestUtils.getField( + tokenResponseClient, "restTemplate"); + RestTemplate userInfoRestTemplate = (RestTemplate) ReflectionTestUtils.getField( + userService, "restTemplate"); + assertThat(tokenRestTemplate).isNotNull(); + assertThat(userInfoRestTemplate).isNotNull(); + tokenServer = MockRestServiceServer.bindTo(tokenRestTemplate).build(); + userInfoServer = MockRestServiceServer.bindTo(userInfoRestTemplate).build(); + } + + @Test + void dingtalkProfileExposesProviderAndCompletesOAuth2Callback() throws Exception { + assertThat(clientRegistrationRepository.findByRegistrationId("dingtalk")).isNotNull(); + mockMvc.perform(get("/api/v1/auth/providers")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.id=='dingtalk')]").isNotEmpty()); + + MvcResult authorizationResult = mockMvc.perform(get("/oauth2/authorization/dingtalk")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", org.hamcrest.Matchers.containsString("scope=openid"))) + .andReturn(); + + String authorizationLocation = authorizationResult.getResponse().getRedirectedUrl(); + assertThat(authorizationLocation).isNotNull(); + String encodedState = UriComponentsBuilder.fromUri(URI.create(authorizationLocation)) + .build() + .getQueryParams() + .getFirst("state"); + String state = UriUtils.decode(encodedState, StandardCharsets.UTF_8); + assertThat(state).isNotBlank(); + MockHttpSession session = (MockHttpSession) authorizationResult.getRequest().getSession(false); + assertThat(session).isNotNull(); + + tokenServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess( + """ + {"accessToken":"dingtalk-access-token","expireIn":7200} + """, + MediaType.APPLICATION_JSON)); + userInfoServer.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER, "dingtalk-access-token")) + .andRespond(withSuccess( + """ + {"openId":"stable-open-id","nick":"DingTalk User"} + """, + MediaType.APPLICATION_JSON)); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-dingtalk", "DingTalk User", null, null, "dingtalk", Set.of("USER")); + when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal); + + mockMvc.perform(get("/login/oauth2/code/dingtalk") + .param("code", "authorization-code") + .param("state", state) + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", "/dashboard")); + + assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal); + tokenServer.verify(); + userInfoServer.verify(); + } + + @Test + void standardOAuth2AndOidcAuthorizationRoutesRemainIntact() throws Exception { + assertAuthorizationRedirectScopes("github", Set.of("read:user", "user:email"), false); + assertAuthorizationRedirectScopes("gitlab", Set.of("read_user", "email"), false); + assertAuthorizationRedirectScopes("oidc", Set.of("openid", "profile", "email"), true); + } + + private void assertAuthorizationRedirectScopes( + String registrationId, + Set expectedScopes, + boolean expectsNonce) throws Exception { + MvcResult result = mockMvc.perform(get("/oauth2/authorization/{registrationId}", registrationId)) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String location = result.getResponse().getRedirectedUrl(); + assertThat(location).isNotNull(); + var query = UriComponentsBuilder.fromUri(URI.create(location)).build().getQueryParams(); + String encodedScope = query.getFirst("scope"); + assertThat(encodedScope).isNotNull(); + assertThat(Arrays.asList(UriUtils.decode(encodedScope, StandardCharsets.UTF_8).split(" "))) + .containsExactlyInAnyOrderElementsOf(expectedScopes); + assertThat(query.containsKey("nonce")).isEqualTo(expectsNonce); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index d7a13d019..6f454af69 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -150,14 +150,10 @@ void providersShouldExposeGithubLoginEntry() throws Exception { mockMvc.perform(get("/api/v1/auth/providers")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.length()").value(4)) - .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab", "dingtalk"))) - .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( - "/oauth2/authorization/github", - "/oauth2/authorization/gitee", - "/oauth2/authorization/gitlab", - "/oauth2/authorization/dingtalk" - ))) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[*].id", hasItems("github"))) + .andExpect(jsonPath("$.data[?(@.id=='dingtalk')]").isEmpty()) + .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems("/oauth2/authorization/github"))) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 8208b0477..2ae7fe6c5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -2,6 +2,7 @@ import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; import com.iflytek.skillhub.auth.oauth.CustomOidcUserService; +import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2Constants; import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2UserService; import com.iflytek.skillhub.auth.oauth.DingTalkTokenResponseClient; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; @@ -48,7 +49,7 @@ * Central Spring Security configuration for browser sessions, API tokens, and * public versus protected endpoints. */ -@Configuration(proxyBeanMethods = false) +@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @@ -203,7 +204,7 @@ private void configureRoutePolicies(AuthorizeHttpRequestsConfigurerField mapping: *

    - *
  • subject → unionId (unique across all apps under the same developer account)
  • - *
  • email → unionId@dingtalk.local (synthetic, DingTalk users may not have email)
  • - *
  • emailVerified → true (synthetic)
  • + *
  • subject → unionId, falling back to openId and userId
  • + *
  • email → optional real email returned by DingTalk
  • + *
  • emailVerified → false because this endpoint does not attest email ownership
  • *
  • providerLogin → nick
  • *
* - *

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. + *

unionId is preferred because it is stable across apps under the same developer. + * The fallbacks preserve login availability when DingTalk omits that optional field. */ @Component public class DingTalkClaimsExtractor implements OAuthClaimsExtractor { @Override public String getProvider() { - return "dingtalk"; + return DingTalkOAuth2Constants.REGISTRATION_ID; } @Override public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { Map attrs = oAuth2User.getAttributes(); - String unionId = (String) attrs.get("unionId"); - String openId = (String) attrs.get("openId"); - String nick = (String) attrs.get("nick"); + String subject = resolveSubject(attrs); - // 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)); + String email = stringValue(attrs.get("email")); + String providerLogin = firstNonBlank(attrs, "nick", "name"); + if (providerLogin == null) { + providerLogin = subject; } - // DingTalk users may not have email; synthesize one for downstream compatibility - String syntheticEmail = unionId + "@dingtalk.local"; - return new OAuthClaims( - "dingtalk", - unionId, // Use unionId (cross-app unique) instead of openId (single-app only) - syntheticEmail, - true, - nick, + DingTalkOAuth2Constants.REGISTRATION_ID, + subject, + email, + false, + providerLogin, attrs ); } -} \ No newline at end of file + + String resolveSubject(Map attributes) { + String subject = firstNonBlank( + attributes, + DingTalkOAuth2Constants.SUBJECT_CLAIM_NAMES.toArray(String[]::new)); + if (subject == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error("missing_subject", + "DingTalk response is missing unionId, openId, and userId", null)); + } + return subject; + } + + private static String firstNonBlank(Map attributes, String... keys) { + for (String key : keys) { + String value = stringValue(attributes.get(key)); + if (value != null) { + return value; + } + } + return null; + } + + private static String stringValue(Object value) { + if (value == null) { + return null; + } + String stringValue = String.valueOf(value).trim(); + return stringValue.isEmpty() ? null : stringValue; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java new file mode 100644 index 000000000..1f8b7241a --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.List; + +/** Shared protocol constants for the DingTalk OAuth2 adapter. */ +public final class DingTalkOAuth2Constants { + + public static final String REGISTRATION_ID = "dingtalk"; + public static final String AUTHORIZATION_SCOPE = "openid"; + public static final String ACCESS_TOKEN_HEADER = "x-acs-dingtalk-access-token"; + public static final String SUBJECT_ATTRIBUTE = "dingtalkSubject"; + static final List SUBJECT_CLAIM_NAMES = List.of("unionId", "openId", "userId"); + + private DingTalkOAuth2Constants() { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java index a3427ced9..81dc4abcf 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -1,12 +1,12 @@ package com.iflytek.skillhub.auth.oauth; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import java.time.Duration; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.Map; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -16,14 +16,15 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; import org.springframework.web.client.RestTemplate; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; - /** * OAuth2UserService for DingTalk — handles DingTalk's non-standard user info * endpoint which uses a custom header {@code x-acs-dingtalk-access-token} @@ -36,8 +37,6 @@ @Component public class DingTalkOAuth2UserService implements OAuth2UserService { - private static final Logger log = LoggerFactory.getLogger(DingTalkOAuth2UserService.class); - private final RestTemplate restTemplate; private final DingTalkClaimsExtractor claimsExtractor; private final OAuthLoginFlowService oauthLoginFlowService; @@ -74,29 +73,40 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) { // Fetch user info using DingTalk's custom header HttpHeaders headers = new HttpHeaders(); - headers.set("x-acs-dingtalk-access-token", accessToken); + headers.set(DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER, accessToken); HttpEntity requestEntity = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange( - userInfoUri, - HttpMethod.GET, - requestEntity, - Map.class - ); + ResponseEntity> response; + try { + response = restTemplate.exchange( + userInfoUri, + HttpMethod.GET, + requestEntity, + new ParameterizedTypeReference<>() { + } + ); + } catch (RestClientResponseException e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("user_info_request_failed", + "DingTalk user-info request failed with HTTP " + e.getStatusCode().value(), null)); + } catch (RestClientException e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("user_info_request_failed", + "DingTalk user-info request failed", null)); + } Map attributes = response.getBody() != null ? response.getBody() : Map.of(); - // Map DingTalk response to standard attributes Map userAttributes = new HashMap<>(attributes); - userAttributes.putIfAbsent("openId", attributes.get("openId")); - userAttributes.putIfAbsent("nickName", attributes.get("nick")); - userAttributes.putIfAbsent("avatarUrl", attributes.get("avatarUrl")); + if (attributes.get("avatarUrl") != null) { + userAttributes.putIfAbsent("avatar_url", attributes.get("avatarUrl")); + } - // Extract claims — use unionId as the name attribute (cross-app unique identity) - OAuthClaims claims = claimsExtractor.extract(userRequest, new DefaultOAuth2User( - java.util.Collections.emptyList(), userAttributes, "unionId")); + String subject = claimsExtractor.resolveSubject(userAttributes); + userAttributes.put(DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE, subject); - log.info("DingTalk OAuth2 login: subject={}, providerLogin={}", claims.subject(), claims.providerLogin()); + OAuthClaims claims = claimsExtractor.extract(userRequest, new DefaultOAuth2User( + java.util.Collections.emptyList(), userAttributes, DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)); // Delegate to OAuthLoginFlowService for access policy evaluation and identity binding PlatformPrincipal principal = oauthLoginFlowService.authenticate(claims); @@ -116,4 +126,4 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) { "providerLogin" ); } -} \ No newline at end of file +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java index 6d4b724db..08237f2d1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.Map; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -14,12 +16,10 @@ import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; import org.springframework.web.client.RestTemplate; -import java.time.Duration; -import java.util.Collections; -import java.util.Map; - /** * Custom token response client for DingTalk (钉钉). * @@ -74,10 +74,14 @@ public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRe ResponseEntity response; try { response = restTemplate.postForEntity(tokenUri, new HttpEntity<>(tokenRequest, headers), String.class); - } catch (Exception e) { + } catch (RestClientResponseException e) { throw new OAuth2AuthenticationException( new OAuth2Error("token_exchange_io_error", - "Failed to exchange code for DingTalk access token: " + e.getMessage(), null), e); + "DingTalk token exchange failed with HTTP " + e.getStatusCode().value(), null)); + } catch (RestClientException e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_exchange_io_error", + "DingTalk token exchange request failed", null)); } if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { @@ -91,21 +95,31 @@ public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRe "DingTalk token response missing accessToken field", null)); } String accessToken = accessTokenNode.asText(); - if (accessToken.isEmpty()) { + if (accessToken.isBlank()) { 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 safeParams = new java.util.LinkedHashMap<>(); JsonNode expireInNode = json.get("expireIn"); - if (expireInNode != null && !expireInNode.isNull()) { - safeParams.put("expireIn", expireInNode.asLong()); + if (expireInNode == null || !expireInNode.isIntegralNumber() || !expireInNode.canConvertToLong()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_response_invalid_expiry", + "DingTalk token response has invalid expireIn field", null)); } + long expireInSeconds = expireInNode.longValue(); + if (expireInSeconds <= 0) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_response_invalid_expiry", + "DingTalk token response has non-positive expireIn field", null)); + } + + // Only include non-sensitive fields in additional parameters. + Map safeParams = Map.of("expireIn", expireInSeconds); return OAuth2AccessTokenResponse.withToken(accessToken) .tokenType(OAuth2AccessToken.TokenType.BEARER) + .expiresIn(expireInSeconds) .additionalParameters(safeParams) .build(); } catch (OAuth2AuthenticationException e) { @@ -113,7 +127,7 @@ public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRe } catch (Exception e) { throw new OAuth2AuthenticationException( new OAuth2Error("token_parse_error", - "Failed to parse DingTalk token response", null), e); + "Failed to parse DingTalk token response", null)); } } @@ -121,4 +135,4 @@ public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRe new OAuth2Error("token_exchange_failed", "DingTalk token exchange failed: HTTP " + response.getStatusCode(), null)); } -} \ No newline at end of file +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java index e228cd0a2..659b4d5ba 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java @@ -3,14 +3,14 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; -import java.io.IOException; - /** * Failure handler for OAuth logins that normalizes policy and account-state * failures into predictable user-facing redirects. @@ -30,7 +30,15 @@ public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) { public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { - log.error("OAuth2 login failed: type={}, message={}", exception.getClass().getSimpleName(), exception.getMessage(), exception); + String errorCode = exception instanceof OAuth2AuthenticationException oauth2Exception + ? oauth2Exception.getError().getErrorCode() + : "unknown"; + log.error( + "OAuth2 login failed: path={}, type={}, errorCode={}", + request.getRequestURI(), + exception.getClass().getSimpleName(), + errorCode); + String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false)); String redirectTarget = oauthLoginFlowService.resolveFailureRedirect(exception, returnTo); if (redirectTarget != null) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java index b925c0409..60e82cded 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java @@ -1,10 +1,15 @@ package com.iflytek.skillhub.auth.oauth; import jakarta.servlet.http.HttpServletRequest; +import java.util.LinkedHashSet; +import java.util.Set; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; /** * OAuth2 authorization request resolver that preserves a sanitized post-login @@ -30,13 +35,36 @@ public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository c public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request); oauthLoginFlowService.rememberReturnTo(request); - return authorizationRequest; + return adaptDingTalkRequest(authorizationRequest); } @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId); oauthLoginFlowService.rememberReturnTo(request); - return authorizationRequest; + return adaptDingTalkRequest(authorizationRequest); + } + + private static OAuth2AuthorizationRequest adaptDingTalkRequest( + OAuth2AuthorizationRequest authorizationRequest) { + if (authorizationRequest == null + || !DingTalkOAuth2Constants.REGISTRATION_ID.equals( + authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID))) { + return authorizationRequest; + } + + Set oauth2Scopes = new LinkedHashSet<>(authorizationRequest.getScopes()); + oauth2Scopes.remove(DingTalkOAuth2Constants.AUTHORIZATION_SCOPE); + String authorizationRequestUri = UriComponentsBuilder + .fromUriString(authorizationRequest.getAuthorizationRequestUri()) + .replaceQueryParam(OidcParameterNames.NONCE) + .build(true) + .toUriString(); + return OAuth2AuthorizationRequest.from(authorizationRequest) + .scopes(oauth2Scopes) + .additionalParameters(parameters -> parameters.remove(OidcParameterNames.NONCE)) + .attributes(attributes -> attributes.remove(OidcParameterNames.NONCE)) + .authorizationRequestUri(authorizationRequestUri) + .build(); } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java index 645851e11..9d573f237 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java @@ -34,14 +34,14 @@ void extract_usesUnionIdAsSubject() { assertThat(claims.provider()).isEqualTo("dingtalk"); assertThat(claims.subject()).isEqualTo("union123"); - assertThat(claims.email()).isEqualTo("union123@dingtalk.local"); - assertThat(claims.emailVerified()).isTrue(); + assertThat(claims.email()).isNull(); + assertThat(claims.emailVerified()).isFalse(); assertThat(claims.providerLogin()).isEqualTo("测试用户"); } @Test - void extract_throwsWhenUnionIdIsMissing() { - assertThatThrownBy(() -> extractor.extract( + void extract_fallsBackToOpenId() { + OAuthClaims claims = extractor.extract( userRequest(), new DefaultOAuth2User( java.util.List.of(), @@ -51,25 +51,60 @@ void extract_throwsWhenUnionIdIsMissing() { ), "openId" ) - )).isInstanceOf(OAuth2AuthenticationException.class) - .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("missing_union_id")); + ); + + assertThat(claims.subject()).isEqualTo("open456"); } @Test - void extract_throwsWhenUnionIdIsEmpty() { - assertThatThrownBy(() -> extractor.extract( + void extract_fallsBackToUserIdWhenHigherPriorityIdentifiersAreBlank() { + OAuthClaims claims = extractor.extract( userRequest(), new DefaultOAuth2User( java.util.List.of(), Map.of( - "unionId", "", - "openId", "open456", + "unionId", " ", + "openId", "", + "userId", "user789", "nick", "测试用户" ), - "openId" + "userId" + ) + ); + + assertThat(claims.subject()).isEqualTo("user789"); + } + + @Test + void extract_preservesRealEmailWithoutClaimingVerification() { + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "unionId", "union123", + "email", "user@example.com" + ), + "unionId" + ) + ); + + assertThat(claims.email()).isEqualTo("user@example.com"); + assertThat(claims.emailVerified()).isFalse(); + } + + @Test + void extract_throwsWhenAllStableIdentifiersAreMissing() { + assertThatThrownBy(() -> extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of("nick", "测试用户"), + "nick" ) )).isInstanceOf(OAuth2AuthenticationException.class) - .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("missing_union_id")); + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex) + .getError().getErrorCode()).isEqualTo("missing_subject")); } @Test @@ -98,4 +133,4 @@ private OAuth2UserRequest userRequest() { ); return new OAuth2UserRequest(registration, accessToken); } -} \ No newline at end of file +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java index 40b2c44bb..8b321617b 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java @@ -1,6 +1,7 @@ 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.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -8,7 +9,9 @@ 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 static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import java.time.Instant; import java.util.Map; import java.util.Set; @@ -20,10 +23,10 @@ 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.OAuth2User; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestTemplate; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; class DingTalkOAuth2UserServiceTest { @@ -62,7 +65,7 @@ void loadUser_fetchesUserInfoWithCustomHeaderAndReturnsOAuth2User() { // Mock OAuthLoginFlowService to return a principal PlatformPrincipal principal = new PlatformPrincipal( - "user-union123", "测试用户", "union123@dingtalk.local", + "user-union123", "测试用户", null, "https://example.com/avatar.jpg", "dingtalk", Set.of("USER") ); when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal); @@ -98,7 +101,7 @@ void loadUser_readsUserInfoUriFromClientRegistration() { )); PlatformPrincipal principal = new PlatformPrincipal( - "user-union789", "自定义用户", "union789@dingtalk.local", + "user-union789", "自定义用户", null, null, "dingtalk", Set.of("USER") ); when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal); @@ -109,6 +112,45 @@ void loadUser_readsUserInfoUriFromClientRegistration() { mockServer.verify(); } + @Test + void loadUser_supportsOpenIdFallbackWhenUnionIdIsMissing() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + .andRespond(withSuccess( + """ + { + "openId": "open456", + "nick": "测试用户" + } + """, + MediaType.APPLICATION_JSON + )); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-open456", "测试用户", null, null, "dingtalk", Set.of("USER") + ); + when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal); + + OAuth2User oauth2User = service.loadUser(userRequest()); + + assertThat(oauth2User.getName()).isEqualTo("user-open456"); + assertThat(oauth2User.getAttributes().get(DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)) + .isEqualTo("open456"); + } + + @Test + void loadUser_wrapsHttpFailureWithoutExposingResponseBody() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + .andRespond(withServerError().body("sensitive-upstream-response")); + + assertThatThrownBy(() -> service.loadUser(userRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> { + OAuth2AuthenticationException oauthException = (OAuth2AuthenticationException) ex; + assertThat(oauthException.getError().getErrorCode()).isEqualTo("user_info_request_failed"); + assertThat(oauthException.getMessage()).doesNotContain("sensitive-upstream-response"); + }); + } + private OAuth2UserRequest userRequest() { ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk") .clientId("dingzgzf3b9k7jv74iq2") @@ -152,4 +194,4 @@ private OAuth2UserRequest userRequestWithCustomUri(String userInfoUri) { ); return new OAuth2UserRequest(registration, accessToken); } -} \ No newline at end of file +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java index 47dbb09c5..ac15a660c 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java @@ -6,6 +6,7 @@ import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import java.time.Duration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; @@ -50,6 +51,11 @@ void getTokenResponse_returnsAccessTokenOnSuccess() { assertThat(response.getAccessToken().getTokenValue()).isEqualTo("dt_access_token_123"); assertThat(response.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER); + assertThat(response.getAccessToken().getIssuedAt()).isNotNull(); + assertThat(response.getAccessToken().getExpiresAt()).isNotNull(); + assertThat(Duration.between( + response.getAccessToken().getIssuedAt(), + response.getAccessToken().getExpiresAt())).isEqualTo(Duration.ofSeconds(7200)); assertThat(response.getAdditionalParameters().get("expireIn")).isEqualTo(7200L); // Verify raw_response is NOT included (sensitive data leak fix) assertThat(response.getAdditionalParameters().containsKey("raw_response")).isFalse(); @@ -112,14 +118,19 @@ void getTokenResponse_throwsWhenAccessTokenIsEmpty() { @Test void getTokenResponse_throwsOnHttpError() { mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) - .andRespond(withServerError()); + .andRespond(withServerError().body("sensitive-upstream-response")); assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) - .isInstanceOf(OAuth2AuthenticationException.class); + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> { + OAuth2AuthenticationException oauthException = (OAuth2AuthenticationException) ex; + assertThat(oauthException.getError().getErrorCode()).isEqualTo("token_exchange_io_error"); + assertThat(oauthException.getMessage()).doesNotContain("sensitive-upstream-response"); + }); } @Test - void getTokenResponse_doesNotIncludeExpireInWhenMissing() { + void getTokenResponse_throwsWhenExpireInIsMissing() { mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) .andRespond(withSuccess( """ @@ -130,11 +141,29 @@ void getTokenResponse_doesNotIncludeExpireInWhenMissing() { MediaType.APPLICATION_JSON )); - OAuth2AccessTokenResponse response = client.getTokenResponse(authorizationCodeGrantRequest()); + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex) + .getError().getErrorCode()).isEqualTo("token_response_invalid_expiry")); + } - assertThat(response.getAccessToken().getTokenValue()).isEqualTo("dt_access_token_123"); - assertThat(response.getAdditionalParameters().containsKey("expireIn")).isFalse(); - assertThat(response.getAdditionalParameters().containsKey("raw_response")).isFalse(); + @Test + void getTokenResponse_throwsWhenExpireInIsNonPositive() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "accessToken": "dt_access_token_123", + "expireIn": 0 + } + """, + MediaType.APPLICATION_JSON + )); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex) + .getError().getErrorCode()).isEqualTo("token_response_invalid_expiry")); } private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest() { @@ -169,4 +198,4 @@ private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest() { new OAuth2AuthorizationExchange(authRequest, authResponse) ); } -} \ No newline at end of file +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java index 357ada331..3cb7b42c4 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java @@ -8,6 +8,8 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -18,29 +20,36 @@ class OAuth2AuthorizationRequestResolverTest { @BeforeEach void setUp() { - ClientRegistration github = ClientRegistration.withRegistrationId("github") - .clientId("client") - .clientSecret("secret") - .authorizationUri("https://example.test/oauth/authorize") - .tokenUri("https://example.test/oauth/token") - .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") - .userInfoUri("https://example.test/user") - .userNameAttributeName("id") - .authorizationGrantType(org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE) - .scope("read:user") - .clientName("GitHub") - .build(); + ClientRegistration github = clientRegistration("github", "read:user"); + ClientRegistration gitlab = clientRegistration("gitlab", "read_user"); + ClientRegistration dingtalk = clientRegistration("dingtalk", "openid"); + ClientRegistration oidc = clientRegistration("oidc", "openid"); OAuthLoginFlowService oauthLoginFlowService = new OAuthLoginFlowService( java.util.List.of(), mock(AccessPolicy.class), mock(IdentityBindingService.class) ); resolver = new SkillHubOAuth2AuthorizationRequestResolver( - new InMemoryClientRegistrationRepository(github), + new InMemoryClientRegistrationRepository(github, gitlab, dingtalk, oidc), oauthLoginFlowService ); } + private static ClientRegistration clientRegistration(String registrationId, String scope) { + return ClientRegistration.withRegistrationId(registrationId) + .clientId("client") + .clientSecret("secret") + .authorizationUri("https://example.test/oauth/authorize") + .tokenUri("https://example.test/oauth/token") + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .userInfoUri("https://example.test/user") + .userNameAttributeName("id") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .scope(scope) + .clientName(registrationId) + .build(); + } + @Test void resolve_storesSanitizedReturnToInSession() { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); @@ -65,4 +74,47 @@ void resolve_ignoresUnsafeReturnTo() { assertThat(session).isNotNull(); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); } + + @Test + void resolve_sendsDingTalkOpenIdScopeWithoutTriggeringOidc() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/dingtalk"); + + OAuth2AuthorizationRequest authorizationRequest = resolver.resolve(request, "dingtalk"); + + assertThat(authorizationRequest).isNotNull(); + assertThat(authorizationRequest.getAuthorizationRequestUri()).contains("scope=openid"); + assertThat(authorizationRequest.getScopes()).doesNotContain("openid"); + assertThat(authorizationRequest.getAdditionalParameters()).doesNotContainKey("nonce"); + } + + @Test + void resolve_preservesStandardOAuth2ProviderScopes() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); + + OAuth2AuthorizationRequest authorizationRequest = resolver.resolve(request, "github"); + + assertThat(authorizationRequest).isNotNull(); + assertThat(authorizationRequest.getScopes()).containsExactly("read:user"); + } + + @Test + void resolve_preservesGitLabOAuth2Scopes() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/gitlab"); + + OAuth2AuthorizationRequest authorizationRequest = resolver.resolve(request, "gitlab"); + + assertThat(authorizationRequest).isNotNull(); + assertThat(authorizationRequest.getScopes()).containsExactly("read_user"); + } + + @Test + void resolve_preservesOpenIdForRealOidcProviders() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/oidc"); + + OAuth2AuthorizationRequest authorizationRequest = resolver.resolve(request, "oidc"); + + assertThat(authorizationRequest).isNotNull(); + assertThat(authorizationRequest.getScopes()).containsExactly("openid"); + assertThat(authorizationRequest.getAdditionalParameters()).containsKey("nonce"); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java index 52c0077bd..350277aa9 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -2,6 +2,9 @@ import jakarta.servlet.http.HttpSession; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -20,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +@ExtendWith(OutputCaptureExtension.class) class OAuth2LoginHandlersTest { @Test @@ -130,4 +134,29 @@ void failureHandler_redirectsBackToLoginWithReturnTo() throws Exception { assertThat(response.getRedirectedUrl()).isEqualTo("/login?returnTo=%2Fsettings%2Faccounts"); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); } + + @Test + void failureHandler_logsErrorCodeWithoutSensitiveExceptionDetails(CapturedOutput output) throws Exception { + OAuthLoginFlowService oauthLoginFlowService = mock(OAuthLoginFlowService.class); + OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(oauthLoginFlowService); + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", "/login/oauth2/code/dingtalk"); + MockHttpServletResponse response = new MockHttpServletResponse(); + org.mockito.Mockito.when(oauthLoginFlowService.resolveFailureRedirect( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.isNull())) + .thenReturn(null); + + handler.onAuthenticationFailure( + request, + response, + new OAuth2AuthenticationException(new OAuth2Error( + "user_info_request_failed", "sensitive-upstream-response", null)) + ); + + assertThat(output).contains( + "OAuth2 login failed: path=/login/oauth2/code/dingtalk, " + + "type=OAuth2AuthenticationException, errorCode=user_info_request_failed"); + assertThat(output).doesNotContain("sensitive-upstream-response"); + } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillStorageDeletionCompensationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillStorageDeletionCompensationJpaRepository.java index 853aa4643..72411f44a 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillStorageDeletionCompensationJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillStorageDeletionCompensationJpaRepository.java @@ -5,7 +5,7 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -public interface SkillStorageDeletionCompensationJpaRepository +interface SkillStorageDeletionCompensationJpaRepository extends JpaRepository { List findTop100ByStatusOrderByCreatedAtAsc( diff --git a/web/public/dingtalk-logo.svg b/web/public/dingtalk-logo.svg index e71bfbbc3..b1a268d15 100644 --- a/web/public/dingtalk-logo.svg +++ b/web/public/dingtalk-logo.svg @@ -1,4 +1,3 @@ - - - - \ No newline at end of file + + +