From 96f244b416fa56b077d8612390eb6e410b14ca2c Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:56:58 +0800 Subject: [PATCH] feat(auth): add DingTalk as a public login provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DingTalk (钉钉) as a public sign-in option: it authenticates a SkillHub platform account and nothing more. No Organization membership, no directory sync, no Namespace grants. DingTalk deviates from standard OAuth at all three stages, one strategy each: - authorize: its endpoint wants scope=openid, but declaring that scope in configuration makes Spring treat the registration as OIDC and attach a nonce, which DingTalk rejects. The scope is added by DingTalkAuthorizationRequestCustomizer instead, keeping this a plain OAuth2 client. A test asserts the scope is present and the nonce is not. - token: credentials go in a JSON body rather than a form, handled by DingTalkTokenResponseClient. - userinfo: the token travels in x-acs-dingtalk-access-token rather than Authorization: Bearer. Subject and email semantics, which decide whether a login can reach an existing account: - unionId is the only accepted subject. DingTalk also returns openId and userId, but they must not act as fallbacks: openId is scoped per app and userId per organization, so a login falling back to either would bind a different identity than a later login carrying unionId, splitting one person across two platform accounts. - A blank or missing unionId fails the login. - emailVerified is always false. DingTalk returns the email an organization admin recorded without attesting the user controls it. The userinfo service only fetches attributes; account matching, provisioning and session creation stay with the unified identity core. The reference implementation called OAuthLoginFlowService.authenticate() from inside loadUser, which decided the account before the core's gate ran. Operational bounds match the Feishu adapter: connect and read timeouts, a 64 KB response cap, error descriptions and logs carrying only the exception class or provider error code, and no logging in the claims extractor. Unused PII is dropped rather than carried into the principal -- notably mobile and stateCode. Adds ProviderStrategyWiringTest, which loads the real application context. The unit tests call package-visible constructors and so cannot catch Spring wiring faults; a component with two constructors and no @Autowired marker unit-tests green and then fails at startup. That happened during this work. Adapted from the implementation in #467 by @konglong87, re-extracted onto current main with the subject, structure and bounds changes above. Part of R1-A2 (public Provider adapters) per openspec/changes/enterprise-identity-platform/rollout-plan.md. Co-authored-by: konglong87 Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../src/main/resources/application.yml | 17 ++ .../oauth/ProviderStrategyWiringTest.java | 88 ++++++++ ...ingTalkAuthorizationRequestCustomizer.java | 30 +++ .../auth/oauth/DingTalkClaimsExtractor.java | 76 +++++++ .../auth/oauth/DingTalkOAuth2Constants.java | 23 ++ .../auth/oauth/DingTalkOAuth2UserService.java | 162 ++++++++++++++ .../oauth/DingTalkTokenResponseClient.java | 145 +++++++++++++ .../oauth/DingTalkClaimsExtractorTest.java | 122 +++++++++++ .../oauth/DingTalkOAuth2UserServiceTest.java | 140 ++++++++++++ .../DingTalkTokenResponseClientTest.java | 201 ++++++++++++++++++ ...Auth2AuthorizationRequestResolverTest.java | 87 ++++++++ web/public/dingtalk-logo.svg | 3 + 12 files changed, 1094 insertions(+) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/ProviderStrategyWiringTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkAuthorizationRequestCustomizer.java 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/DingTalkOAuth2Constants.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 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 create mode 100644 web/public/dingtalk-logo.svg diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 0328a749..97b724f7 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -80,6 +80,18 @@ spring: client-authentication-method: client_secret_post redirect-uri: "${OAUTH2_FEISHU_REDIRECT_URI:{baseUrl}/login/oauth2/code/{registrationId}}" client-name: ${OAUTH2_FEISHU_DISPLAY_NAME:飞书} + dingtalk: + client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder} + client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder} + # No scope is declared on purpose. DingTalk's authorize endpoint wants scope=openid, + # but declaring it here makes Spring treat the registration as OIDC and attach a + # nonce, which DingTalk rejects. DingTalkAuthorizationRequestCustomizer adds the + # scope back to the outgoing URI without turning this into an OIDC flow. + authorization-grant-type: authorization_code + # DingTalk sends credentials in a JSON body, handled by DingTalkTokenResponseClient. + client-authentication-method: none + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉} provider: github: api-base-url: ${OAUTH2_GITHUB_API_BASE_URL:https://api.github.com} @@ -97,6 +109,11 @@ spring: token-uri: ${OAUTH2_FEISHU_TOKEN_URI:https://accounts.feishu.cn/oauth/v3/token} user-info-uri: ${OAUTH2_FEISHU_USER_INFO_URI:${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info} user-name-attribute: open_id + dingtalk: + authorization-uri: ${OAUTH2_DINGTALK_AUTHORIZE_URI:https://login.dingtalk.com}/oauth2/auth + token-uri: ${OAUTH2_DINGTALK_BASE_URI:https://api.dingtalk.com}/v1.0/oauth2/userAccessToken + user-info-uri: ${OAUTH2_DINGTALK_BASE_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/ProviderStrategyWiringTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/ProviderStrategyWiringTest.java new file mode 100644 index 00000000..c87a9ecc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/ProviderStrategyWiringTest.java @@ -0,0 +1,88 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2Constants; +import com.iflytek.skillhub.auth.oauth.DispatchingTokenResponseClient; +import com.iflytek.skillhub.auth.oauth.OAuthClaimsExtractor; +import com.iflytek.skillhub.auth.oauth.ProviderAuthorizationRequestCustomizer; +import com.iflytek.skillhub.auth.oauth.ProviderOAuth2UserService; +import com.iflytek.skillhub.auth.oauth.ProviderTokenResponseClient; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +/** + * Loads the real application context to prove the provider strategy beans are constructible. + * + *

The unit tests for these classes call their package-visible constructors directly, so they + * cannot catch Spring wiring faults: a component with two constructors and no {@code @Autowired} + * marker compiles and unit-tests green, then fails at startup with "No default constructor found". + * This test is the guard for that class of failure. + */ +@SpringBootTest +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class ProviderStrategyWiringTest { + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Autowired + private DispatchingTokenResponseClient dispatchingTokenResponseClient; + + @Autowired + private List tokenResponseClients; + + @Autowired + private List userServices; + + @Autowired + private List authorizationCustomizers; + + @Autowired + private List claimsExtractors; + + @Test + void dispatcherAndEveryProviderStrategyAreConstructible() { + assertThat(dispatchingTokenResponseClient).isNotNull(); + + // DingTalk needs all three strategy hooks; a missing bean would silently fall back to the + // standard OAuth2 behaviour its endpoints reject. + assertThat(tokenResponseClients) + .extracting(ProviderTokenResponseClient::getProvider) + .contains(DingTalkOAuth2Constants.REGISTRATION_ID); + assertThat(authorizationCustomizers) + .extracting(ProviderAuthorizationRequestCustomizer::getProvider) + .contains(DingTalkOAuth2Constants.REGISTRATION_ID); + assertThat(userServices) + .extracting(ProviderOAuth2UserService::getProvider) + .contains(DingTalkOAuth2Constants.REGISTRATION_ID, "feishu"); + assertThat(claimsExtractors) + .extracting(OAuthClaimsExtractor::getProvider) + .contains(DingTalkOAuth2Constants.REGISTRATION_ID, "feishu", "github"); + } + + @Test + void providerKeysAreUniqueSoDispatchMapsCannotCollide() { + // Collectors.toMap in the dispatchers throws on duplicate keys, which would break startup. + assertThat(tokenResponseClients).extracting(ProviderTokenResponseClient::getProvider) + .doesNotHaveDuplicates(); + assertThat(userServices).extracting(ProviderOAuth2UserService::getProvider) + .doesNotHaveDuplicates(); + assertThat(authorizationCustomizers).extracting(ProviderAuthorizationRequestCustomizer::getProvider) + .doesNotHaveDuplicates(); + assertThat(claimsExtractors).extracting(OAuthClaimsExtractor::getProvider) + .doesNotHaveDuplicates(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkAuthorizationRequestCustomizer.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkAuthorizationRequestCustomizer.java new file mode 100644 index 00000000..aa5494c1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkAuthorizationRequestCustomizer.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.LinkedHashSet; +import java.util.Set; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +/** + * Adds the {@code openid} scope DingTalk's authorize endpoint requires. + * + *

The scope cannot simply be declared in {@code application.yml}: Spring Security treats a + * registration carrying {@code openid} as an OIDC client and attaches a {@code nonce} parameter, + * which DingTalk rejects. Adding the scope here keeps the registration a plain OAuth2 client while + * still sending the parameter DingTalk expects. + */ +@Component +public class DingTalkAuthorizationRequestCustomizer implements ProviderAuthorizationRequestCustomizer { + + @Override + public String getProvider() { + return DingTalkOAuth2Constants.REGISTRATION_ID; + } + + @Override + public void customize(OAuth2AuthorizationRequest.Builder builder) { + Set scopes = new LinkedHashSet<>(builder.build().getScopes()); + scopes.add(DingTalkOAuth2Constants.AUTHORIZATION_SCOPE); + builder.scopes(scopes); + } +} 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..a382f075 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.Map; +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; + +/** + * Provider-specific claims extractor for DingTalk (钉钉). Attributes are already fetched by + * {@link DingTalkOAuth2UserService}, which reads them from DingTalk's non-standard user info + * endpoint. + * + *

Like the GitHub and Feishu extractors, this class logs nothing: the subject, display name and + * email it handles are exactly the values that must stay out of the logs. + */ +@Component +public class DingTalkClaimsExtractor implements OAuthClaimsExtractor { + + @Override + public String getProvider() { + return DingTalkOAuth2Constants.REGISTRATION_ID; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { + Map attrs = oAuth2User.getAttributes(); + + String subject = requireText( + attrs.get(DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME), + DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME + ); + + String email = text(attrs.get("email")); + // DingTalk's user info endpoint returns the email recorded by the organization admin and + // does not attest that the user controls it, so it carries no verification signal and + // cannot be used to join an existing account. + boolean emailVerified = false; + + // nick -> name and stop. Falling back to the subject would write it into + // UserAccount.displayName and into UserActivatedEvent, pushing the external subject + // somewhere event consumers may log it. + String providerLogin = text(attrs.get("nick")); + if (providerLogin == null) { + providerLogin = text(attrs.get("name")); + } + + return new OAuthClaims( + DingTalkOAuth2Constants.REGISTRATION_ID, + subject, + email, + emailVerified, + providerLogin, + attrs + ); + } + + private static String requireText(Object value, String attribute) { + String text = text(value); + if (text == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error("missing_subject", "DingTalk user info is missing " + attribute, null) + ); + } + return text; + } + + private static String text(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } +} 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 00000000..83026e50 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.auth.oauth; + +/** 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"; + + /** + * The only accepted subject claim. DingTalk also returns {@code openId} and {@code userId}, but + * they must not act as fallbacks: {@code openId} is scoped per app and {@code userId} per + * organization, so a login that fell back to either would bind a different identity than a + * later login carrying {@code unionId}, splitting one person across two platform accounts. + * Promoting another claim later needs an explicit alias migration. + */ + static final String SUBJECT_CLAIM_NAME = "unionId"; + + public static final String SUBJECT_ATTRIBUTE = "dingtalkSubject"; + + 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 new file mode 100644 index 00000000..7b8331a2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -0,0 +1,162 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * Loads DingTalk (钉钉) user info, which deviates from standard OAuth: the access token travels in + * a custom {@code x-acs-dingtalk-access-token} header rather than {@code Authorization: Bearer}. + * + *

This service only fetches attributes. Account matching, provisioning and session creation + * stay with the unified identity core reached through {@link OAuthLoginFlowService}, so DingTalk + * cannot decide who a login resolves to. + */ +@Component +public class DingTalkOAuth2UserService implements ProviderOAuth2UserService { + + private static final Logger log = LoggerFactory.getLogger(DingTalkOAuth2UserService.class); + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(10); + + /** A DingTalk contact payload is well under 1 KB; this only needs to stop an unbounded body. */ + private static final int MAX_RESPONSE_BYTES = 64 * 1024; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final RestClient restClient; + + /** + * Uses an external-service client that is intentionally not customized with application + * tracing. Trace context must not be propagated to the external DingTalk service. + */ + @Autowired + public DingTalkOAuth2UserService() { + this(RestClient.builder().requestFactory(defaultRequestFactory())); + } + + public DingTalkOAuth2UserService(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + /** + * Bounds the userinfo call so an unresponsive DingTalk endpoint cannot hold a login thread. The + * timeouts apply to this provider client only and do not change the shared HTTP defaults. + */ + private static ClientHttpRequestFactory defaultRequestFactory() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT); + factory.setReadTimeout(READ_TIMEOUT); + return factory; + } + + @Override + public String getProvider() { + return DingTalkOAuth2Constants.REGISTRATION_ID; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + String userInfoUri = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUri(); + + Map payload; + try { + payload = restClient.get() + .uri(userInfoUri) + .header( + DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER, + userRequest.getAccessToken().getTokenValue() + ) + .exchange((request, clientResponse) -> readBounded(clientResponse.getBody())); + } catch (Exception e) { + // Exception class only: the message can quote the request URI, which holds the token. + log.warn("DingTalk user info request failed with {}", e.getClass().getSimpleName()); + throw new OAuth2AuthenticationException( + new OAuth2Error("dingtalk_userinfo_error", "Failed to load DingTalk user info", null), + e + ); + } + + return new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), + normalize(payload), + DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME + ); + } + + /** + * Reads at most {@link #MAX_RESPONSE_BYTES} before parsing, so a misconfigured or hostile + * {@code OAUTH2_DINGTALK_BASE_URI} cannot stream an unbounded body into the parser. Reading one + * byte past the cap is what distinguishes an oversized payload from one that exactly fills it. + */ + private static Map readBounded(InputStream body) throws IOException { + byte[] bytes = body.readNBytes(MAX_RESPONSE_BYTES + 1); + if (bytes.length > MAX_RESPONSE_BYTES) { + throw new IOException("DingTalk user info response exceeds " + MAX_RESPONSE_BYTES + " bytes"); + } + return OBJECT_MAPPER.readValue(bytes, new com.fasterxml.jackson.core.type.TypeReference<>() { + }); + } + + /** + * Copies through only the attributes the platform consumes, and aliases DingTalk's + * {@code avatarUrl} to the {@code avatar_url} key the identity core reads. Attributes the + * platform does not use -- notably {@code mobile} and {@code stateCode} -- are dropped rather + * than carried into the principal, keeping unused PII out of claims and logs. + */ + private static Map normalize(Map payload) { + Map attributes = new LinkedHashMap<>(); + copyIfPresent(attributes, payload, DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME); + copyIfPresent(attributes, payload, "nick"); + copyIfPresent(attributes, payload, "name"); + copyIfPresent(attributes, payload, "email"); + Object avatar = payload.get("avatarUrl"); + if (avatar != null && !String.valueOf(avatar).isBlank()) { + attributes.put("avatar_url", avatar); + } + if (!attributes.containsKey(DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME)) { + throw new OAuth2AuthenticationException( + new OAuth2Error( + "dingtalk_userinfo_error", + "DingTalk user info missing " + DingTalkOAuth2Constants.SUBJECT_CLAIM_NAME, + null + ) + ); + } + return attributes; + } + + private static void copyIfPresent( + Map target, + Map source, + String key + ) { + Object value = source.get(key); + if (value != null && !String.valueOf(value).isBlank()) { + target.put(key, value); + } + } +} 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..bfb79978 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java @@ -0,0 +1,145 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +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.RestClientException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; + +/** + * 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 ProviderTokenResponseClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final RestTemplate restTemplate; + + @Autowired + public DingTalkTokenResponseClient() { + this.restTemplate = buildRestTemplate(); + } + + /** Package-visible constructor for unit testing with a mock RestTemplate. */ + DingTalkTokenResponseClient(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + @Override + public String getProvider() { + return DingTalkOAuth2Constants.REGISTRATION_ID; + } + + 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 (RestClientResponseException e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_exchange_io_error", + "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) { + 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.isBlank()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_response_missing_field", + "DingTalk token response has empty accessToken", null)); + } + + JsonNode expireInNode = json.get("expireIn"); + 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) { + throw e; + } catch (Exception e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_parse_error", + "Failed to parse DingTalk token response", null)); + } + } + + throw new OAuth2AuthenticationException( + new OAuth2Error("token_exchange_failed", + "DingTalk token exchange failed: HTTP " + response.getStatusCode(), null)); + } +} 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 00000000..8bf38b18 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java @@ -0,0 +1,122 @@ +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.HashMap; +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.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; + +class DingTalkClaimsExtractorTest { + + private final DingTalkClaimsExtractor extractor = new DingTalkClaimsExtractor(); + + @Test + void extract_mapsUnionIdAndNick() { + Map attrs = new HashMap<>(Map.of( + "unionId", "un_123", + "nick", "张三", + "email", "zhangsan@corp.example" + )); + + OAuthClaims claims = extractor.extract(userRequest(), user(attrs)); + + assertThat(claims.provider()).isEqualTo("dingtalk"); + assertThat(claims.subject()).isEqualTo("un_123"); + assertThat(claims.providerLogin()).isEqualTo("张三"); + assertThat(claims.email()).isEqualTo("zhangsan@corp.example"); + // DingTalk's contact endpoint does not attest email ownership. + assertThat(claims.emailVerified()).isFalse(); + } + + @Test + void extract_neverAcceptsOpenIdOrUserIdAsSubject() { + // openId is per-app and userId per-organization. Accepting either as a fallback would bind + // a different identity than a later login carrying unionId, splitting one person across + // two platform accounts. + Map attrs = new HashMap<>(Map.of( + "openId", "op_456", + "userId", "usr_789", + "nick", "张三" + )); + + assertThatThrownBy(() -> extractor.extract(userRequest(), user(attrs))) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("unionId"); + } + + @Test + void extract_rejectsBlankUnionId() { + Map attrs = new HashMap<>(); + attrs.put("unionId", " "); + attrs.put("nick", "张三"); + + assertThatThrownBy(() -> extractor.extract(userRequest(), user(attrs))) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("unionId"); + } + + @Test + void extract_fallsBackToNameThenLeavesDisplayNameUnset() { + Map withName = new HashMap<>(Map.of("unionId", "un_1", "name", "Alice")); + assertThat(extractor.extract(userRequest(), user(withName)).providerLogin()).isEqualTo("Alice"); + + // Must not synthesize from the subject: providerLogin is written to displayName and into + // UserActivatedEvent, so a synthesized value would carry the subject to event consumers. + Map bare = new HashMap<>(Map.of("unionId", "un_2")); + OAuthClaims claims = extractor.extract(userRequest(), user(bare)); + assertThat(claims.providerLogin()).isNull(); + assertThat(claims.subject()).isEqualTo("un_2"); + } + + /** Does not enforce the name attribute, unlike DefaultOAuth2User. */ + private OAuth2User user(Map attrs) { + return new OAuth2User() { + @Override + public Map getAttributes() { + return attrs; + } + + @Override + public java.util.Collection + getAuthorities() { + return java.util.List.of(); + } + + @Override + public String getName() { + return String.valueOf(attrs.get("unionId")); + } + }; + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk") + .clientId("dingoauth_test") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .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, + "token-123", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +} 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 00000000..e5d7027a --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java @@ -0,0 +1,140 @@ +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.header; +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 org.junit.jupiter.api.Test; +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.ClientAuthenticationMethod; +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.RestClient; + +class DingTalkOAuth2UserServiceTest { + + @Test + void loadUser_sendsCustomTokenHeaderAndNormalizesAttributes() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + // DingTalk reads the token from its own header, not Authorization: Bearer. + .andExpect(header(DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER, "token-123")) + .andRespond(withSuccess( + """ + { + "unionId": "un_123", + "openId": "op_456", + "nick": "张三", + "avatarUrl": "https://avatar.example/z.png", + "email": "zhangsan@corp.example", + "mobile": "13800000000", + "stateCode": "86" + } + """, + MediaType.APPLICATION_JSON + )); + DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder); + + OAuth2User user = service.loadUser(userRequest()); + + assertThat(user.getName()).isEqualTo("un_123"); + assertThat(user.getAttributes()) + .containsEntry("unionId", "un_123") + .containsEntry("nick", "张三") + .containsEntry("email", "zhangsan@corp.example") + // avatarUrl is aliased to the key the identity core reads. + .containsEntry("avatar_url", "https://avatar.example/z.png"); + // Unused PII must not travel into the principal or claims. + assertThat(user.getAttributes()).doesNotContainKeys("mobile", "stateCode", "avatarUrl"); + // openId must not survive as a usable subject candidate. + assertThat(user.getAttributes()).doesNotContainKey("openId"); + server.verify(); + } + + @Test + void loadUser_rejectsResponseWithoutUnionId() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + .andRespond(withSuccess( + """ + {"openId": "op_456", "nick": "张三"} + """, + MediaType.APPLICATION_JSON + )); + DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder); + + assertThatThrownBy(() -> service.loadUser(userRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("unionId"); + server.verify(); + } + + @Test + void loadUser_rejectsOversizedResponseBody() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + // 64 KB cap; pad a structurally valid payload past it so the size check fires, not the parser. + String padding = "x".repeat(70 * 1024); + server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + .andRespond(withSuccess( + "{\"unionId\":\"un_123\",\"nick\":\"" + padding + "\"}", + MediaType.APPLICATION_JSON + )); + DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder); + + assertThatThrownBy(() -> service.loadUser(userRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()) + .isEqualTo("dingtalk_userinfo_error")); + server.verify(); + } + + @Test + void loadUser_errorDescriptionDoesNotEchoUpstreamTextOrToken() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + .andRespond(withSuccess("not json at all: token-123", MediaType.APPLICATION_JSON)); + DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder); + + assertThatThrownBy(() -> service.loadUser(userRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> { + String description = ((OAuth2AuthenticationException) ex).getError().getDescription(); + assertThat(description).doesNotContain("token-123"); + }); + server.verify(); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk") + .clientId("dingoauth_test") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .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, + "token-123", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +} 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 00000000..ac15a660 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java @@ -0,0 +1,201 @@ +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 java.time.Duration; +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.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(); + 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().body("sensitive-upstream-response")); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .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_throwsWhenExpireInIsMissing() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "accessToken": "dt_access_token_123" + } + """, + MediaType.APPLICATION_JSON + )); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex) + .getError().getErrorCode()).isEqualTo("token_response_invalid_expiry")); + } + + @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() { + 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) + ); + } +} 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 9cef26b2..49cb92c8 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 @@ -84,4 +84,91 @@ class OAuth2AuthorizationRequestResolverTest { assertThat(session).isNotNull(); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); } + + @Test + void resolve_addsDingTalkScopeWithoutTurningTheRequestIntoOidc() { + SkillHubOAuth2AuthorizationRequestResolver dingTalkResolver = resolverFor( + dingTalkRegistration(), + new DingTalkAuthorizationRequestCustomizer() + ); + MockHttpServletRequest request = + new MockHttpServletRequest("GET", "/oauth2/authorization/dingtalk"); + + var authorizationRequest = dingTalkResolver.resolve(request, "dingtalk"); + + assertThat(authorizationRequest).isNotNull(); + // DingTalk's authorize endpoint requires scope=openid... + assertThat(authorizationRequest.getScopes()).contains("openid"); + assertThat(authorizationRequest.getAuthorizationRequestUri()).contains("scope=openid"); + // ...but rejects the nonce Spring attaches when a registration declares openid in config. + // Declaring no scope there and adding it here is what keeps the nonce away. + assertThat(authorizationRequest.getAdditionalParameters()).doesNotContainKey("nonce"); + assertThat(authorizationRequest.getAttributes()).doesNotContainKey("nonce"); + assertThat(authorizationRequest.getAuthorizationRequestUri()).doesNotContain("nonce="); + } + + @Test + void resolve_leavesOtherProvidersUntouchedWhenADingTalkCustomizerIsRegistered() { + SkillHubOAuth2AuthorizationRequestResolver mixedResolver = resolverFor( + githubRegistration(), + new DingTalkAuthorizationRequestCustomizer() + ); + MockHttpServletRequest request = + new MockHttpServletRequest("GET", "/oauth2/authorization/github"); + + var authorizationRequest = mixedResolver.resolve(request, "github"); + + assertThat(authorizationRequest).isNotNull(); + assertThat(authorizationRequest.getScopes()).containsExactly("read:user"); + } + + private static SkillHubOAuth2AuthorizationRequestResolver resolverFor( + ClientRegistration registration, + ProviderAuthorizationRequestCustomizer customizer + ) { + OAuthLoginFlowService flowService = new OAuthLoginFlowService( + java.util.List.of(), + mock(AccessPolicy.class), + mock(IdentityBindingService.class) + ); + return new SkillHubOAuth2AuthorizationRequestResolver( + new InMemoryClientRegistrationRepository(registration), + flowService, + java.util.List.of(customizer) + ); + } + + private static ClientRegistration githubRegistration() { + return 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(); + } + + private static ClientRegistration dingTalkRegistration() { + // Mirrors application.yml: no scope declared, so Spring keeps this a plain OAuth2 client. + return ClientRegistration.withRegistrationId("dingtalk") + .clientId("dingoauth_test") + .clientSecret("secret") + .authorizationUri("https://login.dingtalk.com/oauth2/auth") + .tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken") + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me") + .userNameAttributeName("unionId") + .authorizationGrantType( + org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod( + org.springframework.security.oauth2.core.ClientAuthenticationMethod.NONE) + .clientName("钉钉") + .build(); + } } diff --git a/web/public/dingtalk-logo.svg b/web/public/dingtalk-logo.svg new file mode 100644 index 00000000..b1a268d1 --- /dev/null +++ b/web/public/dingtalk-logo.svg @@ -0,0 +1,3 @@ + + +