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] 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 59387e0f..5b2fbc9d 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 421e27f6..5621c8c9 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 8c2ff2dc..c58a718c 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 @@ package com.iflytek.skillhub.auth.config; 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.HttpStatus; 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 class SecurityConfig { 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 class SecurityConfig { }) .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 @@ public class SecurityConfig { } } - static boolean hasSessionCookie(HttpServletRequest request) { +static boolean hasSessionCookie(HttpServletRequest request) { if (request.getRequestedSessionId() != null) { return true; } @@ -201,4 +217,50 @@ public class SecurityConfig { } return false; } + + /** + * Delegates OAuth2 user info loading to the appropriate service based on + * the registrationId. DingTalk uses a custom service due to its non-standard + * user info endpoint; all other providers use the standard service. + */ + private static class DelegatingOAuth2UserService implements OAuth2UserService { + 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 00000000..51f6d27b --- /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 00000000..d22f4ad4 --- /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 00000000..0fe402a1 --- /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 00000000..e71bfbbc --- /dev/null +++ b/web/public/dingtalk-logo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file