Merge pull request #880 from iflytek/feature/dingtalk-public-provider

feat(auth): add DingTalk as a public login provider (R1-A2)
This commit is contained in:
XiaoSeS 2026-09-22 11:02:22 +08:00 • committed by GitHub
commit 8498fd047f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1932 additions and 21 deletions

View file

@ -138,6 +138,20 @@ OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info
OAUTH2_FEISHU_REDIRECT_URI=
OAUTH2_FEISHU_DISPLAY_NAME=飞书
# Optional: DingTalk login as a public sign-in provider. Leaving the client id empty keeps the
# button off the login page. Use the app's AppKey as the client id and AppSecret as the secret.
# Like Feishu, DingTalk returns an organization-recorded email without attesting ownership, so
# emailVerified is always false and the EMAIL_DOMAIN access policy would reject every login.
# The DingTalk console's server egress IP must be the real public IP of the backend calling
# api.dingtalk.com. A reverse tunnel only changes callback ingress and does not change egress.
OAUTH2_DINGTALK_CLIENT_ID=
OAUTH2_DINGTALK_CLIENT_SECRET=
OAUTH2_DINGTALK_AUTHORIZE_URI=https://login.dingtalk.com
OAUTH2_DINGTALK_BASE_URI=https://api.dingtalk.com
# Optional; defaults to {baseUrl}/login/oauth2/code/dingtalk.
OAUTH2_DINGTALK_REDIRECT_URI=
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.

View file

@ -110,6 +110,8 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \
| `skillhub-download-anon-cookie-secret` | 是 | 至少 32 字符的匿名下载 Cookie 签名密钥 |
| `oauth2-github-client-id` | 否 | GitHub OAuth2 Client ID |
| `oauth2-github-client-secret` | 否 | GitHub OAuth2 Client Secret |
| `oauth2-dingtalk-client-id` | 否 | DingTalk AppKey |
| `oauth2-dingtalk-client-secret` | 否 | DingTalk AppSecret |
| `skill-scanner-llm-api-key` | 否 | Scanner LLM API Key |
| `skill-scanner-llm-base-url` | 否 | Scanner 自定义 LLM API 地址 |
| `skill-scanner-llm-model` | 否 | Scanner LLM 模型名称 |

View file

@ -67,6 +67,14 @@ stringData:
oauth2-feishu-client-secret: {{ .Values.secrets.oauth2FeishuClientSecret | quote }}
{{- end }}
# OAuth2 DingTalk (optional)
{{- if .Values.secrets.oauth2DingtalkClientId }}
oauth2-dingtalk-client-id: {{ .Values.secrets.oauth2DingtalkClientId | quote }}
{{- end }}
{{- if .Values.secrets.oauth2DingtalkClientSecret }}
oauth2-dingtalk-client-secret: {{ .Values.secrets.oauth2DingtalkClientSecret | quote }}
{{- end }}
# Scanner LLM 配置 (optional)
{{- if .Values.secrets.scannerLlmApiKey }}
skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey | quote }}

View file

@ -381,6 +381,30 @@ spec:
value: {{ . | quote }}
{{- end }}
# OAuth2 DingTalk (optional)
- name: OAUTH2_DINGTALK_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: oauth2-dingtalk-client-id
optional: true
- name: OAUTH2_DINGTALK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: oauth2-dingtalk-client-secret
optional: true
- name: OAUTH2_DINGTALK_AUTHORIZE_URI
value: {{ .Values.oauth2.dingtalk.authorizeBaseUri | quote }}
- name: OAUTH2_DINGTALK_BASE_URI
value: {{ .Values.oauth2.dingtalk.apiBaseUri | quote }}
{{- with .Values.oauth2.dingtalk.redirectUri }}
- name: OAUTH2_DINGTALK_REDIRECT_URI
value: {{ . | quote }}
{{- end }}
- name: OAUTH2_DINGTALK_DISPLAY_NAME
value: {{ .Values.oauth2.dingtalk.displayName | quote }}
{{- if .Values.server.javaOpts }}
- name: JAVA_OPTS
value: {{ .Values.server.javaOpts }}

View file

@ -42,6 +42,9 @@ grep -A1 -F 'name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED' "$TMP_DIR/default.yaml"
if grep -Fq 'name: OAUTH2_FEISHU_REDIRECT_URI' "$TMP_DIR/default.yaml"; then
fail "default Helm rendering must omit an empty Feishu redirect URI so Spring can derive baseUrl"
fi
if grep -Fq 'name: OAUTH2_DINGTALK_REDIRECT_URI' "$TMP_DIR/default.yaml"; then
fail "default Helm rendering must omit an empty DingTalk redirect URI so Spring can derive baseUrl"
fi
render feishu-redirect "$CHART_DIR" \
--set-string oauth2.feishu.redirectUri=https://skills.example.com/login/oauth2/code/feishu \
@ -50,6 +53,13 @@ grep -A1 -F 'name: OAUTH2_FEISHU_REDIRECT_URI' "$TMP_DIR/feishu-redirect.yaml" \
| grep -Fq 'value: "https://skills.example.com/login/oauth2/code/feishu"' \
|| fail "Helm must inject an explicitly configured Feishu redirect URI"
render dingtalk-redirect "$CHART_DIR" \
--set-string oauth2.dingtalk.redirectUri=https://skills.example.com/login/oauth2/code/dingtalk \
--show-only templates/server-deployment.yaml >"$TMP_DIR/dingtalk-redirect.yaml"
grep -A1 -F 'name: OAUTH2_DINGTALK_REDIRECT_URI' "$TMP_DIR/dingtalk-redirect.yaml" \
| grep -Fq 'value: "https://skills.example.com/login/oauth2/code/dingtalk"' \
|| fail "Helm must inject an explicitly configured DingTalk redirect URI"
render suite-review-enabled "$CHART_DIR" \
--set server.suiteReviewWritesEnabled=true \
--show-only templates/server-deployment.yaml >"$TMP_DIR/suite-review-enabled.yaml"
@ -74,6 +84,17 @@ render stable "$CHART_DIR" "${stable_args[@]}" >"$TMP_DIR/stable-a.yaml"
render stable "$CHART_DIR" "${stable_args[@]}" >"$TMP_DIR/stable-b.yaml"
cmp "$TMP_DIR/stable-a.yaml" "$TMP_DIR/stable-b.yaml"
render dingtalk "$CHART_DIR" "${stable_args[@]}" \
--set-string secrets.oauth2DingtalkClientId=ding-test \
--set-string secrets.oauth2DingtalkClientSecret=dingtalk-test-secret \
>"$TMP_DIR/dingtalk.yaml"
grep -Fq 'oauth2-dingtalk-client-id: "ding-test"' "$TMP_DIR/dingtalk.yaml" \
|| fail "Helm must render the configured DingTalk client id"
grep -Fq 'oauth2-dingtalk-client-secret: "dingtalk-test-secret"' "$TMP_DIR/dingtalk.yaml" \
|| fail "Helm must render the configured DingTalk client secret"
grep -Fq 'name: OAUTH2_DINGTALK_CLIENT_ID' "$TMP_DIR/dingtalk.yaml" \
|| fail "server deployment must inject the DingTalk client id"
render private-registry "$CHART_DIR" \
--set server.dependencyWait.image.registry=registry.example.com \
--set server.dependencyWait.image.repository=library/busybox \

View file

@ -37,7 +37,7 @@
"oauth2": {
"type": "object",
"additionalProperties": false,
"required": ["feishu"],
"required": ["feishu", "dingtalk"],
"properties": {
"feishu": {
"type": "object",
@ -50,6 +50,17 @@
"userInfoUri": { "type": "string", "format": "uri" },
"redirectUri": { "type": "string" }
}
},
"dingtalk": {
"type": "object",
"additionalProperties": false,
"required": ["authorizeBaseUri", "apiBaseUri", "redirectUri", "displayName"],
"properties": {
"authorizeBaseUri": { "type": "string", "format": "uri" },
"apiBaseUri": { "type": "string", "format": "uri" },
"redirectUri": { "type": "string" },
"displayName": { "type": "string", "minLength": 1 }
}
}
}
},
@ -177,6 +188,8 @@
"oauth2GithubClientSecret": { "type": "string" },
"oauth2FeishuClientId": { "type": "string" },
"oauth2FeishuClientSecret": { "type": "string" },
"oauth2DingtalkClientId": { "type": "string" },
"oauth2DingtalkClientSecret": { "type": "string" },
"scannerLlmApiKey": { "type": "string" },
"scannerLlmBaseUrl": { "type": "string" },
"scannerLlmModel": { "type": "string" }

View file

@ -29,6 +29,11 @@ oauth2:
tokenUri: https://accounts.feishu.cn/oauth/v3/token
userInfoUri: https://open.feishu.cn/open-apis/authen/v1/user_info
redirectUri: ""
dingtalk:
authorizeBaseUri: https://login.dingtalk.com
apiBaseUri: https://api.dingtalk.com
redirectUri: ""
displayName: 钉钉
builtinSkills:
enabled: true
@ -103,6 +108,8 @@ secrets:
oauth2GithubClientSecret: ""
oauth2FeishuClientId: ""
oauth2FeishuClientSecret: ""
oauth2DingtalkClientId: ""
oauth2DingtalkClientSecret: ""
scannerLlmApiKey: ""
scannerLlmBaseUrl: ""
scannerLlmModel: ""

View file

@ -128,6 +128,12 @@ services:
OAUTH2_FEISHU_USER_INFO_URI: ${OAUTH2_FEISHU_USER_INFO_URI:-${OAUTH2_FEISHU_BASE_URI:-https://open.feishu.cn}/open-apis/authen/v1/user_info}
OAUTH2_FEISHU_REDIRECT_URI: ${OAUTH2_FEISHU_REDIRECT_URI:-${SKILLHUB_PUBLIC_BASE_URL:-http://localhost}/login/oauth2/code/feishu}
OAUTH2_FEISHU_DISPLAY_NAME: ${OAUTH2_FEISHU_DISPLAY_NAME:-飞书}
OAUTH2_DINGTALK_CLIENT_ID: ${OAUTH2_DINGTALK_CLIENT_ID:-local-placeholder}
OAUTH2_DINGTALK_CLIENT_SECRET: ${OAUTH2_DINGTALK_CLIENT_SECRET:-local-placeholder}
OAUTH2_DINGTALK_AUTHORIZE_URI: ${OAUTH2_DINGTALK_AUTHORIZE_URI:-https://login.dingtalk.com}
OAUTH2_DINGTALK_BASE_URI: ${OAUTH2_DINGTALK_BASE_URI:-https://api.dingtalk.com}
OAUTH2_DINGTALK_REDIRECT_URI: ${OAUTH2_DINGTALK_REDIRECT_URI:-${SKILLHUB_PUBLIC_BASE_URL:-http://localhost}/login/oauth2/code/dingtalk}
OAUTH2_DINGTALK_DISPLAY_NAME: ${OAUTH2_DINGTALK_DISPLAY_NAME:-钉钉}
SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-}
SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25}
SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-}

View file

@ -248,6 +248,26 @@ spec:
value: "https://accounts.feishu.cn/oauth/v3/token"
- name: OAUTH2_FEISHU_USER_INFO_URI
value: "https://open.feishu.cn/open-apis/authen/v1/user_info"
# OAuth2 DingTalk (optional)
- name: OAUTH2_DINGTALK_CLIENT_ID
valueFrom:
secretKeyRef:
name: skillhub-secret
key: oauth2-dingtalk-client-id
optional: true
- name: OAUTH2_DINGTALK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: skillhub-secret
key: oauth2-dingtalk-client-secret
optional: true
- name: OAUTH2_DINGTALK_AUTHORIZE_URI
value: "https://login.dingtalk.com"
- name: OAUTH2_DINGTALK_BASE_URI
value: "https://api.dingtalk.com"
- name: OAUTH2_DINGTALK_DISPLAY_NAME
value: "钉钉"
volumeMounts:
- name: skillhub-storage
mountPath: /var/lib/skillhub/storage

View file

@ -31,6 +31,10 @@ stringData:
oauth2-feishu-client-id: ""
oauth2-feishu-client-secret: ""
# 钉钉 OAuth(可选,用于钉钉登录;留空则登录页不展示该入口)
oauth2-dingtalk-client-id: ""
oauth2-dingtalk-client-secret: ""
# LLM 配置(可选,用于技能扫描)
skill-scanner-llm-api-key: ""
skill-scanner-llm-base-url: ""

View file

@ -282,6 +282,17 @@ spring:
# 飞书的 scope 配在开放平台应用上,不在这里传
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
dingtalk:
client-id: ${OAUTH2_DINGTALK_CLIENT_ID}
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET}
# 故意不声明 scope:钉钉的授权端点要 scope=openid,但在这里声明会让
# Spring 把该注册当成 OIDC 客户端并附加 nonce,而钉钉不接受 nonce。
# scope=openid 与 prompt=consent 由 DingTalkAuthorizationRequestCustomizer
# 在请求阶段补上。
# 钉钉是 confidential client,只是由自定义 token client 把 secret 放进 JSON body。
# 不使用 none,避免 Spring 自动添加本实现无法应答的 PKCE challenge。
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
provider:
feishu:
# Full endpoints are configurable for Lark, private deployments, and gateways.
@ -300,12 +311,24 @@ Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider
第 2 步是按 Provider 注册一个 Bean,而不是在某个类里按 `registrationId` 分支。
账号匹配、建号、资料权威和账号守卫都在 `OAuthClaims` 之后共享,Provider 自己不做这些决策。
如果该 Provider 的 userinfo 响应不是标准的扁平结构(例如飞书用
`{code, msg, data}` 信封,且以 HTTP 200 返回错误),再额外实现一个
`ProviderOAuth2UserService`:它声明自己负责哪个 `registrationId`,
接管 userinfo 的加载步骤,其余流程不变。该覆盖运行在
如果该 Provider 的协议有偏离标准之处,按偏离的环节实现对应的策略接口,
每个接口都声明自己负责哪个 `registrationId`,由框架分发,不需要在共享类里写分支:
| 偏离环节 | 策略接口 | 现有实现 |
|---|---|---|
| 授权请求参数 | `ProviderAuthorizationRequestCustomizer` | 钉钉补 `scope=openid` 与 `prompt=consent` |
| token 交换 | `ProviderTokenResponseClient` | 钉钉用 JSON body 而非表单 |
| userinfo 加载 | `ProviderOAuth2UserService` | 飞书拆信封;钉钉用自定义 token header |
以 userinfo 为例:飞书用 `{code, msg, data}` 信封且以 HTTP 200 返回错误,
钉钉则把 token 放在 `x-acs-dingtalk-access-token` 而不是 `Authorization: Bearer`。
两者都只接管加载步骤,其余流程不变。该覆盖运行在
`RemoteIdentityIoExecutor` 边界内,因此 Provider 的 HTTP 调用不会持有数据库事务。
Provider 的实现**不得**自己做账号决策 —— 不建号、不绑定、不建 session。
这些一律交给统一身份核心,否则每个 Provider 都会长出一套账号逻辑,
正是统一身份认证要消除的问题。
Provider 侧还需遵守:subject 必须稳定(不要用可能在两次登录间变化的字段做
fallback,否则同一个人会被拆成两个平台账号)、只有在 Provider 真正证明了邮箱
所有权时才置 `emailVerified=true`、远程调用要有超时与响应大小上限、

View file

@ -314,11 +314,22 @@ services:
兼容回退。`OAUTH2_FEISHU_TOKEN_URI` 必须指向支持 JSON authorization-code
exchange 的 endpoint。`OAUTH2_FEISHU_PROTOCOL_VERSION` 只允许 `v2` 或 `v3`,
默认 `v3`,不会自动 fallback。
- 钉钉:`OAUTH2_DINGTALK_CLIENT_ID` / `OAUTH2_DINGTALK_CLIENT_SECRET`
(分别填应用的 AppKey 与 AppSecret)。在钉钉开发者后台登记
`https://<公网域名>/login/oauth2/code/dingtalk`,并为用户信息接口开通所需权限。
同时将钉钉开发者后台的“服务器出口 IP”配置为实际运行 SkillHub 后端并调用
DingTalk API 的机器公网 IP;仅将回调域名或反向隧道服务器 IP 加入白名单并不能
改变本地后端的出站 IP。使用 SSH 反向隧道做本地预览时,应临时加入本机出站 IP,
或让后端出站流量经过已加入白名单的服务器;生产环境应只配置生产后端的固定出口 IP。
`OAUTH2_DINGTALK_REDIRECT_URI` 可在动态端口或特殊反向代理场景显式覆盖;Compose
默认根据 `SKILLHUB_PUBLIC_BASE_URL` 生成回调,Helm/K8s 未设置时由 Spring 使用
`{baseUrl}`。国际版或网关场景可覆盖 `OAUTH2_DINGTALK_AUTHORIZE_URI` 与
`OAUTH2_DINGTALK_BASE_URI`。
留空即不展示该入口,无需改配置文件。注意:飞书邮箱由企业管理员导入、未经用户
确认,因此 `emailVerified` 恒为 false;若在 `application.yml` 中把
留空即不展示该入口,无需改配置文件。注意:飞书和钉钉的邮箱都由企业管理员导入、
未经用户确认,因此 `emailVerified` 恒为 false;若在 `application.yml` 中把
`skillhub.access-policy.mode` 设为 `EMAIL_DOMAIN`,该策略会拒绝所有未验证邮箱,
飞书登录将一律失败。启用飞书时请保留默认的 `OPEN` 或改用其他准入模式。
这两个入口的登录将一律失败。启用它们时请保留默认的 `OPEN` 或改用其他准入模式。
启用飞书前,使用一个测试租户完成一次真实回调验收。不要把真实 client secret
写入仓库、报告或聊天记录;只在受控的 `.env.release`、CI Secret 或 Kubernetes
@ -351,6 +362,149 @@ services:
本地 mock 回调只能证明 SkillHub 与协议形状的集成,不能替代上述真实租户验收。
没有可用飞书租户时,应将该项记录为“未验证”,不要宣称 Feishu 登录已通过。
钉钉登录使用同样的验收边界,但协议配置不同:在钉钉开发者后台创建企业内部
H5 微应用,使用应用的 AppKey/AppSecret,进入“钉钉登录与分享”登记
`https://<公网域名>/login/oauth2/code/dingtalk`,并开通个人信息读取权限。
验收前设置:
```dotenv
OAUTH2_DINGTALK_CLIENT_ID=<AppKey>
OAUTH2_DINGTALK_CLIENT_SECRET=<AppSecret>
OAUTH2_DINGTALK_REDIRECT_URI=https://<公网域名>/login/oauth2/code/dingtalk
```
登录请求必须包含 `scope=openid` 和 `prompt=consent`,但配置文件不能声明 `openid`
scope;实现会把它们仅写入外发授权 URL,避免 Spring 将回调路由到 OIDC。验收时应
确认 token 请求为 JSON body,userinfo 请求使用 `x-acs-dingtalk-access-token`,重复
登录仍绑定同一 `unionId`。上游失败时日志只记录 HTTP 状态、错误码、requiredScopes
和 requestId,不记录 AppSecret、authorization code、access token、unionId 或完整错误正文。
没有钉钉测试应用凭据时,这些只能标记为“协议测试通过、真实厂商往返未验证”。
### 7.1 钉钉配置示例
以下示例中的 `AppKey`、`AppSecret`、公网地址和出口 IP 都必须替换为部署环境的真实值。
不要把 `AppSecret` 提交到 Git、镜像或 HTML 报告。
#### Docker Compose release
在受保护的 `.env.release` 中设置:
```dotenv
# 浏览器访问地址,不带末尾斜杠
SKILLHUB_PUBLIC_BASE_URL=https://skills.example.com
SESSION_COOKIE_SECURE=true
# 钉钉企业内部 H5 微应用
OAUTH2_DINGTALK_CLIENT_ID=dingxxxxxxxx
OAUTH2_DINGTALK_CLIENT_SECRET=<从密钥管理系统注入>
OAUTH2_DINGTALK_REDIRECT_URI=https://skills.example.com/login/oauth2/code/dingtalk
OAUTH2_DINGTALK_AUTHORIZE_URI=https://login.dingtalk.com
OAUTH2_DINGTALK_BASE_URI=https://api.dingtalk.com
OAUTH2_DINGTALK_DISPLAY_NAME=钉钉
```
启动和检查:
```bash
make validate-release-config
docker compose --env-file .env.release -f compose.release.yml up -d
curl -fsS http://127.0.0.1:8080/actuator/health
curl -fsS http://127.0.0.1:8080/api/v1/auth/methods
```
钉钉后台必须同时配置:
1. “钉钉登录与分享”回调 URL:与 `OAUTH2_DINGTALK_REDIRECT_URI` 完全一致。
2. `Contact.User.Read` 个人信息读取权限,并将应用发布到当前版本。
3. 服务器出口 IP:填写运行 SkillHub 后端并访问 `api.dingtalk.com` 的真实公网出口。
4. 测试账号必须属于应用所属组织,并在应用可用范围内。
#### Helm 私有化部署
推荐使用 Kubernetes Secret,不把密钥写入 `values-production.yaml`:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: skillhub-production-secret
namespace: skillhub
type: Opaque
stringData:
bootstrap-admin-password: "<固定随机密码>"
skillhub-download-anon-cookie-secret: "<至少32字符随机值>"
oauth2-dingtalk-client-id: "dingxxxxxxxx"
oauth2-dingtalk-client-secret: "<从密钥管理系统注入>"
```
`values-production.yaml` 只放非敏感配置:
```yaml
images:
registry: ghcr.io/iflytek
tag: <固定发布版本>
pullPolicy: IfNotPresent
publicBaseUrl: https://skills.example.com
session:
cookieSecure: true
ingress:
enabled: true
className: nginx
hosts:
- host: skills.example.com
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- skills.example.com
secretName: skillhub-tls
oauth2:
dingtalk:
authorizeBaseUri: https://login.dingtalk.com
apiBaseUri: https://api.dingtalk.com
redirectUri: https://skills.example.com/login/oauth2/code/dingtalk
displayName: 钉钉
```
安装或升级:
```bash
kubectl create namespace skillhub --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f skillhub-production-secret.yaml
helm upgrade --install skillhub ./charts/skillhub \
--namespace skillhub \
-f values-production.yaml \
--set existingSecret=skillhub-production-secret
```
如果使用 Chart 自己创建 Secret,也可以在受保护的 values 文件中设置
`secrets.oauth2DingtalkClientId` 和 `secrets.oauth2DingtalkClientSecret`;生产环境优先使用
External Secrets、Sealed Secrets 或其他密钥注入方案。
#### 原生 Kubernetes/Kustomize
在 `deploy/k8s/base/secret.yaml.example` 对应的 Secret 中提供:
```yaml
stringData:
oauth2-dingtalk-client-id: dingxxxxxxxx
oauth2-dingtalk-client-secret: "<从密钥管理系统注入>"
```
再通过环境变量或 overlay 设置公开地址和回调:
```yaml
env:
- name: SKILLHUB_PUBLIC_BASE_URL
value: https://skills.example.com
- name: OAUTH2_DINGTALK_REDIRECT_URI
value: https://skills.example.com/login/oauth2/code/dingtalk
```
Kubernetes 集群节点或出口网关的公网 IP 必须加入钉钉服务器出口 IP 白名单。Ingress 只负责浏览器
回调可达性,不会替代后端出站 IP 白名单。
- 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md`
## 8 OIDC 登录配置

View file

@ -197,7 +197,7 @@ A: Login entries are config-driven: `/api/v1/auth/methods` only returns registra
So there are two ways to hide one:
- Leave the matching environment variable unset (for example, omit `OAUTH2_FEISHU_CLIENT_ID`). No config file change needed.
- Or edit `application.yml` and comment out or delete the relevant registration block (`github`, `gitlab`, `feishu`) under `spring.security.oauth2.client.registration`, along with its `provider` section. Spring Boot then won't create that registration at startup.
- Or edit `application.yml` and comment out or delete the relevant registration block (`github`, `gitlab`, `feishu`, `dingtalk`) under `spring.security.oauth2.client.registration`, along with its `provider` section. Spring Boot then won't create that registration at startup.
## Q: Is SkillHub's security scanning (Skill Scanner) developed in-house by iFLYTEK? What license does it use?

View file

@ -199,7 +199,7 @@ A: 登录入口是配置驱动的:`/api/v1/auth/methods` 只返回配置了真
- 留空对应的环境变量即可(例如不设置 `OAUTH2_FEISHU_CLIENT_ID`),无需改动配置文件。
- 或修改 `application.yml`,注释/删除 `spring.security.oauth2.client.registration`
下对应的注册块(`github`、`gitlab`、`feishu`)以及对应的 `provider` 段,
下对应的注册块(`github`、`gitlab`、`feishu`、`dingtalk`)以及对应的 `provider` 段,
Spring Boot 启动时便不会创建该注册。
## Q: SkillHub 的安全扫描(Skill Scanner)是讯飞自研的吗?使用什么协议?

View file

@ -107,6 +107,27 @@ write_env "$invalid_feishu_redirect_env" "release-download-secret-32-bytes-minim
printf '%s\n' "OAUTH2_FEISHU_REDIRECT_URI=https://skillhub.example.com/login/oauth2/code/feishu?bad=1" >>"$invalid_feishu_redirect_env"
expect_fail "$invalid_feishu_redirect_env" "OAUTH2_FEISHU_REDIRECT_URI must not contain a query"
valid_dingtalk_env="$tmp/valid-dingtalk.env"
write_env "$valid_dingtalk_env" "release-download-secret-32-bytes-minimum"
cat >>"$valid_dingtalk_env" <<'EOF'
OAUTH2_DINGTALK_CLIENT_ID=ding-test
OAUTH2_DINGTALK_CLIENT_SECRET=dingtalk-test-secret
OAUTH2_DINGTALK_AUTHORIZE_URI=https://login.dingtalk.com
OAUTH2_DINGTALK_BASE_URI=https://api.dingtalk.com
OAUTH2_DINGTALK_REDIRECT_URI=https://skillhub.example.com/login/oauth2/code/dingtalk
EOF
"$SCRIPT" "$valid_dingtalk_env" >/dev/null
invalid_dingtalk_base_env="$tmp/invalid-dingtalk-base.env"
write_env "$invalid_dingtalk_base_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "OAUTH2_DINGTALK_BASE_URI=https://api.dingtalk.com/" >>"$invalid_dingtalk_base_env"
expect_fail "$invalid_dingtalk_base_env" "OAUTH2_DINGTALK_BASE_URI must not have a trailing slash"
invalid_dingtalk_redirect_env="$tmp/invalid-dingtalk-redirect.env"
write_env "$invalid_dingtalk_redirect_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "OAUTH2_DINGTALK_REDIRECT_URI=https://skillhub.example.com/callback?bad=1" >>"$invalid_dingtalk_redirect_env"
expect_fail "$invalid_dingtalk_redirect_env" "OAUTH2_DINGTALK_REDIRECT_URI must not contain a query"
disabled_builtin_skills_env="$tmp/disabled-builtin-skills.env"
write_env "$disabled_builtin_skills_env" "release-download-secret-32-bytes-minimum"
printf '%s\n' "SKILLHUB_BUILTIN_SKILLS_ENABLED=false" >>"$disabled_builtin_skills_env"
@ -292,7 +313,7 @@ expect_fail "$invalid_redis_sentinel_check_env" "SKILLHUB_REDIS_SENTINEL_CHECK_S
# An OAuth client id without its secret (or vice versa) leaves the provider half-configured:
# the login button renders but the exchange fails. Checked for every supported provider.
for provider in GITHUB GITLAB FEISHU; do
for provider in GITHUB GITLAB FEISHU DINGTALK; do
missing_oauth_secret_env="$tmp/missing-oauth-secret.env"
write_env "$missing_oauth_secret_env" "release-download-secret-32-bytes-minimum"
printf 'OAUTH2_%s_CLIENT_ID=real-client-id\n' "$provider" >>"$missing_oauth_secret_env"

View file

@ -380,7 +380,7 @@ if [ "${REDIS_BIND_ADDRESS:-127.0.0.1}" != "127.0.0.1" ]; then
warn "REDIS_BIND_ADDRESS is not 127.0.0.1; confirm Redis exposure is intended"
fi
for provider in GITHUB GITLAB FEISHU; do
for provider in GITHUB GITLAB FEISHU DINGTALK; do
eval "oauth_id=\"\${OAUTH2_${provider}_CLIENT_ID:-}\""
eval "oauth_secret=\"\${OAUTH2_${provider}_CLIENT_SECRET:-}\""
if [ -n "$oauth_id" ] && [ -z "$oauth_secret" ]; then
@ -406,6 +406,15 @@ for feishu_endpoint in OAUTH2_FEISHU_AUTHORIZATION_URI OAUTH2_FEISHU_TOKEN_URI O
fi
done
for dingtalk_endpoint in OAUTH2_DINGTALK_AUTHORIZE_URI OAUTH2_DINGTALK_BASE_URI OAUTH2_DINGTALK_REDIRECT_URI; do
eval "dingtalk_endpoint_value=\${$dingtalk_endpoint:-}"
if [ -n "$dingtalk_endpoint_value" ]; then
validate_url "$dingtalk_endpoint"
fi
done
validate_no_trailing_slash OAUTH2_DINGTALK_AUTHORIZE_URI
validate_no_trailing_slash OAUTH2_DINGTALK_BASE_URI
if [ "$errors" -gt 0 ]; then
echo "Release config validation failed: $errors error(s), $warnings warning(s)." >&2
exit 1

View file

@ -80,6 +80,21 @@ 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 is a confidential client that happens to carry its secret in a JSON body,
# which DingTalkTokenResponseClient builds. client-secret-post is the honest
# description; "none" would additionally make Spring apply PKCE, and the DingTalk token
# request sends no code_verifier to match the challenge.
client-authentication-method: client_secret_post
redirect-uri: "${OAUTH2_DINGTALK_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 +112,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

View file

@ -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.
*
* <p>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<ProviderTokenResponseClient> tokenResponseClients;
@Autowired
private List<ProviderOAuth2UserService> userServices;
@Autowired
private List<ProviderAuthorizationRequestCustomizer> authorizationCustomizers;
@Autowired
private List<OAuthClaimsExtractor> 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, "feishu");
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();
}
}

View file

@ -2,7 +2,7 @@ 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.FeishuOAuth2AccessTokenResponseClient;
import com.iflytek.skillhub.auth.oauth.DispatchingTokenResponseClient;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
@ -62,7 +62,7 @@ public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomOidcUserService customOidcUserService;
private final FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient;
private final DispatchingTokenResponseClient tokenResponseClient;
private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver;
private final OAuth2LoginSuccessHandler successHandler;
private final OAuth2LoginFailureHandler failureHandler;
@ -77,7 +77,7 @@ public class SecurityConfig {
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
CustomOidcUserService customOidcUserService,
FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient,
DispatchingTokenResponseClient tokenResponseClient,
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
OAuth2LoginSuccessHandler successHandler,
OAuth2LoginFailureHandler failureHandler,
@ -91,7 +91,7 @@ public class SecurityConfig {
@Value("${server.servlet.session.cookie.name:SESSION}") String sessionCookieName) {
this.customOAuth2UserService = customOAuth2UserService;
this.customOidcUserService = customOidcUserService;
this.feishuOAuth2AccessTokenResponseClient = feishuOAuth2AccessTokenResponseClient;
this.tokenResponseClient = tokenResponseClient;
this.authorizationRequestResolver = authorizationRequestResolver;
this.successHandler = successHandler;
this.failureHandler = failureHandler;
@ -136,8 +136,7 @@ public class SecurityConfig {
})
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
.tokenEndpoint(tokenEndpoint -> tokenEndpoint
.accessTokenResponseClient(feishuOAuth2AccessTokenResponseClient))
.tokenEndpoint(token -> token.accessTokenResponseClient(tokenResponseClient))
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
.oidcUserService(customOidcUserService))

View file

@ -0,0 +1,43 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Sends the {@code scope=openid} parameter DingTalk's authorize endpoint requires, without letting
* Spring Security classify the login as OIDC.
*
* <p>Two separate mechanisms keyed off {@code openid} have to be avoided, which is why the scope is
* written onto the URI rather than into the request's scope set:
*
* <ul>
* <li>A registration declaring {@code openid} in configuration becomes an OIDC client, and
* {@code DefaultOAuth2AuthorizationRequestResolver} attaches a {@code nonce} that DingTalk
* rejects. Hence no scope in {@code application.yml}.
* <li>{@code OAuth2LoginAuthenticationProvider.authenticate} returns null when the authorization
* request's {@code getScopes()} contains {@code openid}, handing the callback to
* {@code OidcAuthorizationCodeAuthenticationProvider}, which then fails with
* {@code invalid_id_token} because DingTalk returns no {@code id_token}. Hence the scope set
* stays empty and only the outgoing URI carries the parameter.
* </ul>
*/
@Component
public class DingTalkAuthorizationRequestCustomizer implements ProviderAuthorizationRequestCustomizer {
@Override
public String getProvider() {
return DingTalkOAuth2Constants.REGISTRATION_ID;
}
@Override
public void customize(OAuth2AuthorizationRequest.Builder builder) {
String authorizationRequestUri = UriComponentsBuilder
.fromUriString(builder.build().getAuthorizationRequestUri())
.replaceQueryParam("scope", DingTalkOAuth2Constants.AUTHORIZATION_SCOPE)
.replaceQueryParam("prompt", "consent")
.build(true)
.toUriString();
builder.authorizationRequestUri(authorizationRequestUri);
}
}

View file

@ -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.
*
* <p>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<String, Object> 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;
}
}

View file

@ -0,0 +1,21 @@
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";
private DingTalkOAuth2Constants() {
}
}

View file

@ -0,0 +1,269 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.util.Collections;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
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}.
*
* <p>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<String, Object> payload;
try {
payload = restClient.get()
.uri(userInfoUri)
.header(
DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER,
userRequest.getAccessToken().getTokenValue()
)
.exchange((request, clientResponse) -> {
if (!clientResponse.getStatusCode().is2xxSuccessful()) {
SafeErrorSummary summary = readSafeErrorSummary(clientResponse.getBody());
log.warn(
"DingTalk user info returned HTTP {}; code={}, requiredScopes={}, requestId={}",
clientResponse.getStatusCode().value(),
summary.code(),
summary.requiredScopes(),
summary.requestId());
throw new IOException(
"DingTalk user info returned HTTP " + clientResponse.getStatusCode().value());
}
return 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<String, Object> 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<>() {
});
}
/** Extracts provider diagnostics without logging tokens, messages, or the upstream body. */
private static SafeErrorSummary readSafeErrorSummary(InputStream body) {
try {
byte[] bytes = body.readNBytes(MAX_RESPONSE_BYTES + 1);
if (bytes.length > MAX_RESPONSE_BYTES) {
return SafeErrorSummary.UNKNOWN;
}
JsonNode root = OBJECT_MAPPER.readTree(bytes);
if (root == null) {
return SafeErrorSummary.UNKNOWN;
}
String code = text(findNode(root, Set.of("code")));
String requestId = text(findNode(root, Set.of("requestid")));
JsonNode data = findNode(root, Set.of("data"));
if (data != null && data.isTextual()) {
try {
JsonNode nested = OBJECT_MAPPER.readTree(data.asText());
if (nested != null) {
root = nested;
}
} catch (Exception ignored) {
// Keep the outer diagnostic fields when Data is not JSON.
}
}
code = valueOrUnknown(code);
requestId = valueOrUnknown(requestId != null ? requestId : text(findNode(root, Set.of("requestid"))));
JsonNode scopes = findNode(root, Set.of("requiredscopes"));
String requiredScopes = scopes != null && scopes.isArray()
? String.join(",", textValues(scopes))
: "-";
return new SafeErrorSummary(code, requiredScopes, requestId);
} catch (Exception ignored) {
return SafeErrorSummary.UNKNOWN;
}
}
private static JsonNode findNode(JsonNode node, Set<String> names) {
if (node.isObject()) {
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
if (names.contains(field.getKey().toLowerCase())) {
return field.getValue();
}
JsonNode nested = findNode(field.getValue(), names);
if (nested != null) {
return nested;
}
}
} else if (node.isArray()) {
for (JsonNode child : node) {
JsonNode nested = findNode(child, names);
if (nested != null) {
return nested;
}
}
}
return null;
}
private static List<String> textValues(JsonNode array) {
List<String> values = new ArrayList<>();
array.forEach(value -> {
if (value.isTextual() && !value.asText().isBlank()) {
values.add(value.asText());
}
});
return values;
}
private static String text(JsonNode node) {
return node != null && node.isValueNode() ? node.asText() : null;
}
private static String valueOrUnknown(String value) {
return value == null || value.isBlank() ? "-" : value;
}
private record SafeErrorSummary(String code, String requiredScopes, String requestId) {
private static final SafeErrorSummary UNKNOWN = new SafeErrorSummary("-", "-", "-");
}
/**
* 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<String, Object> normalize(Map<String, Object> payload) {
Map<String, Object> 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)) {
// A reachable failure: DingTalk omits unionId for some app configurations, and the
// operator needs to see why every login is being rejected. The claim name is a
// constant, so this records nothing about the user.
log.warn(
"DingTalk user info response omitted {}; login rejected",
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<String, Object> target,
Map<String, Object> source,
String key
) {
Object value = source.get(key);
if (value != null && !String.valueOf(value).isBlank()) {
target.put(key, value);
}
}
}

View file

@ -0,0 +1,197 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.ClientHttpResponse;
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 (钉钉).
*
* <p>DingTalk requires a JSON body for token exchange instead of the standard
* form-urlencoded format. This client adapts the request accordingly.
*
* <p>Request body format:
* <pre>{ "clientId": "...", "clientSecret": "...", "code": "...", "grantType": "authorization_code" }</pre>
*/
@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;
}
/** A DingTalk token payload is a few hundred bytes; this only stops an unbounded body. */
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
/** Package-visible so a test can exercise the production template, size cap included. */
static RestTemplate buildRestTemplate() {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(5));
factory.setReadTimeout(Duration.ofSeconds(10));
RestTemplate template = new RestTemplate(factory);
// The timeouts bound how long the exchange may take; this bounds how much it may return, so
// a misconfigured or hostile token endpoint cannot stream an unbounded body into the parser.
// The userinfo client applies the same cap.
template.getInterceptors().add((request, body, execution) -> {
ClientHttpResponse response = execution.execute(request, body);
byte[] bytes = response.getBody().readNBytes(MAX_RESPONSE_BYTES + 1);
if (bytes.length > MAX_RESPONSE_BYTES) {
response.close();
throw new IOException("DingTalk token response exceeds " + MAX_RESPONSE_BYTES + " bytes");
}
return new BoundedClientHttpResponse(response, bytes);
});
return template;
}
/** Replays the already-read, size-checked body so the converters can still parse it. */
private record BoundedClientHttpResponse(ClientHttpResponse delegate, byte[] body)
implements ClientHttpResponse {
@Override
public HttpStatusCode getStatusCode() throws IOException {
return delegate.getStatusCode();
}
@Override
public String getStatusText() throws IOException {
return delegate.getStatusText();
}
@Override
public void close() {
delegate.close();
}
@Override
public InputStream getBody() {
return new ByteArrayInputStream(body);
}
@Override
public HttpHeaders getHeaders() {
return delegate.getHeaders();
}
}
@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<String, Object> tokenRequest = Map.of(
"clientId", clientId,
"clientSecret", clientSecret,
"code", code,
"grantType", "authorization_code"
);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> 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<String, Object> 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));
}
}

View file

@ -0,0 +1,48 @@
package com.iflytek.skillhub.auth.oauth;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.stereotype.Component;
/**
* Routes the authorization-code token exchange to a {@link ProviderTokenResponseClient} when one
* claims the registration, and to the standard Spring client otherwise.
*
* <p>Spring's {@code tokenEndpoint} accepts a single client, so per-provider exchange needs one
* dispatcher rather than a branch inside the security configuration.
*/
@Component
public class DispatchingTokenResponseClient
implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
private final Map<String, ProviderTokenResponseClient> overrides;
private final OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> delegate;
@Autowired
public DispatchingTokenResponseClient(List<ProviderTokenResponseClient> providerClients) {
this(providerClients, new DefaultAuthorizationCodeTokenResponseClient());
}
DispatchingTokenResponseClient(
List<ProviderTokenResponseClient> providerClients,
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> delegate
) {
this.overrides = providerClients.stream()
.collect(Collectors.toMap(ProviderTokenResponseClient::getProvider, Function.identity()));
this.delegate = delegate;
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest request) {
String registrationId = request.getClientRegistration().getRegistrationId();
ProviderTokenResponseClient override = overrides.get(registrationId);
return (override != null ? override : delegate).getTokenResponse(request);
}
}

View file

@ -33,7 +33,7 @@ import org.springframework.web.client.RestClient;
*/
@Component
public class FeishuOAuth2AccessTokenResponseClient
implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
implements ProviderTokenResponseClient {
private static final Logger log = LoggerFactory.getLogger(FeishuOAuth2AccessTokenResponseClient.class);
private static final String FEISHU_PROVIDER = "feishu";
@ -78,6 +78,11 @@ public class FeishuOAuth2AccessTokenResponseClient
this.protocolVersion = normalizeProtocolVersion(protocolVersion);
}
@Override
public String getProvider() {
return FEISHU_PROVIDER;
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(
OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
/**
* Strategy interface for provider-specific authorization request tweaks, for providers whose
* authorize endpoint deviates from the standard parameter contract.
*
* <p>The token and userinfo counterparts are {@link ProviderTokenResponseClient} and
* {@link ProviderOAuth2UserService}.
*/
public interface ProviderAuthorizationRequestCustomizer {
String getProvider();
void customize(OAuth2AuthorizationRequest.Builder builder);
}

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
/**
* Strategy interface for provider-specific token exchange. Implementations override the default
* exchange for providers whose token endpoints deviate from the standard form-urlencoded contract.
*
* <p>The userinfo counterpart is {@link ProviderOAuth2UserService}.
*/
public interface ProviderTokenResponseClient
extends OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
String getProvider();
}

View file

@ -1,11 +1,17 @@
package com.iflytek.skillhub.auth.oauth;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.stereotype.Component;
/**
@ -21,13 +27,36 @@ public class SkillHubOAuth2AuthorizationRequestResolver
private final DefaultOAuth2AuthorizationRequestResolver delegate;
private final OAuthLoginFlowService oauthLoginFlowService;
public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository,
OAuthLoginFlowService oauthLoginFlowService) {
SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository,
OAuthLoginFlowService oauthLoginFlowService) {
this(clientRegistrationRepository, oauthLoginFlowService, List.of());
}
@Autowired
public SkillHubOAuth2AuthorizationRequestResolver(
ClientRegistrationRepository clientRegistrationRepository,
OAuthLoginFlowService oauthLoginFlowService,
List<ProviderAuthorizationRequestCustomizer> customizers) {
this.delegate = new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
"/oauth2/authorization"
);
this.oauthLoginFlowService = oauthLoginFlowService;
Map<String, ProviderAuthorizationRequestCustomizer> byProvider = customizers.stream()
.collect(Collectors.toMap(
ProviderAuthorizationRequestCustomizer::getProvider,
Function.identity()
));
// Spring resolves the registration id into the builder attributes, so one customizer hook
// can dispatch per provider instead of this class knowing about any of them.
this.delegate.setAuthorizationRequestCustomizer(builder -> {
OAuth2AuthorizationRequest probe = builder.build();
String registrationId = probe.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
ProviderAuthorizationRequestCustomizer customizer = byProvider.get(registrationId);
if (customizer != null) {
customizer.customize(builder);
}
});
}
@Override

View file

@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> attrs) {
return new OAuth2User() {
@Override
public Map<String, Object> getAttributes() {
return attrs;
}
@Override
public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority>
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.CLIENT_SECRET_POST)
.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);
}
}

View file

@ -0,0 +1,212 @@
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 static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import java.time.Instant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.http.HttpStatus;
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;
import org.slf4j.LoggerFactory;
class DingTalkOAuth2UserServiceTest {
private final Logger logger = (Logger) LoggerFactory.getLogger(DingTalkOAuth2UserService.class);
private ListAppender<ILoggingEvent> appender;
@AfterEach
void tearDown() {
if (appender != null) {
logger.detachAppender(appender);
appender.stop();
}
}
@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_rejectsNonSuccessfulHttpStatusWithoutExposingBody() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
.andRespond(withStatus(HttpStatus.FORBIDDEN)
.body("access denied for token-123")
.contentType(MediaType.APPLICATION_JSON));
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
assertThatThrownBy(() -> service.loadUser(userRequest()))
.isInstanceOf(OAuth2AuthenticationException.class)
.satisfies(ex -> {
var error = ((OAuth2AuthenticationException) ex).getError();
assertThat(error.getErrorCode()).isEqualTo("dingtalk_userinfo_error");
assertThat(error.getDescription()).doesNotContain("token-123", "access denied");
});
server.verify();
}
@Test
void loadUser_logsSafeProviderDiagnosticsWithoutUpstreamMessageOrToken() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me"))
.andRespond(withStatus(HttpStatus.FORBIDDEN)
.body("{\"Code\":\"Forbidden.AccessDenied.AccessTokenPermissionDenied\","
+ "\"Data\":\"{\\\"AccessDeniedDetail\\\":{\\\"requiredScopes\\\":[\\\"Contact.User.Read\\\"]},"
+ "\\\"RequestId\\\":\\\"req-123\\\"}\","
+ "\"Message\":\"secret upstream message token-123\"}")
.contentType(MediaType.APPLICATION_JSON));
attachAppender();
DingTalkOAuth2UserService service = new DingTalkOAuth2UserService(builder);
assertThatThrownBy(() -> service.loadUser(userRequest()))
.isInstanceOf(OAuth2AuthenticationException.class);
assertThat(appender.list).extracting(ILoggingEvent::getFormattedMessage)
.anySatisfy(message -> assertThat(message)
.contains("code=Forbidden.AccessDenied.AccessTokenPermissionDenied")
.contains("requiredScopes=Contact.User.Read")
.contains("requestId=req-123")
.doesNotContain("secret upstream message", "token-123"));
server.verify();
}
private void attachAppender() {
logger.setLevel(Level.INFO);
appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
}
@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.CLIENT_SECRET_POST)
.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);
}
}

View file

@ -0,0 +1,223 @@
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)
);
}
@Test
void getTokenResponse_rejectsOversizedResponseBody() {
// Uses the production template so the size-cap interceptor is in play; the tests above
// inject a bare RestTemplate and therefore cannot reach it.
RestTemplate productionTemplate = DingTalkTokenResponseClient.buildRestTemplate();
MockRestServiceServer server = MockRestServiceServer.createServer(productionTemplate);
// 64 KB cap; pad a structurally valid token payload past it so the size check fires.
String padding = "x".repeat(70 * 1024);
server.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken"))
.andRespond(withSuccess(
"{\"accessToken\":\"" + padding + "\",\"expireIn\":7200}",
MediaType.APPLICATION_JSON
));
DingTalkTokenResponseClient boundedClient = new DingTalkTokenResponseClient(productionTemplate);
assertThatThrownBy(() -> boundedClient.getTokenResponse(authorizationCodeGrantRequest()))
.isInstanceOf(OAuth2AuthenticationException.class)
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode())
.isEqualTo("token_exchange_io_error"));
server.verify();
}
}

View file

@ -0,0 +1,99 @@
package com.iflytek.skillhub.auth.oauth;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
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.ClientAuthenticationMethod;
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;
class DispatchingTokenResponseClientTest {
@Test
void routesToProviderOverrideWhenOneClaimsTheRegistration() {
OAuth2AccessTokenResponse overrideResponse = response("from-override");
OAuth2AccessTokenResponse defaultResponse = response("from-default");
DispatchingTokenResponseClient client = new DispatchingTokenResponseClient(
List.of(stubProvider("dingtalk", overrideResponse)),
request -> defaultResponse
);
OAuth2AccessTokenResponse result = client.getTokenResponse(grantRequest("dingtalk"));
assertThat(result.getAccessToken().getTokenValue()).isEqualTo("from-override");
}
@Test
void fallsBackToDefaultClientForUnclaimedRegistrations() {
OAuth2AccessTokenResponse overrideResponse = response("from-override");
OAuth2AccessTokenResponse defaultResponse = response("from-default");
DispatchingTokenResponseClient client = new DispatchingTokenResponseClient(
List.of(stubProvider("dingtalk", overrideResponse)),
request -> defaultResponse
);
// GitHub must keep the standard exchange even while a DingTalk override is registered.
OAuth2AccessTokenResponse result = client.getTokenResponse(grantRequest("github"));
assertThat(result.getAccessToken().getTokenValue()).isEqualTo("from-default");
}
private static ProviderTokenResponseClient stubProvider(
String provider,
OAuth2AccessTokenResponse response
) {
return new ProviderTokenResponseClient() {
@Override
public String getProvider() {
return provider;
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest request) {
return response;
}
};
}
private static OAuth2AccessTokenResponse response(String tokenValue) {
return OAuth2AccessTokenResponse.withToken(tokenValue)
.tokenType(org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType.BEARER)
.expiresIn(3600)
.build();
}
private static OAuth2AuthorizationCodeGrantRequest grantRequest(String registrationId) {
ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId)
.clientId("client")
.clientSecret("secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
.authorizationUri("https://provider.example/authorize")
.tokenUri("https://provider.example/token")
.userInfoUri("https://provider.example/me")
.userNameAttributeName("id")
.build();
OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
.authorizationUri("https://provider.example/authorize")
.clientId("client")
.redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
.state("state-1")
.build();
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success("code-1")
.redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId)
.state("state-1")
.build();
return new OAuth2AuthorizationCodeGrantRequest(
registration,
new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse)
);
}
}

View file

@ -84,4 +84,102 @@ class OAuth2AuthorizationRequestResolverTest {
assertThat(session).isNotNull();
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
}
@Test
void resolve_sendsDingTalkScopeOnTheUriButKeepsTheRequestNonOidc() {
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 on the wire.
assertThat(authorizationRequest.getAuthorizationRequestUri()).contains("scope=openid");
assertThat(authorizationRequest.getAuthorizationRequestUri()).contains("prompt=consent");
// But getScopes() must stay empty. OAuth2LoginAuthenticationProvider.authenticate returns
// null when the authorization request's scopes contain "openid", which hands the callback to
// OidcAuthorizationCodeAuthenticationProvider; that then fails with invalid_id_token because
// DingTalk returns no id_token, and neither the token client nor the user service is reached.
assertThat(authorizationRequest.getScopes()).doesNotContain("openid");
// And no nonce: a registration declaring openid in configuration would get one attached,
// which DingTalk also rejects.
assertThat(authorizationRequest.getAdditionalParameters()).doesNotContainKey("nonce");
assertThat(authorizationRequest.getAttributes()).doesNotContainKey("nonce");
assertThat(authorizationRequest.getAuthorizationRequestUri()).doesNotContain("nonce=");
// client-secret-post rather than none, so Spring does not apply PKCE. The DingTalk token
// request sends no code_verifier, so a challenge on the authorize URI could not be answered.
assertThat(authorizationRequest.getAuthorizationRequestUri()).doesNotContain("code_challenge");
}
@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.CLIENT_SECRET_POST)
.clientName("钉钉")
.build();
}
}

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path fill="#118EE9" d="M573.7 252.5C422.5 197.4 201.3 96.7 201.3 96.7c-15.7-4.1-17.9 11.1-17.9 11.1c-5 61.1 33.6 160.5 53.6 182.8c19.9 22.3 319.1 113.7 319.1 113.7S326 357.9 270.5 341.9c-55.6-16-37.9 17.8-37.9 17.8c11.4 61.7 64.9 131.8 107.2 138.4c42.2 6.6 220.1 4 220.1 4s-35.5 4.1-93.2 11.9c-42.7 5.8-97 12.5-111.1 17.8c-33.1 12.5 24 62.6 24 62.6c84.7 76.8 129.7 50.5 129.7 50.5c33.3-10.7 61.4-18.5 85.2-24.2L565 743.1h84.6L603 928l205.3-271.9H700.8l22.3-38.7c.3.5.4.8.4.8S799.8 496.1 829 433.8l.6-1h-.1c5-10.8 8.6-19.7 10-25.8c17-71.3-114.5-99.4-265.8-154.5"/>
</svg>

After

Width:  |  Height:  |  Size: 639 B