diff --git a/.env.release.example b/.env.release.example index cdbddf061..813846df6 100644 --- a/.env.release.example +++ b/.env.release.example @@ -67,6 +67,14 @@ 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. +# 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=钉钉 + # 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/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. diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index 87dd24194..1cf41e34e 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: @@ -49,5 +52,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 a592b0359..988f3f1c3 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -68,6 +68,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: + - 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 @@ -76,6 +84,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: unionId servlet: multipart: max-file-size: 100MB 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 a25d31048..6079e9517 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 @@ -142,12 +142,13 @@ void providersShouldExposeGithubLoginEntry() throws Exception { mockMvc.perform(get("/api/v1/auth/providers")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.length()").value(3)) - .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab"))) + .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/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 05b870252..5a167a84e 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; @@ -17,6 +19,13 @@ 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.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; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; @@ -37,7 +46,7 @@ * Central Spring Security configuration for browser sessions, API tokens, and * public versus protected endpoints. */ -@Configuration +@Configuration(proxyBeanMethods = false) @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @@ -55,6 +64,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; @@ -67,6 +78,8 @@ public class SecurityConfig { public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomOidcUserService customOidcUserService, + DingTalkOAuth2UserService dingTalkOAuth2UserService, + DingTalkTokenResponseClient dingTalkTokenResponseClient, SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, OAuth2LoginSuccessHandler successHandler, OAuth2LoginFailureHandler failureHandler, @@ -78,6 +91,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; @@ -118,8 +133,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) @@ -183,4 +200,50 @@ 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..1a5956f3c --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java @@ -0,0 +1,67 @@ +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; + +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: + *

+ * + *

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 { + + @Override + public String getProvider() { + return "dingtalk"; + } + + @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"); + + // 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 + "@dingtalk.local"; + + return new OAuthClaims( + "dingtalk", + unionId, // Use unionId (cross-app unique) instead of openId (single-app only) + 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..a3427ced9 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -0,0 +1,119 @@ +package com.iflytek.skillhub.auth.oauth; + +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.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +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; + +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} + * 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 { + + private static final Logger log = LoggerFactory.getLogger(DingTalkOAuth2UserService.class); + + private final RestTemplate restTemplate; + private final DingTalkClaimsExtractor claimsExtractor; + private final OAuthLoginFlowService oauthLoginFlowService; + + @Autowired + public DingTalkOAuth2UserService(DingTalkClaimsExtractor claimsExtractor, + OAuthLoginFlowService oauthLoginFlowService) { + this.restTemplate = buildRestTemplate(); + this.claimsExtractor = claimsExtractor; + this.oauthLoginFlowService = oauthLoginFlowService; + } + + /** Package-visible constructor for unit testing with a mock RestTemplate. */ + DingTalkOAuth2UserService(DingTalkClaimsExtractor claimsExtractor, + OAuthLoginFlowService oauthLoginFlowService, + RestTemplate restTemplate) { + this.restTemplate = restTemplate; + this.claimsExtractor = claimsExtractor; + this.oauthLoginFlowService = oauthLoginFlowService; + } + + private static RestTemplate buildRestTemplate() { + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(5)); + factory.setReadTimeout(Duration.ofSeconds(10)); + return new RestTemplate(factory); + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) { + String accessToken = userRequest.getAccessToken().getTokenValue(); + String userInfoUri = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUri(); + + // Fetch user info using DingTalk's custom header + HttpHeaders headers = new HttpHeaders(); + headers.set("x-acs-dingtalk-access-token", accessToken); + HttpEntity requestEntity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + userInfoUri, + 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 — use unionId as the name attribute (cross-app unique identity) + OAuthClaims claims = claimsExtractor.extract(userRequest, new DefaultOAuth2User( + java.util.Collections.emptyList(), userAttributes, "unionId")); + + log.info("DingTalk OAuth2 login: subject={}, providerLogin={}", claims.subject(), claims.providerLogin()); + + // Delegate to OAuthLoginFlowService for access policy evaluation and identity binding + PlatformPrincipal principal = oauthLoginFlowService.authenticate(claims); + + // Build OAuth2User with principal and authorities, consistent with CustomOAuth2UserService + userAttributes.put("platformPrincipal", principal); + userAttributes.put("providerLogin", principal.userId()); + + var authorities = new LinkedHashSet(); + principal.platformRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .forEach(authorities::add); + + return new DefaultOAuth2User( + authorities, + userAttributes, + "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 new file mode 100644 index 000000000..6d4b724db --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java @@ -0,0 +1,124 @@ +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.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; +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.time.Duration; +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; + + 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) + 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()); + + JsonNode accessTokenNode = json.get("accessToken"); + if (accessTokenNode == null || accessTokenNode.isNull()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_response_missing_field", + "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(safeParams) + .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/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 14beac753..e228cd0a2 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,6 +3,8 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; @@ -16,6 +18,8 @@ @Component public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { + private static final Logger log = LoggerFactory.getLogger(OAuth2LoginFailureHandler.class); + private final OAuthLoginFlowService oauthLoginFlowService; public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) { @@ -26,6 +30,7 @@ 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 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 c72b1d9d6..b925c0409 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 @@ -7,8 +7,8 @@ import org.springframework.stereotype.Component; /** - * OAuth2 authorization request resolver that preserves a sanitized post-login redirect target in - * the HTTP session. + * OAuth2 authorization request resolver that preserves a sanitized post-login + * redirect target in the HTTP session. */ @Component public class SkillHubOAuth2AuthorizationRequestResolver 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 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( 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