From 0216cdfdee80d0d1cac7a7417d1b32ac928c7f1c Mon Sep 17 00:00:00 2001
From: konglong87 <38234954+konglong87@users.noreply.github.com>
Date: Tue, 2 Jun 2026 14:49:40 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E9=92=89=E9=92=89OAuth2=E5=AE=89?=
=?UTF-8?q?=E5=85=A8=E5=8A=A0=E5=9B=BA=E4=B8=8E=E8=BA=AB=E4=BB=BD=E6=A0=87?=
=?UTF-8?q?=E8=AF=86=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. 身份标识: openId → unionId (跨应用唯一)
- DingTalkClaimsExtractor: subject使用unionId, 新增null校验
- DingTalkOAuth2UserService: nameAttribute改为unionId
- application.yml: user-name-attribute改为unionId
2. Scope配置: dingtalk → openid (钉钉OAuth2正确scope)
- application.yml: scope改为openid
- .env.release.example: 新增scope说明注释
3. RestTemplate超时配置 (5s connect + 10s read)
- DingTalkTokenResponseClient: 防止无限阻塞
- DingTalkOAuth2UserService: 同步配置
4. NPE风险修复
- DingTalkTokenResponseClient: JsonNode null检查
5. 敏感信息泄露修复
- DingTalkTokenResponseClient: 移除raw_response, 只保留expireIn
6. 硬编码URL修复
- DingTalkOAuth2UserService: userInfoUri从ClientRegistration配置读取
7. 单元测试覆盖 (12个测试全部通过)
- DingTalkClaimsExtractorTest: 4个
- DingTalkTokenResponseClientTest: 6个
- DingTalkOAuth2UserServiceTest: 2个
Signed-off-by: konglong87 <38234954+konglong87@users.noreply.github.com>
---
.env.release.example | 2 +
.../src/main/resources/application.yml | 4 +-
.../auth/oauth/DingTalkClaimsExtractor.java | 26 ++-
.../auth/oauth/DingTalkOAuth2UserService.java | 28 ++-
.../oauth/DingTalkTokenResponseClient.java | 43 ++++-
.../oauth/DingTalkClaimsExtractorTest.java | 101 ++++++++++
.../oauth/DingTalkOAuth2UserServiceTest.java | 155 ++++++++++++++++
.../DingTalkTokenResponseClientTest.java | 172 ++++++++++++++++++
8 files changed, 514 insertions(+), 17 deletions(-)
create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java
create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java
create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java
diff --git a/.env.release.example b/.env.release.example
index 5b2fbc9d..126c0035 100644
--- a/.env.release.example
+++ b/.env.release.example
@@ -94,6 +94,8 @@ OAUTH2_GITLAB_DISPLAY_NAME=GitLab
# Optional: configure DingTalk (钉钉) OAuth2 login.
# Register your app at https://open-dev.dingtalk.com and request the Contact.User.Read scope.
+# The scope must be "openid" (not "dingtalk") — DingTalk uses openid for OAuth2 authorization.
+# Add "openid corpid" if you also need corporate identity information.
OAUTH2_DINGTALK_CLIENT_ID=
OAUTH2_DINGTALK_CLIENT_SECRET=
OAUTH2_DINGTALK_DISPLAY_NAME=钉钉
diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml
index 5621c8c9..3f85d0e4 100644
--- a/server/skillhub-app/src/main/resources/application.yml
+++ b/server/skillhub-app/src/main/resources/application.yml
@@ -73,7 +73,7 @@ spring:
client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder}
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder}
scope:
- - dingtalk
+ - openid
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉}
@@ -89,7 +89,7 @@ spring:
authorization-uri: https://login.dingtalk.com/oauth2/auth
token-uri: https://api.dingtalk.com/v1.0/oauth2/userAccessToken
user-info-uri: https://api.dingtalk.com/v1.0/contact/users/me
- user-name-attribute: openId
+ user-name-attribute: unionId
servlet:
multipart:
max-file-size: 100MB
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java
index 51f6d27b..1a5956f3 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java
@@ -1,6 +1,8 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
@@ -14,11 +16,16 @@ import java.util.Map;
*
*
Field mapping:
*
- * - subject → openId
+ * - subject → unionId (unique across all apps under the same developer account)
* - email → unionId@dingtalk.local (synthetic, DingTalk users may not have email)
* - emailVerified → true (synthetic)
* - providerLogin → nick
*
+ *
+ * Note: unionId is used instead of openId because openId is only unique within
+ * a single DingTalk application. If a user logs in through different DingTalk apps
+ * under the same developer account, openId would differ, causing duplicate accounts.
+ * unionId remains stable across all apps under the same developer.
*/
@Component
public class DingTalkClaimsExtractor implements OAuthClaimsExtractor {
@@ -32,18 +39,25 @@ public class DingTalkClaimsExtractor implements OAuthClaimsExtractor {
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
Map attrs = oAuth2User.getAttributes();
- String openId = (String) attrs.get("openId");
String unionId = (String) attrs.get("unionId");
+ String openId = (String) attrs.get("openId");
String nick = (String) attrs.get("nick");
+ // unionId is required — it is the cross-app stable identity for DingTalk users
+ if (unionId == null || unionId.isEmpty()) {
+ throw new OAuth2AuthenticationException(
+ new OAuth2Error("missing_union_id",
+ "DingTalk response missing required unionId field. "
+ + "Ensure the 'openid' scope is configured and the DingTalk app "
+ + "has the Contact.User.Read permission.", null));
+ }
+
// DingTalk users may not have email; synthesize one for downstream compatibility
- String syntheticEmail = (unionId != null && !unionId.isEmpty())
- ? unionId + "@dingtalk.local"
- : (openId != null ? openId + "@dingtalk.local" : null);
+ String syntheticEmail = unionId + "@dingtalk.local";
return new OAuthClaims(
"dingtalk",
- openId,
+ unionId, // Use unionId (cross-app unique) instead of openId (single-app only)
syntheticEmail,
true,
nick,
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java
index 6280972a..ba929aac 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java
@@ -1,5 +1,6 @@
package com.iflytek.skillhub.auth.oauth;
+import java.time.Duration;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
@@ -10,6 +11,7 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
@@ -41,14 +43,32 @@ public class DingTalkOAuth2UserService implements OAuth2UserService requestEntity = new HttpEntity<>(headers);
ResponseEntity