mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-05 02:41:49 +00:00
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:
commit
8498fd047f
35 changed files with 1932 additions and 21 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 模型名称 |
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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`、远程调用要有超时与响应大小上限、
|
||||
|
|
|
|||
|
|
@ -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 登录配置
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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)是讯飞自研的吗?使用什么协议?
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
web/public/dingtalk-logo.svg
Normal file
3
web/public/dingtalk-logo.svg
Normal 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 |
Loading…
Add table
Reference in a new issue