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