mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-10 03:27:54 +00:00
feat(auth): add DingTalk OAuth2 adapter to unified identity core (#677)
Merged into big-main after local validation, callback integration coverage, CI, DCO/CLA, and review. Main remains untouched; Hong Kong remote validation follows on the exact integration SHA.
This commit is contained in:
commit
7b33660634
36 changed files with 2338 additions and 7 deletions
|
|
@ -116,6 +116,19 @@ OAUTH2_GITLAB_CLIENT_SECRET=
|
|||
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
|
||||
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
|
||||
|
||||
# Optional: native DingTalk OAuth2 browser login. The adapter remains hidden
|
||||
# unless enabled and both client credentials plus a stable operator-owned
|
||||
# authority are present. The authority is a local identity namespace, not a
|
||||
# URL and never comes from DingTalk claims; keep it unchanged after binding.
|
||||
SKILLHUB_AUTH_DINGTALK_ENABLED=false
|
||||
SKILLHUB_AUTH_DINGTALK_AUTHORITY=dingtalk.corp
|
||||
SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT=PT5S
|
||||
SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT=PT10S
|
||||
SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES=1048576
|
||||
OAUTH2_DINGTALK_CLIENT_ID=
|
||||
OAUTH2_DINGTALK_CLIENT_SECRET=
|
||||
OAUTH2_DINGTALK_DISPLAY_NAME=DingTalk
|
||||
|
||||
# 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.
|
||||
|
|
|
|||
5
Makefile
5
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
|
||||
.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web dingtalk-smoke docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
|
||||
|
||||
DEV_DIR := .dev
|
||||
DEV_SERVER_PID := $(DEV_DIR)/server.pid
|
||||
|
|
@ -147,6 +147,9 @@ dev-server-restart: ## 重启后端开发服务器
|
|||
namespace-smoke: ## 运行命名空间工作流 smoke test
|
||||
./scripts/namespace-smoke-test.sh $(DEV_API_URL)
|
||||
|
||||
dingtalk-smoke: ## 验证 DingTalk provider 目录与 disabled route(默认要求关闭)
|
||||
./scripts/dingtalk-smoke-test.sh $(DEV_API_URL)
|
||||
|
||||
dev-down: ## 停止本地开发环境(含 skill-scanner)
|
||||
$(DEV_COMPOSE) down --remove-orphans
|
||||
|
||||
|
|
|
|||
|
|
@ -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 时 | DingTalk OAuth2 Client ID |
|
||||
| `oauth2-dingtalk-client-secret` | 启用 DingTalk 时 | DingTalk OAuth2 Client Secret |
|
||||
| `skill-scanner-llm-api-key` | 否 | Scanner LLM API Key |
|
||||
| `skill-scanner-llm-base-url` | 否 | Scanner 自定义 LLM API 地址 |
|
||||
| `skill-scanner-llm-model` | 否 | Scanner LLM 模型名称 |
|
||||
|
|
@ -185,6 +187,28 @@ auth:
|
|||
普通 CAS email attribute 只按 asserted 处理。完整说明见
|
||||
[`docs/23-cas-integration.md`](../../docs/23-cas-integration.md)。
|
||||
|
||||
### DingTalk OAuth2
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
dingtalk:
|
||||
enabled: true
|
||||
displayName: DingTalk
|
||||
authority: dingtalk.corp
|
||||
connectTimeout: PT5S
|
||||
readTimeout: PT10S
|
||||
maxResponseBytes: 1048576
|
||||
secrets:
|
||||
oauth2DingtalkClientId: your-client-id
|
||||
oauth2DingtalkClientSecret: your-client-secret
|
||||
```
|
||||
|
||||
使用 `existingSecret` 时,该 Secret 必须提供
|
||||
`oauth2-dingtalk-client-id` 和 `oauth2-dingtalk-client-secret` 两个 key。authority 是
|
||||
SkillHub 维护的稳定身份命名空间,不是 URL;产生身份绑定后不得修改。DingTalk
|
||||
`unionId` 是唯一 primary subject,普通 email 不能用于自动绑定。完整说明见
|
||||
[`docs/26-dingtalk-unified-identity-integration.md`](../../docs/26-dingtalk-unified-identity-integration.md)。
|
||||
|
||||
### 副本数配置
|
||||
|
||||
| 参数 | 描述 | 默认值 |
|
||||
|
|
|
|||
|
|
@ -98,4 +98,10 @@ data:
|
|||
auth-cas-attribute-display-name: {{ .Values.auth.cas.attributes.displayName | quote }}
|
||||
auth-cas-attribute-email: {{ .Values.auth.cas.attributes.email | quote }}
|
||||
auth-cas-attribute-avatar-url: {{ .Values.auth.cas.attributes.avatarUrl | quote }}
|
||||
auth-dingtalk-enabled: {{ .Values.auth.dingtalk.enabled | quote }}
|
||||
auth-dingtalk-display-name: {{ .Values.auth.dingtalk.displayName | quote }}
|
||||
auth-dingtalk-authority: {{ .Values.auth.dingtalk.authority | quote }}
|
||||
auth-dingtalk-connect-timeout: {{ .Values.auth.dingtalk.connectTimeout | quote }}
|
||||
auth-dingtalk-read-timeout: {{ .Values.auth.dingtalk.readTimeout | quote }}
|
||||
auth-dingtalk-max-response-bytes: {{ .Values.auth.dingtalk.maxResponseBytes | quote }}
|
||||
builtin-skills-enabled: {{ .Values.builtinSkills.enabled | quote }}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,14 @@ stringData:
|
|||
oauth2-github-client-secret: {{ .Values.secrets.oauth2GithubClientSecret | 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 }}
|
||||
|
||||
# LDAP service-account password (optional unless LDAP is enabled)
|
||||
{{- if .Values.secrets.ldapBindPassword }}
|
||||
ldap-bind-password: {{ .Values.secrets.ldapBindPassword | quote }}
|
||||
|
|
|
|||
|
|
@ -522,6 +522,36 @@ spec:
|
|||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-cas-attribute-avatar-url
|
||||
- name: SKILLHUB_AUTH_DINGTALK_ENABLED
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-dingtalk-enabled
|
||||
- name: OAUTH2_DINGTALK_DISPLAY_NAME
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-dingtalk-display-name
|
||||
- name: SKILLHUB_AUTH_DINGTALK_AUTHORITY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-dingtalk-authority
|
||||
- name: SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-dingtalk-connect-timeout
|
||||
- name: SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-dingtalk-read-timeout
|
||||
- name: SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "skillhub.fullname" . }}-config
|
||||
key: auth-dingtalk-max-response-bytes
|
||||
- name: SKILLHUB_BUILTIN_SKILLS_ENABLED
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
|
|
@ -579,6 +609,18 @@ spec:
|
|||
name: {{ include "skillhub.secretName" . }}
|
||||
key: oauth2-github-client-secret
|
||||
optional: true
|
||||
- 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
|
||||
|
||||
{{- if .Values.server.javaOpts }}
|
||||
- name: JAVA_OPTS
|
||||
|
|
|
|||
|
|
@ -62,6 +62,15 @@
|
|||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if .Values.auth.dingtalk.enabled -}}
|
||||
{{- if and (not .Values.existingSecret) (not .Values.secrets.oauth2DingtalkClientId) -}}
|
||||
{{- fail "auth.dingtalk.enabled=true requires secrets.oauth2DingtalkClientId or existingSecret key oauth2-dingtalk-client-id" -}}
|
||||
{{- end -}}
|
||||
{{- if and (not .Values.existingSecret) (not .Values.secrets.oauth2DingtalkClientSecret) -}}
|
||||
{{- fail "auth.dingtalk.enabled=true requires secrets.oauth2DingtalkClientSecret or existingSecret key oauth2-dingtalk-client-secret" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if and .Values.ingress.enabled (not .Values.server.service.enabled) -}}
|
||||
{{- fail "ingress.enabled=true requires server.service.enabled=true" -}}
|
||||
{{- end -}}
|
||||
|
|
|
|||
|
|
@ -219,6 +219,20 @@ if grep -Fq 'ldap-bind-password:' "$TMP_DIR/default.yaml"; then
|
|||
fail "disabled LDAP must not render a bind password"
|
||||
fi
|
||||
|
||||
render dingtalk "$CHART_DIR" \
|
||||
--set auth.dingtalk.enabled=true \
|
||||
--set-string secrets.oauth2DingtalkClientId=dingtalk-client-id \
|
||||
--set-string secrets.oauth2DingtalkClientSecret=dingtalk-client-secret \
|
||||
--show-only templates/configmap.yaml \
|
||||
--show-only templates/secret.yaml \
|
||||
--show-only templates/server-deployment.yaml >"$TMP_DIR/dingtalk.yaml"
|
||||
grep -Fq 'auth-dingtalk-enabled: "true"' "$TMP_DIR/dingtalk.yaml"
|
||||
grep -Fq 'auth-dingtalk-display-name: "DingTalk"' "$TMP_DIR/dingtalk.yaml"
|
||||
grep -Fq 'auth-dingtalk-authority: "dingtalk.corp"' "$TMP_DIR/dingtalk.yaml"
|
||||
grep -Fq 'oauth2-dingtalk-client-id: "dingtalk-client-id"' "$TMP_DIR/dingtalk.yaml"
|
||||
grep -Fq 'oauth2-dingtalk-client-secret: "dingtalk-client-secret"' "$TMP_DIR/dingtalk.yaml"
|
||||
grep -Fq 'name: OAUTH2_DINGTALK_CLIENT_SECRET' "$TMP_DIR/dingtalk.yaml"
|
||||
|
||||
render tls "$CHART_DIR" \
|
||||
--set ingress.enabled=true \
|
||||
--set-json 'ingress.tls=[{"hosts":["skills.example.com"],"secretName":"skills-tls"}]' \
|
||||
|
|
@ -317,6 +331,17 @@ assert_rejected cas-with-wrong-callback \
|
|||
--set auth.cas.enabled=true \
|
||||
--set auth.cas.serverUrl=https://cas.example.com/cas \
|
||||
--set auth.cas.serviceUrl=https://skills.example.com/api/v1/auth/cas/other/callback
|
||||
assert_rejected dingtalk-without-client-id \
|
||||
--set auth.dingtalk.enabled=true \
|
||||
--set-string secrets.oauth2DingtalkClientSecret=dingtalk-client-secret
|
||||
assert_rejected dingtalk-without-client-secret \
|
||||
--set auth.dingtalk.enabled=true \
|
||||
--set-string secrets.oauth2DingtalkClientId=dingtalk-client-id
|
||||
assert_rejected dingtalk-with-invalid-authority \
|
||||
--set auth.dingtalk.enabled=true \
|
||||
--set-string auth.dingtalk.authority=https://dingtalk.example.com \
|
||||
--set-string secrets.oauth2DingtalkClientId=dingtalk-client-id \
|
||||
--set-string secrets.oauth2DingtalkClientSecret=dingtalk-client-secret
|
||||
assert_rejected ingress-without-server-service --set ingress.enabled=true --set server.service.enabled=false
|
||||
assert_rejected ingress-without-web-service --set ingress.enabled=true --set web.service.enabled=false
|
||||
assert_rejected multi-without-rwx --set server.replicaCount=2
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
"auth": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["direct", "accountMerge", "ldap", "cas"],
|
||||
"required": ["direct", "accountMerge", "ldap", "cas", "dingtalk"],
|
||||
"properties": {
|
||||
"direct": {
|
||||
"type": "object",
|
||||
|
|
@ -100,6 +100,19 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dingtalk": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "displayName", "authority", "connectTimeout", "readTimeout", "maxResponseBytes"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"displayName": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"authority": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._:-]{0,127}$" },
|
||||
"connectTimeout": { "type": "string", "minLength": 1 },
|
||||
"readTimeout": { "type": "string", "minLength": 1 },
|
||||
"maxResponseBytes": { "type": "integer", "minimum": 1024, "maximum": 1048576 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -230,6 +243,8 @@
|
|||
"downloadAnonCookieSecret": { "type": "string" },
|
||||
"oauth2GithubClientId": { "type": "string" },
|
||||
"oauth2GithubClientSecret": { "type": "string" },
|
||||
"oauth2DingtalkClientId": { "type": "string" },
|
||||
"oauth2DingtalkClientSecret": { "type": "string" },
|
||||
"ldapBindPassword": { "type": "string" },
|
||||
"scannerLlmApiKey": { "type": "string" },
|
||||
"scannerLlmBaseUrl": { "type": "string" },
|
||||
|
|
|
|||
|
|
@ -68,6 +68,13 @@ auth:
|
|||
displayName: displayName
|
||||
email: mail
|
||||
avatarUrl: ""
|
||||
dingtalk:
|
||||
enabled: false
|
||||
displayName: DingTalk
|
||||
authority: dingtalk.corp
|
||||
connectTimeout: PT5S
|
||||
readTimeout: PT10S
|
||||
maxResponseBytes: 1048576
|
||||
|
||||
builtinSkills:
|
||||
enabled: true
|
||||
|
|
@ -143,6 +150,8 @@ secrets:
|
|||
downloadAnonCookieSecret: ""
|
||||
oauth2GithubClientId: ""
|
||||
oauth2GithubClientSecret: ""
|
||||
oauth2DingtalkClientId: ""
|
||||
oauth2DingtalkClientSecret: ""
|
||||
ldapBindPassword: ""
|
||||
scannerLlmApiKey: ""
|
||||
scannerLlmBaseUrl: ""
|
||||
|
|
|
|||
|
|
@ -136,6 +136,11 @@ services:
|
|||
SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_DISPLAY_NAME:-}
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_EMAIL:-}
|
||||
SKILLHUB_AUTH_CAS_ATTRIBUTES_AVATAR_URL: ${SKILLHUB_AUTH_CAS_ATTRIBUTES_AVATAR_URL:-}
|
||||
SKILLHUB_AUTH_DINGTALK_ENABLED: ${SKILLHUB_AUTH_DINGTALK_ENABLED:-false}
|
||||
SKILLHUB_AUTH_DINGTALK_AUTHORITY: ${SKILLHUB_AUTH_DINGTALK_AUTHORITY:-}
|
||||
SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT: ${SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT:-PT5S}
|
||||
SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT: ${SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT:-PT10S}
|
||||
SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES: ${SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES:-1048576}
|
||||
SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none}
|
||||
SKILLHUB_LOG_FORMAT: ${SKILLHUB_LOG_FORMAT:-json}
|
||||
SKILLHUB_LOG_ASYNC_QUEUE_SIZE: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:-1024}
|
||||
|
|
@ -153,6 +158,9 @@ services:
|
|||
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin@skillhub.local}
|
||||
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder}
|
||||
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder}
|
||||
OAUTH2_DINGTALK_CLIENT_ID: ${OAUTH2_DINGTALK_CLIENT_ID:-local-placeholder}
|
||||
OAUTH2_DINGTALK_CLIENT_SECRET: ${OAUTH2_DINGTALK_CLIENT_SECRET:-local-placeholder}
|
||||
OAUTH2_DINGTALK_DISPLAY_NAME: ${OAUTH2_DINGTALK_DISPLAY_NAME:-DingTalk}
|
||||
SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-}
|
||||
SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25}
|
||||
SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-}
|
||||
|
|
|
|||
|
|
@ -398,6 +398,38 @@ spec:
|
|||
name: skillhub-config
|
||||
key: auth-cas-attribute-avatar-url
|
||||
|
||||
# DingTalk native OAuth2 browser login
|
||||
- name: SKILLHUB_AUTH_DINGTALK_ENABLED
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-dingtalk-enabled
|
||||
- name: OAUTH2_DINGTALK_DISPLAY_NAME
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-dingtalk-display-name
|
||||
- name: SKILLHUB_AUTH_DINGTALK_AUTHORITY
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-dingtalk-authority
|
||||
- name: SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-dingtalk-connect-timeout
|
||||
- name: SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-dingtalk-read-timeout
|
||||
- name: SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: skillhub-config
|
||||
key: auth-dingtalk-max-response-bytes
|
||||
|
||||
# Bootstrap Admin (non-sensitive from ConfigMap)
|
||||
- name: BOOTSTRAP_ADMIN_ENABLED
|
||||
valueFrom:
|
||||
|
|
@ -447,6 +479,20 @@ spec:
|
|||
key: oauth2-github-client-secret
|
||||
optional: true
|
||||
|
||||
# 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
|
||||
|
||||
volumeMounts:
|
||||
- name: skillhub-storage
|
||||
mountPath: /var/lib/skillhub/storage
|
||||
|
|
|
|||
|
|
@ -100,6 +100,15 @@ data:
|
|||
auth-cas-attribute-display-name: displayName
|
||||
auth-cas-attribute-email: mail
|
||||
auth-cas-attribute-avatar-url: ""
|
||||
|
||||
# DingTalk native OAuth2 browser login(默认关闭)
|
||||
# authority 是维护者定义的稳定身份命名空间,不是上游 URL。
|
||||
auth-dingtalk-enabled: "false"
|
||||
auth-dingtalk-display-name: DingTalk
|
||||
auth-dingtalk-authority: dingtalk.corp
|
||||
auth-dingtalk-connect-timeout: PT5S
|
||||
auth-dingtalk-read-timeout: PT10S
|
||||
auth-dingtalk-max-response-bytes: "1048576"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ stringData:
|
|||
oauth2-github-client-id: ""
|
||||
oauth2-github-client-secret: ""
|
||||
|
||||
# DingTalk OAuth2(启用 auth-dingtalk-enabled 时必填)
|
||||
oauth2-dingtalk-client-id: ""
|
||||
oauth2-dingtalk-client-secret: ""
|
||||
|
||||
# LDAP service account 密码(启用 LDAP/AD 时必填)
|
||||
ldap-bind-password: ""
|
||||
|
||||
|
|
|
|||
221
docs/26-dingtalk-unified-identity-integration.md
Normal file
221
docs/26-dingtalk-unified-identity-integration.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# DingTalk 与统一身份核心集成设计
|
||||
|
||||
> 适用范围:维护者 Issue [#675](https://github.com/iflytek/skillhub/issues/675)。
|
||||
>
|
||||
> 本文件描述 DingTalk Native OAuth2 Browser Adapter 的协议、信任边界、部署和验收。
|
||||
> 它不替代统一身份核心的领域规则;账号、身份绑定、账号合并、角色和 Session 的唯一
|
||||
> 入口仍是 `skillhub-auth` 中现有的 Registry 与 Identity Services。
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 支持 DingTalk 官方 authorization-code OAuth2 浏览器登录。
|
||||
- 将 DingTalk 响应转换成协议无关的 `ProviderAuthenticationResult`。
|
||||
- 让同一个 DingTalk 身份经过现有 Provider Registry、`ExternalIdentityLoginService`、
|
||||
Identity Link 和 Account Merge 流程解析到同一个平台账号。
|
||||
- 在启动和运行时都对 Provider Instance、固定 endpoint、响应大小、超时和稳定 subject
|
||||
实施 fail-closed 校验。
|
||||
- 让 provider disabled/incomplete 时从登录目录隐藏,并且在隐藏状态不访问 DingTalk。
|
||||
|
||||
### 1.2 非目标
|
||||
|
||||
- 适配器不能创建 `UserAccount`、`PlatformPrincipal`、角色、权限或 Session。
|
||||
- 不能根据 DingTalk email 自动绑定已有账号,也不能把普通 email 当作 verified email。
|
||||
- 不能从 DingTalk response 推导 SkillHub authority、租户、平台角色或管理员身份。
|
||||
- 不修改外部贡献者 PR [#467](https://github.com/iflytek/skillhub/pull/467) 的提交。
|
||||
- 不在 `main` 上直接验证或合并;先进入 `big-main`,再做独立香港测试环境验证。
|
||||
|
||||
## 2. 信任模型
|
||||
|
||||
SkillHub 把外部登录拆成三个层次:
|
||||
|
||||
```text
|
||||
DingTalk protocol response
|
||||
│ (native adapter: transport + shape validation)
|
||||
▼
|
||||
ProviderAuthenticationResult
|
||||
│ (trusted ProviderDescriptor from server configuration)
|
||||
▼
|
||||
IdentityAssertion / identity binding / account merge / platform session
|
||||
```
|
||||
|
||||
适配器只负责第一层到第二层。第二层到第三层必须由统一身份核心完成,原因是:
|
||||
|
||||
1. 账号绑定需要事务、冲突检测、link intent 和一次性 proof。
|
||||
2. 同一个 subject 必须经过 server-owned provider authority 才能形成全局唯一坐标。
|
||||
3. 角色和 Session 是 SkillHub 的安全状态,不能由外部 IdP 的可变字段决定。
|
||||
|
||||
### 2.1 Provider Instance 与 authority
|
||||
|
||||
DingTalk 的 registration id 固定为 `dingtalk`。运维配置的
|
||||
`SKILLHUB_AUTH_DINGTALK_AUTHORITY` 是 SkillHub 内部稳定的身份命名空间,例如
|
||||
`dingtalk.corp`;它不是 URL,不从 `unionId`、`openId`、email 或任意上游 claim 读取。
|
||||
|
||||
产生身份绑定后不得修改 authority。若需要迁移到另一个 DingTalk 应用或租户,应通过显式
|
||||
的 Identity Link/迁移流程证明两边账号控制权,不能覆盖旧 binding。
|
||||
|
||||
### 2.2 Subject 规则
|
||||
|
||||
| DingTalk 字段 | SkillHub 类型 | 用途 | 信任要求 |
|
||||
| --- | --- | --- | --- |
|
||||
| `unionId` | `dingtalk_union_id` | primary subject | 必须存在、非空、原样保留 |
|
||||
| `openId` | `dingtalk_open_id` | typed alternate subject | 仅接受同一已验证响应中的值 |
|
||||
| `userId` | `dingtalk_user_id` | typed alternate subject | 仅接受同一已验证响应中的值 |
|
||||
|
||||
`unionId` 缺失时整个登录失败;不能退回使用 `openId` 或 `userId` 作为 primary。三种
|
||||
subject 都使用 `EXACT` canonicalizer,大小写和字符不能被静默改写。alternate 只能用于
|
||||
统一核心已有的受控查找/迁移语义,不能绕过 authority 或 Link proof。
|
||||
|
||||
### 2.3 Profile 与 email
|
||||
|
||||
适配器只映射以下非敏感属性:
|
||||
|
||||
- `nick` → `dingtalk_nick`
|
||||
- `name` → `dingtalk_name`
|
||||
- `email` → `dingtalk_email`
|
||||
- `avatarUrl` → `dingtalk_avatar_url`
|
||||
|
||||
属性值只接受无首尾空白的字符串。DingTalk 普通 OAuth userinfo 返回的 email 标记为
|
||||
`ProviderAttributeTrust.ASSERTED`,Registry 将其上限钳制为 `PROVIDER_ASSERTED`。它不能
|
||||
触发 email 自动绑定,也不能作为 verified/authoritative 邮箱。头像仍由统一核心执行
|
||||
HTTP(S) URI 校验;适配器不保存 raw response。
|
||||
|
||||
## 3. 协议适配器
|
||||
|
||||
实现位于 `server/skillhub-auth/.../oauth/`:
|
||||
|
||||
- `DingTalkTokenResponseClient`:以 JSON body 发送 `clientId`、`clientSecret`、`code`、
|
||||
`grantType=authorization_code`,解析 `accessToken` 与正整数 `expireIn`。
|
||||
- `DingTalkOAuth2UserService`:固定调用 userinfo endpoint,并使用
|
||||
`x-acs-dingtalk-access-token` header,不使用标准 `Authorization: Bearer` 传输。
|
||||
- `DingTalkClaimsExtractor`:只把已验证的 userinfo facts 转成统一核心输入。
|
||||
- `ProviderAware*`:只按 registration id 将 DingTalk 请求路由到 native adapter,其他
|
||||
OAuth/OIDC registration 保持原有 Spring Security delegate。
|
||||
|
||||
固定 endpoint:
|
||||
|
||||
```text
|
||||
authorization: https://login.dingtalk.com/oauth2/auth
|
||||
token: https://api.dingtalk.com/v1.0/oauth2/userAccessToken
|
||||
userinfo: https://api.dingtalk.com/v1.0/contact/users/me
|
||||
```
|
||||
|
||||
所有 endpoint 都必须与 server-owned `ClientRegistration` 完全一致。重建的 registration、
|
||||
改变的 user-name attribute、非 authorization-code grant 或任意 endpoint 偏差均 fail closed。
|
||||
|
||||
### 3.1 响应边界
|
||||
|
||||
- connect/read timeout 必须为正且不超过 1 分钟。
|
||||
- response body 默认最多 1 MiB,允许范围为 1 KiB–1 MiB。
|
||||
- HTTP 错误、空 body、超限 body、非法 JSON、缺少 token、非法 `expireIn` 或缺少
|
||||
`unionId` 都转换成稳定 OAuth 错误码,不回显响应 body。
|
||||
- access token、client secret、authorization code 和 raw response 不进入
|
||||
`ProviderAuthenticationResult`、日志或审计字段。
|
||||
- provider disabled 或 registration 不完整时,在任何 HTTP 调用前失败。
|
||||
|
||||
## 4. 配置与部署
|
||||
|
||||
### 4.1 Compose / release 环境变量
|
||||
|
||||
```text
|
||||
SKILLHUB_AUTH_DINGTALK_ENABLED=false
|
||||
SKILLHUB_AUTH_DINGTALK_AUTHORITY=dingtalk.corp
|
||||
SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT=PT5S
|
||||
SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT=PT10S
|
||||
SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES=1048576
|
||||
OAUTH2_DINGTALK_CLIENT_ID=
|
||||
OAUTH2_DINGTALK_CLIENT_SECRET=
|
||||
OAUTH2_DINGTALK_DISPLAY_NAME=DingTalk
|
||||
```
|
||||
|
||||
启用前必须同时提供非 placeholder 的 client id、client secret、authority、timeout 和
|
||||
response limit。`scripts/validate-release-config.sh` 会拒绝不完整凭据、非法 authority
|
||||
字符和越界 response limit。
|
||||
|
||||
在 Compose 中,client credentials 通过环境变量注入容器;在 Kubernetes 中,authority 和
|
||||
边界参数进入 ConfigMap,client id/secret 进入 Secret。不要把 Secret 提交到仓库或写入
|
||||
ConfigMap。默认关闭意味着普通部署不会产生 DingTalk 网络流量。
|
||||
|
||||
### 4.2 启用步骤
|
||||
|
||||
1. 在 DingTalk 管理端创建 OAuth 应用并配置与 SkillHub 完全一致的回调 URI:
|
||||
`/login/oauth2/code/dingtalk`(由 Spring `{baseUrl}` 展开)。
|
||||
2. 在 secret store 写入 `OAUTH2_DINGTALK_CLIENT_ID` 和
|
||||
`OAUTH2_DINGTALK_CLIENT_SECRET`。
|
||||
3. 设置稳定的 `SKILLHUB_AUTH_DINGTALK_AUTHORITY`,确认该值尚未用于另一套 IdP。
|
||||
4. 运行 release config validator 和 Kustomize/Compose 配置渲染检查。
|
||||
5. 在 `big-main` 的精确 SHA 镜像上先部署,确认 provider 目录出现 DingTalk;再执行
|
||||
operator smoke 和人工 callback 验证。
|
||||
6. 只有远端验证通过、现有服务健康且本次资源已清理后,才向维护者申请合入 `main`。
|
||||
|
||||
## 5. 升级、绑定与回滚
|
||||
|
||||
### 5.1 老版本兼容性
|
||||
|
||||
本功能只增加代码、配置和登录目录项,不改变现有 GitHub、GitLab、OIDC、LDAP、CAS、
|
||||
local password、API token、Identity Link 或 Account Merge 的数据结构。禁用 DingTalk
|
||||
时老版本无需识别新环境变量即可继续运行。
|
||||
|
||||
新版本升级不迁移、不删除既有 `identity_binding`。DingTalk 的第一次登录如果找不到
|
||||
binding,遵循现有 provider provisioning policy;若 email 与已有账号冲突,进入统一核心
|
||||
的 link-required 流程,而不是自动合并。已存在的旧绑定不会因为 profile name/email 变化
|
||||
而改变 subject。
|
||||
|
||||
### 5.2 回滚
|
||||
|
||||
回滚前先把 `SKILLHUB_AUTH_DINGTALK_ENABLED=false` 部署到所有新 Pod,并确认登录目录不再
|
||||
提供 DingTalk。保留已有 identity binding 和审计数据;不要删除 binding、直接改 authority、
|
||||
恢复角色或手工改库。旧镜像忽略新环境变量即可回滚到不支持 DingTalk 的版本。
|
||||
|
||||
## 6. 验收矩阵
|
||||
|
||||
### 6.1 自动检查
|
||||
|
||||
```bash
|
||||
./mvnw -pl skillhub-auth -am test
|
||||
bash scripts/tests/validate-release-config-test.sh
|
||||
make test-backend-app
|
||||
make typecheck-web
|
||||
make lint-web
|
||||
make staging
|
||||
```
|
||||
|
||||
适配器测试必须覆盖:
|
||||
|
||||
- unionId primary、openId/userId typed aliases;
|
||||
- 缺少 unionId、非法 JSON、HTTP error、空/超限响应;
|
||||
- 非正或超长 token expiry;
|
||||
- DingTalk 自定义 token header 和官方 endpoint;
|
||||
- disabled/incomplete provider 在网络调用前失败;
|
||||
- Provider Registry 对三种 subject 使用 `EXACT`,email 上限为
|
||||
`PROVIDER_ASSERTED`;
|
||||
- adapter bytecode 不依赖账号、principal、role、session 或 persistence 类。
|
||||
|
||||
### 6.2 香港测试机最小人工路径
|
||||
|
||||
在不重启宿主机、不中断现有 `skillhub-runtime-*` 的前提下,使用独立 project/network/
|
||||
container name 部署精确 SHA:
|
||||
|
||||
1. `GET /actuator/health` 返回 200,provider catalog 在 disabled 时不包含 DingTalk。
|
||||
2. 启用 mock DingTalk transport 后,登录 callback 只创建/解析统一核心允许的结果;重复
|
||||
`unionId` 登录解析到同一 binding。
|
||||
3. 修改 `nick`/`email` 不改变 `dingtalk_union_id`;普通 email 不能自动绑定冲突账号。
|
||||
4. Identity Link 和 Account Merge 仍要求现有一次性 state/proof,不能通过重复 callback
|
||||
或换 openId 绕过。
|
||||
5. 检查容器日志、响应和审计中没有 client secret、authorization code、access token、
|
||||
raw response 或 session/nonce。
|
||||
6. 验证完成后只删除带本次 run id 的容器、网络、镜像和临时文件;不删除 multica 历史
|
||||
记录、共享卷、其他测试资源,也不重启机器。
|
||||
|
||||
### 6.3 证据
|
||||
|
||||
进入 `main` 前应保留:feature SHA、`big-main` SHA、OCI revision、自动测试输出、配置
|
||||
validator 输出、远端 health/smoke 输出、provider catalog 前后差异、日志脱敏检查和
|
||||
精确清理清单。任何一项缺失都保持 DingTalk 关闭。
|
||||
|
||||
## 7. 参考标准
|
||||
|
||||
- [RFC 6749: The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749)
|
||||
- [Spring Security OAuth2 Client](https://docs.spring.io/spring-security/reference/servlet/oauth2/client/index.html)
|
||||
- [OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700)
|
||||
52
scripts/dingtalk-smoke-test.sh
Executable file
52
scripts/dingtalk-smoke-test.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-http://localhost:8080}"
|
||||
EXPECTED_ENABLED="${DINGTALK_EXPECTED_ENABLED:-false}"
|
||||
RESPONSE_FILE="$(mktemp)"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$RESPONSE_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
status="$(curl --max-time 10 -s -o "$RESPONSE_FILE" -w "%{http_code}" \
|
||||
"$BASE_URL/api/v1/auth/providers" || true)"
|
||||
if [[ "$status" != "200" ]]; then
|
||||
echo "FAIL: authentication provider catalog returned HTTP $status" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$RESPONSE_FILE" "$EXPECTED_ENABLED" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
response_file, expected_enabled = sys.argv[1:]
|
||||
with open(response_file, encoding="utf-8") as response:
|
||||
providers = json.load(response)["data"]
|
||||
|
||||
dingtalk = [provider for provider in providers if provider.get("id") == "dingtalk"]
|
||||
if expected_enabled == "true":
|
||||
if len(dingtalk) != 1:
|
||||
raise SystemExit("FAIL: enabled DingTalk provider is missing from catalog")
|
||||
expected_url = "/oauth2/authorization/dingtalk"
|
||||
if dingtalk[0].get("authorizationUrl") != expected_url:
|
||||
raise SystemExit(
|
||||
"FAIL: DingTalk authorization URL is not the trusted route: "
|
||||
+ repr(dingtalk[0].get("authorizationUrl")))
|
||||
print("PASS: enabled DingTalk provider exposes the trusted authorization route")
|
||||
else:
|
||||
if dingtalk:
|
||||
raise SystemExit("FAIL: disabled DingTalk provider is visible in catalog")
|
||||
print("PASS: disabled DingTalk provider is hidden from catalog")
|
||||
PY
|
||||
|
||||
if [[ "$EXPECTED_ENABLED" == "false" ]]; then
|
||||
route_status="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
|
||||
"$BASE_URL/oauth2/authorization/dingtalk" || true)"
|
||||
if [[ "$route_status" != "403" ]]; then
|
||||
echo "FAIL: disabled DingTalk route returned HTTP $route_status (expected 403)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: disabled DingTalk route fails before upstream redirect (HTTP 403)"
|
||||
fi
|
||||
|
|
@ -75,6 +75,33 @@ write_env "$disabled_builtin_skills_env" "release-download-secret-32-bytes-minim
|
|||
printf '%s\n' "SKILLHUB_BUILTIN_SKILLS_ENABLED=false" >>"$disabled_builtin_skills_env"
|
||||
"$SCRIPT" "$disabled_builtin_skills_env" >/dev/null
|
||||
|
||||
valid_dingtalk_env="$tmp/valid-dingtalk.env"
|
||||
write_env "$valid_dingtalk_env" "release-download-secret-32-bytes-minimum"
|
||||
cat >>"$valid_dingtalk_env" <<'EOF'
|
||||
SKILLHUB_AUTH_DINGTALK_ENABLED=true
|
||||
SKILLHUB_AUTH_DINGTALK_AUTHORITY=dingtalk.corp
|
||||
SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT=PT5S
|
||||
SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT=PT10S
|
||||
SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES=1048576
|
||||
OAUTH2_DINGTALK_CLIENT_ID=real-dingtalk-client
|
||||
OAUTH2_DINGTALK_CLIENT_SECRET=real-dingtalk-secret
|
||||
EOF
|
||||
"$SCRIPT" "$valid_dingtalk_env" >/dev/null
|
||||
|
||||
missing_dingtalk_secret_env="$tmp/missing-dingtalk-secret.env"
|
||||
grep -v '^OAUTH2_DINGTALK_CLIENT_SECRET=' "$valid_dingtalk_env" >"$missing_dingtalk_secret_env"
|
||||
expect_fail "$missing_dingtalk_secret_env" "OAUTH2_DINGTALK_CLIENT_SECRET is required"
|
||||
|
||||
invalid_dingtalk_authority_env="$tmp/invalid-dingtalk-authority.env"
|
||||
cp "$valid_dingtalk_env" "$invalid_dingtalk_authority_env"
|
||||
sed -i 's/^SKILLHUB_AUTH_DINGTALK_AUTHORITY=.*/SKILLHUB_AUTH_DINGTALK_AUTHORITY=https:\/\/dingtalk.example.com/' "$invalid_dingtalk_authority_env"
|
||||
expect_fail "$invalid_dingtalk_authority_env" "SKILLHUB_AUTH_DINGTALK_AUTHORITY contains invalid characters"
|
||||
|
||||
invalid_dingtalk_size_env="$tmp/invalid-dingtalk-size.env"
|
||||
cp "$valid_dingtalk_env" "$invalid_dingtalk_size_env"
|
||||
sed -i 's/^SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES=.*/SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES=512/' "$invalid_dingtalk_size_env"
|
||||
expect_fail "$invalid_dingtalk_size_env" "SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES must be between 1024 and 1048576"
|
||||
|
||||
missing_env="$tmp/missing.env"
|
||||
write_env "$missing_env" "" no
|
||||
expect_fail "$missing_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required"
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ validate_boolean SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING
|
|||
validate_boolean SKILLHUB_AUTH_LDAP_EMAIL_AUTHORITATIVE
|
||||
validate_boolean SKILLHUB_AUTH_CAS_ENABLED
|
||||
validate_boolean SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING
|
||||
validate_boolean SKILLHUB_AUTH_DINGTALK_ENABLED
|
||||
validate_boolean SPRING_DATA_REDIS_SSL_ENABLED
|
||||
validate_boolean SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST
|
||||
|
||||
|
|
@ -306,6 +307,43 @@ if [ "${SKILLHUB_AUTH_CAS_ENABLED:-false}" = "true" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
if [ "${SKILLHUB_AUTH_DINGTALK_ENABLED:-false}" = "true" ]; then
|
||||
require_non_empty SKILLHUB_AUTH_DINGTALK_AUTHORITY
|
||||
require_non_empty SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT
|
||||
require_non_empty SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT
|
||||
require_non_empty SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES
|
||||
require_non_empty OAUTH2_DINGTALK_CLIENT_ID
|
||||
require_non_empty OAUTH2_DINGTALK_CLIENT_SECRET
|
||||
reject_values OAUTH2_DINGTALK_CLIENT_ID "placeholder" "local-placeholder"
|
||||
reject_values OAUTH2_DINGTALK_CLIENT_SECRET "placeholder" "local-placeholder"
|
||||
case "${SKILLHUB_AUTH_DINGTALK_AUTHORITY:-}" in
|
||||
[a-z0-9]*) ;;
|
||||
*) error "SKILLHUB_AUTH_DINGTALK_AUTHORITY must start with a lowercase letter or digit" ;;
|
||||
esac
|
||||
case "${SKILLHUB_AUTH_DINGTALK_AUTHORITY:-}" in
|
||||
*[!a-z0-9._:-]*) error "SKILLHUB_AUTH_DINGTALK_AUTHORITY contains invalid characters" ;;
|
||||
esac
|
||||
validate_non_negative_integer SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES
|
||||
case "${SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES:-}" in
|
||||
"") ;;
|
||||
*)
|
||||
if [ "$SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES" -lt 1024 ] \
|
||||
|| [ "$SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES" -gt 1048576 ]; then
|
||||
error "SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES must be between 1024 and 1048576"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
dingtalk_id="${OAUTH2_DINGTALK_CLIENT_ID:-}"
|
||||
dingtalk_secret="${OAUTH2_DINGTALK_CLIENT_SECRET:-}"
|
||||
if [ -n "$dingtalk_id" ] && [ -z "$dingtalk_secret" ]; then
|
||||
error "OAUTH2_DINGTALK_CLIENT_SECRET is required when OAUTH2_DINGTALK_CLIENT_ID is set"
|
||||
fi
|
||||
if [ -n "$dingtalk_secret" ] && [ -z "$dingtalk_id" ]; then
|
||||
error "OAUTH2_DINGTALK_CLIENT_ID is required when OAUTH2_DINGTALK_CLIENT_SECRET is set"
|
||||
fi
|
||||
|
||||
validate_port POSTGRES_PORT
|
||||
validate_port REDIS_PORT
|
||||
validate_port API_PORT
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@ spring:
|
|||
authorization-grant-type: authorization_code
|
||||
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
|
||||
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
|
||||
dingtalk:
|
||||
client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder}
|
||||
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder}
|
||||
scope:
|
||||
- openid
|
||||
authorization-grant-type: authorization_code
|
||||
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
|
||||
client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:DingTalk}
|
||||
provider:
|
||||
github:
|
||||
user-info-uri: https://api.github.com/user
|
||||
|
|
@ -79,6 +87,11 @@ spring:
|
|||
token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token
|
||||
user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user
|
||||
user-name-attribute: username
|
||||
dingtalk:
|
||||
authorization-uri: https://login.dingtalk.com/oauth2/auth
|
||||
token-uri: https://api.dingtalk.com/v1.0/oauth2/userAccessToken
|
||||
user-info-uri: https://api.dingtalk.com/v1.0/contact/users/me
|
||||
user-name-attribute: dingtalkSubject
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 100MB
|
||||
|
|
@ -147,6 +160,13 @@ skillhub:
|
|||
pool-wait-timeout: ${SKILLHUB_AUTH_LDAP_POOL_WAIT_TIMEOUT:PT2S}
|
||||
max-concurrent-requests: ${SKILLHUB_AUTH_LDAP_MAX_CONCURRENT_REQUESTS:16}
|
||||
max-attribute-values: ${SKILLHUB_AUTH_LDAP_MAX_ATTRIBUTE_VALUES:16}
|
||||
dingtalk:
|
||||
enabled: ${SKILLHUB_AUTH_DINGTALK_ENABLED:false}
|
||||
authority: ${SKILLHUB_AUTH_DINGTALK_AUTHORITY:}
|
||||
display-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:DingTalk}
|
||||
connect-timeout: ${SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT:PT5S}
|
||||
read-timeout: ${SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT:PT10S}
|
||||
max-response-bytes: ${SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES:1048576}
|
||||
session-bootstrap:
|
||||
enabled: ${SKILLHUB_AUTH_SESSION_BOOTSTRAP_ENABLED:false}
|
||||
cas:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService;
|
|||
import com.iflytek.skillhub.auth.oauth.CustomOidcUserService;
|
||||
import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
|
||||
import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
|
||||
import com.iflytek.skillhub.auth.oauth.ProviderAwareAccessTokenResponseClient;
|
||||
import com.iflytek.skillhub.auth.oauth.IdentityProviderRouteReadinessFilter;
|
||||
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
|
||||
import com.iflytek.skillhub.auth.identity.IdentityProviderReadinessService;
|
||||
|
|
@ -64,6 +65,8 @@ public class SecurityConfig {
|
|||
private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver;
|
||||
private final OAuth2LoginSuccessHandler successHandler;
|
||||
private final OAuth2LoginFailureHandler failureHandler;
|
||||
private final ProviderAwareAccessTokenResponseClient
|
||||
accessTokenResponseClient;
|
||||
private final ApiTokenAuthenticationFilter apiTokenAuthenticationFilter;
|
||||
private final ApiTokenScopeFilter apiTokenScopeFilter;
|
||||
private final AuthenticationEntryPoint apiAuthenticationEntryPoint;
|
||||
|
|
@ -78,6 +81,8 @@ public class SecurityConfig {
|
|||
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
|
||||
OAuth2LoginSuccessHandler successHandler,
|
||||
OAuth2LoginFailureHandler failureHandler,
|
||||
ProviderAwareAccessTokenResponseClient
|
||||
accessTokenResponseClient,
|
||||
ApiTokenAuthenticationFilter apiTokenAuthenticationFilter,
|
||||
ApiTokenScopeFilter apiTokenScopeFilter,
|
||||
AuthenticationEntryPoint apiAuthenticationEntryPoint,
|
||||
|
|
@ -91,6 +96,7 @@ public class SecurityConfig {
|
|||
this.authorizationRequestResolver = authorizationRequestResolver;
|
||||
this.successHandler = successHandler;
|
||||
this.failureHandler = failureHandler;
|
||||
this.accessTokenResponseClient = accessTokenResponseClient;
|
||||
this.apiTokenAuthenticationFilter = apiTokenAuthenticationFilter;
|
||||
this.apiTokenScopeFilter = apiTokenScopeFilter;
|
||||
this.apiAuthenticationEntryPoint = apiAuthenticationEntryPoint;
|
||||
|
|
@ -130,6 +136,9 @@ public class SecurityConfig {
|
|||
})
|
||||
.oauth2Login(oauth2 -> oauth2
|
||||
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
|
||||
.tokenEndpoint(endpoint -> endpoint
|
||||
.accessTokenResponseClient(
|
||||
accessTokenResponseClient))
|
||||
.userInfoEndpoint(userInfo -> userInfo
|
||||
.userService(customOAuth2UserService)
|
||||
.oidcUserService(customOidcUserService))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2Constants;
|
||||
import com.iflytek.skillhub.auth.oauth.DingTalkProperties;
|
||||
import com.iflytek.skillhub.auth.oauth.OAuthClaimsExtractor;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
|
@ -35,20 +37,23 @@ class StaticTrustedProviderDescriptorSource
|
|||
private final Map<String, ProviderDescriptor> descriptors;
|
||||
private final Map<String, ClientRegistration> trustedRegistrations;
|
||||
private final IdentityProviderPolicyProperties policyProperties;
|
||||
private final DingTalkProperties dingTalkProperties;
|
||||
|
||||
@Autowired
|
||||
StaticTrustedProviderDescriptorSource(
|
||||
OAuth2ClientProperties properties,
|
||||
ClientRegistrationRepository registrationRepository,
|
||||
List<OAuthClaimsExtractor> extractors,
|
||||
IdentityProviderPolicyProperties policyProperties) {
|
||||
IdentityProviderPolicyProperties policyProperties,
|
||||
DingTalkProperties dingTalkProperties) {
|
||||
this(
|
||||
properties,
|
||||
registrationRepository,
|
||||
extractors.stream()
|
||||
.map(OAuthClaimsExtractor::getProvider)
|
||||
.collect(Collectors.toUnmodifiableSet()),
|
||||
policyProperties);
|
||||
policyProperties,
|
||||
dingTalkProperties);
|
||||
}
|
||||
|
||||
StaticTrustedProviderDescriptorSource(
|
||||
|
|
@ -59,7 +64,8 @@ class StaticTrustedProviderDescriptorSource
|
|||
properties,
|
||||
registrationRepository,
|
||||
extractorCodes,
|
||||
new IdentityProviderPolicyProperties());
|
||||
new IdentityProviderPolicyProperties(),
|
||||
new DingTalkProperties());
|
||||
}
|
||||
|
||||
StaticTrustedProviderDescriptorSource(
|
||||
|
|
@ -67,7 +73,22 @@ class StaticTrustedProviderDescriptorSource
|
|||
ClientRegistrationRepository registrationRepository,
|
||||
Set<String> extractorCodes,
|
||||
IdentityProviderPolicyProperties policyProperties) {
|
||||
this(
|
||||
properties,
|
||||
registrationRepository,
|
||||
extractorCodes,
|
||||
policyProperties,
|
||||
new DingTalkProperties());
|
||||
}
|
||||
|
||||
StaticTrustedProviderDescriptorSource(
|
||||
OAuth2ClientProperties properties,
|
||||
ClientRegistrationRepository registrationRepository,
|
||||
Set<String> extractorCodes,
|
||||
IdentityProviderPolicyProperties policyProperties,
|
||||
DingTalkProperties dingTalkProperties) {
|
||||
this.policyProperties = policyProperties;
|
||||
this.dingTalkProperties = dingTalkProperties;
|
||||
Map<String, ProviderDescriptor> resolvedDescriptors =
|
||||
new LinkedHashMap<>();
|
||||
Map<String, ClientRegistration> resolvedRegistrations =
|
||||
|
|
@ -125,6 +146,10 @@ class StaticTrustedProviderDescriptorSource
|
|||
if (!hasRealClientId(properties.getClientId())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (DingTalkOAuth2Constants.REGISTRATION_ID.equals(providerCode)
|
||||
&& !hasRealClientSecret(properties.getClientSecret())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
ClientRegistration registration;
|
||||
try {
|
||||
registration = registrationRepository
|
||||
|
|
@ -138,6 +163,11 @@ class StaticTrustedProviderDescriptorSource
|
|||
if (registration == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (DingTalkOAuth2Constants.REGISTRATION_ID.equals(providerCode)
|
||||
&& (!hasRealClientId(registration.getClientId())
|
||||
|| !hasRealClientSecret(registration.getClientSecret()))) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
ProviderDescriptor descriptor = descriptorFor(
|
||||
|
|
@ -202,6 +232,16 @@ class StaticTrustedProviderDescriptorSource
|
|||
List.of("email"),
|
||||
List.of("avatar_url"));
|
||||
}
|
||||
case DingTalkOAuth2Constants.REGISTRATION_ID -> {
|
||||
if (!hasExtractor
|
||||
|| hasIssuer
|
||||
|| !dingTalkProperties.isEnabled()) {
|
||||
throw new IllegalArgumentException(
|
||||
"DingTalk adapter is unavailable");
|
||||
}
|
||||
validateDingTalkEndpoints(registration);
|
||||
yield dingTalkDescriptor(providerCode);
|
||||
}
|
||||
default -> {
|
||||
if (!hasIssuer || hasExtractor) {
|
||||
throw new IllegalArgumentException(
|
||||
|
|
@ -236,6 +276,30 @@ class StaticTrustedProviderDescriptorSource
|
|||
List<String> displayNameAttributes,
|
||||
List<String> emailAttributes,
|
||||
List<String> avatarAttributes) {
|
||||
return descriptor(
|
||||
providerCode,
|
||||
protocol,
|
||||
authority,
|
||||
configuredDisplayName,
|
||||
subjectType,
|
||||
canonicalizer,
|
||||
displayNameAttributes,
|
||||
emailAttributes,
|
||||
avatarAttributes,
|
||||
EmailAssurance.VERIFIED);
|
||||
}
|
||||
|
||||
private ProviderDescriptor descriptor(
|
||||
String providerCode,
|
||||
String protocol,
|
||||
String authority,
|
||||
String configuredDisplayName,
|
||||
String subjectType,
|
||||
SubjectCanonicalizer canonicalizer,
|
||||
List<String> displayNameAttributes,
|
||||
List<String> emailAttributes,
|
||||
List<String> avatarAttributes,
|
||||
EmailAssurance emailAssuranceLimit) {
|
||||
String displayName =
|
||||
configuredDisplayName == null
|
||||
|| configuredDisplayName.isBlank()
|
||||
|
|
@ -254,11 +318,59 @@ class StaticTrustedProviderDescriptorSource
|
|||
displayNameAttributes,
|
||||
emailAttributes,
|
||||
avatarAttributes,
|
||||
EmailAssurance.VERIFIED,
|
||||
emailAssuranceLimit,
|
||||
policy.provisioningMode(),
|
||||
policy.profileSyncPolicy());
|
||||
}
|
||||
|
||||
private ProviderDescriptor dingTalkDescriptor(String providerCode) {
|
||||
IdentityProviderPolicyProperties.ProviderIdentityPolicy policy =
|
||||
policyProperties.resolve(providerCode);
|
||||
String displayName = dingTalkProperties.getDisplayName();
|
||||
if (displayName == null || displayName.isBlank()) {
|
||||
displayName = "DingTalk";
|
||||
}
|
||||
return new ProviderDescriptor(
|
||||
providerCode,
|
||||
"dingtalk-oauth2",
|
||||
requireDingTalkAuthority(),
|
||||
displayName,
|
||||
"dingtalk_union_id",
|
||||
"dingtalk_union_id",
|
||||
Map.of(
|
||||
"dingtalk_union_id",
|
||||
SubjectCanonicalizer.EXACT,
|
||||
"dingtalk_open_id",
|
||||
SubjectCanonicalizer.EXACT,
|
||||
"dingtalk_user_id",
|
||||
SubjectCanonicalizer.EXACT),
|
||||
List.of("dingtalk_nick", "dingtalk_name"),
|
||||
List.of("dingtalk_email"),
|
||||
List.of("dingtalk_avatar_url"),
|
||||
EmailAssurance.PROVIDER_ASSERTED,
|
||||
policy.provisioningMode(),
|
||||
policy.profileSyncPolicy());
|
||||
}
|
||||
|
||||
private void validateDingTalkEndpoints(
|
||||
ClientRegistration registration) {
|
||||
if (!DingTalkOAuth2Constants.hasTrustedRegistration(registration)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid DingTalk provider endpoints");
|
||||
}
|
||||
}
|
||||
|
||||
private String requireDingTalkAuthority() {
|
||||
String authority = dingTalkProperties.getAuthority();
|
||||
if (authority == null
|
||||
|| !authority.matches(
|
||||
"[a-z0-9][a-z0-9._:-]{0,127}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid DingTalk authority");
|
||||
}
|
||||
return authority;
|
||||
}
|
||||
|
||||
private void validatePublicGithubEndpoints(
|
||||
ClientRegistration registration) {
|
||||
var details = registration.getProviderDetails();
|
||||
|
|
@ -360,6 +472,13 @@ class StaticTrustedProviderDescriptorSource
|
|||
.contains("placeholder");
|
||||
}
|
||||
|
||||
private boolean hasRealClientSecret(String clientSecret) {
|
||||
return clientSecret != null
|
||||
&& !clientSecret.isBlank()
|
||||
&& !clientSecret.toLowerCase(Locale.ROOT)
|
||||
.contains("placeholder");
|
||||
}
|
||||
|
||||
private IdentityCoreException providerDisabled() {
|
||||
return new IdentityCoreException(
|
||||
IdentityFailureCode.PROVIDER_DISABLED);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAttributeValue;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
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;
|
||||
|
||||
/** Maps a verified DingTalk user-info response into unified identity facts. */
|
||||
@Component
|
||||
public class DingTalkClaimsExtractor implements OAuthClaimsExtractor {
|
||||
|
||||
static final String NICK_ATTRIBUTE = "dingtalk_nick";
|
||||
static final String NAME_ATTRIBUTE = "dingtalk_name";
|
||||
static final String EMAIL_ATTRIBUTE = "dingtalk_email";
|
||||
static final String AVATAR_ATTRIBUTE = "dingtalk_avatar_url";
|
||||
|
||||
private static final String UNION_SUBJECT_TYPE =
|
||||
"dingtalk_union_id";
|
||||
private static final String OPEN_SUBJECT_TYPE =
|
||||
"dingtalk_open_id";
|
||||
private static final String USER_SUBJECT_TYPE =
|
||||
"dingtalk_user_id";
|
||||
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return DingTalkOAuth2Constants.REGISTRATION_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderAuthenticationResult extract(
|
||||
OAuth2UserRequest request,
|
||||
OAuth2User oauthUser) {
|
||||
Map<String, Object> source = oauthUser.getAttributes();
|
||||
String unionId = requireString(
|
||||
source,
|
||||
DingTalkOAuth2Constants.UNION_ID_ATTRIBUTE);
|
||||
|
||||
List<SubjectCandidate> aliases = new ArrayList<>();
|
||||
addAlias(
|
||||
aliases,
|
||||
OPEN_SUBJECT_TYPE,
|
||||
source.get(DingTalkOAuth2Constants.OPEN_ID_ATTRIBUTE));
|
||||
addAlias(
|
||||
aliases,
|
||||
USER_SUBJECT_TYPE,
|
||||
source.get(DingTalkOAuth2Constants.USER_ID_ATTRIBUTE));
|
||||
|
||||
Map<String, List<ProviderAttributeValue>> attributes =
|
||||
new LinkedHashMap<>();
|
||||
put(
|
||||
attributes,
|
||||
NICK_ATTRIBUTE,
|
||||
source.get(DingTalkOAuth2Constants.NICK_ATTRIBUTE));
|
||||
put(
|
||||
attributes,
|
||||
NAME_ATTRIBUTE,
|
||||
source.get(DingTalkOAuth2Constants.NAME_ATTRIBUTE));
|
||||
put(
|
||||
attributes,
|
||||
EMAIL_ATTRIBUTE,
|
||||
source.get(DingTalkOAuth2Constants.EMAIL_ATTRIBUTE));
|
||||
put(
|
||||
attributes,
|
||||
AVATAR_ATTRIBUTE,
|
||||
source.get(DingTalkOAuth2Constants.AVATAR_ATTRIBUTE));
|
||||
|
||||
return new ProviderAuthenticationResult(
|
||||
new SubjectCandidate(UNION_SUBJECT_TYPE, unionId),
|
||||
aliases,
|
||||
attributes,
|
||||
new ProtocolAuthenticationEvidence(
|
||||
"dingtalk-oauth2",
|
||||
request.getAccessToken().getIssuedAt(),
|
||||
Set.of("oauth2_authorization_code")));
|
||||
}
|
||||
|
||||
static String requireUnionId(Map<String, Object> attributes) {
|
||||
return requireString(
|
||||
attributes,
|
||||
DingTalkOAuth2Constants.UNION_ID_ATTRIBUTE);
|
||||
}
|
||||
|
||||
private static String requireString(
|
||||
Map<String, Object> attributes,
|
||||
String key) {
|
||||
String value = stringValue(attributes.get(key));
|
||||
if (value == null) {
|
||||
throw new OAuth2AuthenticationException(
|
||||
new OAuth2Error(
|
||||
"missing_stable_subject",
|
||||
"DingTalk unionId is required",
|
||||
null));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void addAlias(
|
||||
List<SubjectCandidate> aliases,
|
||||
String type,
|
||||
Object rawValue) {
|
||||
String value = stringValue(rawValue);
|
||||
if (value != null) {
|
||||
aliases.add(new SubjectCandidate(type, value));
|
||||
}
|
||||
}
|
||||
|
||||
private static void put(
|
||||
Map<String, List<ProviderAttributeValue>> attributes,
|
||||
String key,
|
||||
Object rawValue) {
|
||||
String value = stringValue(rawValue);
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
attributes.put(
|
||||
key,
|
||||
List.of(new ProviderAttributeValue(
|
||||
value,
|
||||
ProviderAttributeTrust.ASSERTED)));
|
||||
}
|
||||
|
||||
private static String stringValue(Object rawValue) {
|
||||
if (!(rawValue instanceof String value)
|
||||
|| value.isBlank()
|
||||
|| !value.equals(value.strip())) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
|
||||
/** Shared protocol constants for the native 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 AUTHORIZATION_URI =
|
||||
"https://login.dingtalk.com/oauth2/auth";
|
||||
public static final String TOKEN_URI =
|
||||
"https://api.dingtalk.com/v1.0/oauth2/userAccessToken";
|
||||
public static final String USER_INFO_URI =
|
||||
"https://api.dingtalk.com/v1.0/contact/users/me";
|
||||
public static final String ACCESS_TOKEN_HEADER =
|
||||
"x-acs-dingtalk-access-token";
|
||||
public static final String SUBJECT_ATTRIBUTE = "dingtalkSubject";
|
||||
public static final String UNION_ID_ATTRIBUTE = "unionId";
|
||||
public static final String OPEN_ID_ATTRIBUTE = "openId";
|
||||
public static final String USER_ID_ATTRIBUTE = "userId";
|
||||
public static final String NICK_ATTRIBUTE = "nick";
|
||||
public static final String NAME_ATTRIBUTE = "name";
|
||||
public static final String EMAIL_ATTRIBUTE = "email";
|
||||
public static final String AVATAR_ATTRIBUTE = "avatarUrl";
|
||||
static final List<String> SUBJECT_CLAIM_NAMES = List.of(
|
||||
UNION_ID_ATTRIBUTE,
|
||||
OPEN_ID_ATTRIBUTE,
|
||||
USER_ID_ATTRIBUTE);
|
||||
|
||||
/** Returns whether a registration exactly matches the server-owned flow. */
|
||||
public static boolean hasTrustedRegistration(
|
||||
ClientRegistration registration) {
|
||||
if (registration == null
|
||||
|| !REGISTRATION_ID.equals(registration.getRegistrationId())
|
||||
|| !AuthorizationGrantType.AUTHORIZATION_CODE.equals(
|
||||
registration.getAuthorizationGrantType())) {
|
||||
return false;
|
||||
}
|
||||
var details = registration.getProviderDetails();
|
||||
var userInfo = details.getUserInfoEndpoint();
|
||||
return AUTHORIZATION_URI.equals(details.getAuthorizationUri())
|
||||
&& TOKEN_URI.equals(details.getTokenUri())
|
||||
&& userInfo != null
|
||||
&& USER_INFO_URI.equals(userInfo.getUri())
|
||||
&& SUBJECT_ATTRIBUTE.equals(
|
||||
userInfo.getUserNameAttributeName());
|
||||
}
|
||||
|
||||
private DingTalkOAuth2Constants() {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
|
||||
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.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* Loads DingTalk user info using its non-standard access-token header.
|
||||
*
|
||||
* <p>This class only verifies protocol transport and returns upstream facts.
|
||||
* It does not create an account, principal, role or session.</p>
|
||||
*/
|
||||
@Component
|
||||
public final class DingTalkOAuth2UserService
|
||||
implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
|
||||
|
||||
private static final Duration MAX_TIMEOUT = Duration.ofMinutes(1);
|
||||
private static final int MIN_RESPONSE_BYTES = 1024;
|
||||
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
private static final TypeReference<Map<String, Object>> RESPONSE_TYPE =
|
||||
new TypeReference<>() {
|
||||
};
|
||||
|
||||
private final DingTalkProperties properties;
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
public DingTalkOAuth2UserService(
|
||||
DingTalkProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
this(
|
||||
properties,
|
||||
objectMapper,
|
||||
buildRestTemplate(properties));
|
||||
}
|
||||
|
||||
DingTalkOAuth2UserService(
|
||||
DingTalkProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
RestTemplate restTemplate) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2User loadUser(OAuth2UserRequest request) {
|
||||
requireTrustedRegistration(request);
|
||||
int maximumBytes = requireMaximumBytes();
|
||||
String responseBody;
|
||||
try {
|
||||
responseBody = restTemplate.execute(
|
||||
DingTalkOAuth2Constants.USER_INFO_URI,
|
||||
HttpMethod.GET,
|
||||
httpRequest -> httpRequest.getHeaders().set(
|
||||
DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER,
|
||||
request.getAccessToken().getTokenValue()),
|
||||
response -> {
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw failure("userinfo_response_rejected");
|
||||
}
|
||||
return readLimited(
|
||||
response.getBody(),
|
||||
maximumBytes);
|
||||
});
|
||||
} catch (OAuth2AuthenticationException exception) {
|
||||
throw exception;
|
||||
} catch (RestClientException exception) {
|
||||
throw failure("userinfo_request_failed");
|
||||
}
|
||||
if (responseBody == null) {
|
||||
throw failure("userinfo_response_empty");
|
||||
}
|
||||
|
||||
Map<String, Object> attributes;
|
||||
try {
|
||||
attributes = objectMapper.readValue(
|
||||
responseBody,
|
||||
RESPONSE_TYPE);
|
||||
} catch (IOException exception) {
|
||||
throw failure("userinfo_response_invalid");
|
||||
}
|
||||
String unionId = DingTalkClaimsExtractor.requireUnionId(attributes);
|
||||
Map<String, Object> copied = new HashMap<>(attributes);
|
||||
copied.put(DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE, unionId);
|
||||
return new DefaultOAuth2User(
|
||||
Set.of(),
|
||||
copied,
|
||||
DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE);
|
||||
}
|
||||
|
||||
private void requireTrustedRegistration(OAuth2UserRequest request) {
|
||||
if (!properties.isEnabled()
|
||||
|| request == null
|
||||
|| !DingTalkOAuth2Constants.hasTrustedRegistration(
|
||||
request.getClientRegistration())) {
|
||||
throw failure("dingtalk_provider_misconfigured");
|
||||
}
|
||||
if (request.getAccessToken() == null
|
||||
|| request.getAccessToken().getTokenValue() == null
|
||||
|| request.getAccessToken().getTokenValue().isBlank()) {
|
||||
throw failure("access_token_missing");
|
||||
}
|
||||
requireDuration(properties.getConnectTimeout());
|
||||
requireDuration(properties.getReadTimeout());
|
||||
}
|
||||
|
||||
private int requireMaximumBytes() {
|
||||
int value = properties.getMaxResponseBytes();
|
||||
if (value < MIN_RESPONSE_BYTES || value > MAX_RESPONSE_BYTES) {
|
||||
throw failure("dingtalk_provider_misconfigured");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private void requireDuration(Duration value) {
|
||||
if (value == null
|
||||
|| value.isZero()
|
||||
|| value.isNegative()
|
||||
|| value.compareTo(MAX_TIMEOUT) > 0) {
|
||||
throw failure("dingtalk_provider_misconfigured");
|
||||
}
|
||||
}
|
||||
|
||||
private String readLimited(
|
||||
InputStream input,
|
||||
int maximumBytes) throws IOException {
|
||||
if (input == null) {
|
||||
throw failure("userinfo_response_empty");
|
||||
}
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream(
|
||||
Math.min(maximumBytes, 8192));
|
||||
byte[] buffer = new byte[8192];
|
||||
int total = 0;
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
total += read;
|
||||
if (total > maximumBytes) {
|
||||
throw failure("userinfo_response_too_large");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static RestTemplate buildRestTemplate(
|
||||
DingTalkProperties properties) {
|
||||
SimpleClientHttpRequestFactory factory =
|
||||
new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(properties.getConnectTimeout());
|
||||
factory.setReadTimeout(properties.getReadTimeout());
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
|
||||
private OAuth2AuthenticationException failure(String errorCode) {
|
||||
return new OAuth2AuthenticationException(
|
||||
new OAuth2Error(errorCode));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Server-owned settings for the native DingTalk browser adapter.
|
||||
*
|
||||
* <p>The provider is deliberately disabled by default. The authority is a
|
||||
* stable enterprise/application identity domain, not a URL supplied by an
|
||||
* upstream response.</p>
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.auth.dingtalk")
|
||||
public class DingTalkProperties {
|
||||
|
||||
private boolean enabled;
|
||||
private String authority;
|
||||
private String displayName = "DingTalk";
|
||||
private Duration connectTimeout = Duration.ofSeconds(5);
|
||||
private Duration readTimeout = Duration.ofSeconds(10);
|
||||
private int maxResponseBytes = 1024 * 1024;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getAuthority() {
|
||||
return authority;
|
||||
}
|
||||
|
||||
public void setAuthority(String authority) {
|
||||
this.authority = authority;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public Duration getConnectTimeout() {
|
||||
return connectTimeout;
|
||||
}
|
||||
|
||||
public void setConnectTimeout(Duration connectTimeout) {
|
||||
this.connectTimeout = connectTimeout;
|
||||
}
|
||||
|
||||
public Duration getReadTimeout() {
|
||||
return readTimeout;
|
||||
}
|
||||
|
||||
public void setReadTimeout(Duration readTimeout) {
|
||||
this.readTimeout = readTimeout;
|
||||
}
|
||||
|
||||
public int getMaxResponseBytes() {
|
||||
return maxResponseBytes;
|
||||
}
|
||||
|
||||
public void setMaxResponseBytes(int maxResponseBytes) {
|
||||
this.maxResponseBytes = maxResponseBytes;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
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.OAuth2AccessToken.TokenType;
|
||||
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.RestTemplate;
|
||||
|
||||
/** Adapts DingTalk's JSON authorization-code token exchange. */
|
||||
@Component
|
||||
public final class DingTalkTokenResponseClient
|
||||
implements OAuth2AccessTokenResponseClient<
|
||||
OAuth2AuthorizationCodeGrantRequest> {
|
||||
|
||||
private static final Duration MAX_TIMEOUT = Duration.ofMinutes(1);
|
||||
private static final int MIN_RESPONSE_BYTES = 1024;
|
||||
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
private static final long MAX_TOKEN_LIFETIME_SECONDS = 86_400L;
|
||||
|
||||
private final DingTalkProperties properties;
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
public DingTalkTokenResponseClient(
|
||||
DingTalkProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
this(
|
||||
properties,
|
||||
objectMapper,
|
||||
buildRestTemplate(properties));
|
||||
}
|
||||
|
||||
DingTalkTokenResponseClient(
|
||||
DingTalkProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
RestTemplate restTemplate) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AccessTokenResponse getTokenResponse(
|
||||
OAuth2AuthorizationCodeGrantRequest request) {
|
||||
if (request == null
|
||||
|| !properties.isEnabled()
|
||||
|| !DingTalkOAuth2Constants.hasTrustedRegistration(
|
||||
request.getClientRegistration())
|
||||
|| !hasRealClientCredentials(request)) {
|
||||
throw failure("dingtalk_provider_misconfigured");
|
||||
}
|
||||
requireDuration(properties.getConnectTimeout());
|
||||
requireDuration(properties.getReadTimeout());
|
||||
int maximumBytes = requireMaximumBytes();
|
||||
String code = request.getAuthorizationExchange() == null
|
||||
|| request.getAuthorizationExchange()
|
||||
.getAuthorizationResponse() == null
|
||||
? null
|
||||
: request.getAuthorizationExchange()
|
||||
.getAuthorizationResponse()
|
||||
.getCode();
|
||||
if (code == null || code.isBlank()) {
|
||||
throw failure("authorization_code_missing");
|
||||
}
|
||||
|
||||
Map<String, String> body = Map.of(
|
||||
"clientId",
|
||||
request.getClientRegistration().getClientId(),
|
||||
"clientSecret",
|
||||
request.getClientRegistration().getClientSecret(),
|
||||
"code",
|
||||
code,
|
||||
"grantType",
|
||||
"authorization_code");
|
||||
String responseBody;
|
||||
try {
|
||||
responseBody = restTemplate.execute(
|
||||
DingTalkOAuth2Constants.TOKEN_URI,
|
||||
HttpMethod.POST,
|
||||
httpRequest -> {
|
||||
httpRequest.getHeaders().setContentType(
|
||||
MediaType.APPLICATION_JSON);
|
||||
byte[] encoded = objectMapper.writeValueAsBytes(body);
|
||||
httpRequest.getBody().write(encoded);
|
||||
},
|
||||
response -> {
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw failure("token_response_rejected");
|
||||
}
|
||||
return readLimited(response.getBody(), maximumBytes);
|
||||
});
|
||||
} catch (OAuth2AuthenticationException exception) {
|
||||
throw exception;
|
||||
} catch (RestClientException exception) {
|
||||
throw failure("token_request_failed");
|
||||
}
|
||||
if (responseBody == null) {
|
||||
throw failure("token_response_empty");
|
||||
}
|
||||
|
||||
JsonNode response;
|
||||
try {
|
||||
response = objectMapper.readTree(responseBody);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw failure("token_response_invalid");
|
||||
}
|
||||
String accessToken = text(response, "accessToken");
|
||||
JsonNode expires = response.get("expireIn");
|
||||
if (accessToken == null
|
||||
|| expires == null
|
||||
|| !expires.isIntegralNumber()
|
||||
|| !expires.canConvertToLong()) {
|
||||
throw failure("token_response_invalid");
|
||||
}
|
||||
long lifetime = expires.longValue();
|
||||
if (lifetime <= 0 || lifetime > MAX_TOKEN_LIFETIME_SECONDS) {
|
||||
throw failure("token_response_invalid");
|
||||
}
|
||||
return OAuth2AccessTokenResponse.withToken(accessToken)
|
||||
.tokenType(TokenType.BEARER)
|
||||
.expiresIn(lifetime)
|
||||
.additionalParameters(Map.of("expireIn", lifetime))
|
||||
.build();
|
||||
}
|
||||
|
||||
private String text(JsonNode object, String field) {
|
||||
JsonNode value = object == null ? null : object.get(field);
|
||||
if (value == null || !value.isTextual() || value.textValue().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.textValue();
|
||||
}
|
||||
|
||||
private String readLimited(InputStream input, int maximumBytes)
|
||||
throws IOException {
|
||||
if (input == null) {
|
||||
throw failure("token_response_empty");
|
||||
}
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream(
|
||||
Math.min(maximumBytes, 8192));
|
||||
byte[] buffer = new byte[8192];
|
||||
int total = 0;
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
total += read;
|
||||
if (total > maximumBytes) {
|
||||
throw failure("token_response_too_large");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private int requireMaximumBytes() {
|
||||
int value = properties.getMaxResponseBytes();
|
||||
if (value < MIN_RESPONSE_BYTES || value > MAX_RESPONSE_BYTES) {
|
||||
throw failure("dingtalk_provider_misconfigured");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private boolean hasRealClientCredentials(
|
||||
OAuth2AuthorizationCodeGrantRequest request) {
|
||||
String clientId = request.getClientRegistration().getClientId();
|
||||
String clientSecret = request.getClientRegistration().getClientSecret();
|
||||
return isRealCredential(clientId) && isRealCredential(clientSecret);
|
||||
}
|
||||
|
||||
private boolean isRealCredential(String value) {
|
||||
return value != null
|
||||
&& !value.isBlank()
|
||||
&& !value.toLowerCase(java.util.Locale.ROOT)
|
||||
.contains("placeholder");
|
||||
}
|
||||
|
||||
private void requireDuration(Duration value) {
|
||||
if (value == null
|
||||
|| value.isZero()
|
||||
|| value.isNegative()
|
||||
|| value.compareTo(MAX_TIMEOUT) > 0) {
|
||||
throw failure("dingtalk_provider_misconfigured");
|
||||
}
|
||||
}
|
||||
|
||||
private static RestTemplate buildRestTemplate(
|
||||
DingTalkProperties properties) {
|
||||
SimpleClientHttpRequestFactory factory =
|
||||
new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(properties.getConnectTimeout());
|
||||
factory.setReadTimeout(properties.getReadTimeout());
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
|
||||
private OAuth2AuthenticationException failure(String errorCode) {
|
||||
return new OAuth2AuthenticationException(new OAuth2Error(errorCode));
|
||||
}
|
||||
}
|
||||
|
|
@ -64,7 +64,6 @@ public class OAuthLoginFlowService {
|
|||
private final AccountMergeProviderProofService
|
||||
accountMergeProviderProofService;
|
||||
|
||||
@Autowired
|
||||
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
|
||||
TrustedProviderRouteResolver providerRouteResolver,
|
||||
ExternalIdentityLoginService identityLoginService,
|
||||
|
|
@ -84,6 +83,29 @@ public class OAuthLoginFlowService {
|
|||
new DefaultOAuth2UserService());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
OAuthLoginFlowService(
|
||||
List<OAuthClaimsExtractor> extractorList,
|
||||
TrustedProviderRouteResolver providerRouteResolver,
|
||||
ExternalIdentityLoginService identityLoginService,
|
||||
ExternalIdentityLinkService identityLinkService,
|
||||
IdentityLinkSessionManager identityLinkSessionManager,
|
||||
AccountMergeSessionManager accountMergeSessionManager,
|
||||
AccountMergeProviderProofService
|
||||
accountMergeProviderProofService,
|
||||
ProviderAwareOAuth2UserService providerAwareUserService) {
|
||||
this(
|
||||
extractorList,
|
||||
providerRouteResolver,
|
||||
identityLoginService,
|
||||
identityLinkService,
|
||||
identityLinkSessionManager,
|
||||
accountMergeSessionManager,
|
||||
accountMergeProviderProofService,
|
||||
(OAuth2UserService<OAuth2UserRequest, OAuth2User>)
|
||||
providerAwareUserService);
|
||||
}
|
||||
|
||||
OAuthLoginFlowService(
|
||||
List<OAuthClaimsExtractor> extractorList,
|
||||
TrustedProviderRouteResolver providerRouteResolver,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Selects the trusted token exchange for each browser registration. */
|
||||
@Component
|
||||
public final class ProviderAwareAccessTokenResponseClient
|
||||
implements OAuth2AccessTokenResponseClient<
|
||||
OAuth2AuthorizationCodeGrantRequest> {
|
||||
|
||||
private final OAuth2AccessTokenResponseClient<
|
||||
OAuth2AuthorizationCodeGrantRequest> standardDelegate;
|
||||
private final OAuth2AccessTokenResponseClient<
|
||||
OAuth2AuthorizationCodeGrantRequest> dingTalkDelegate;
|
||||
|
||||
@Autowired
|
||||
public ProviderAwareAccessTokenResponseClient(
|
||||
@Qualifier("dingTalkTokenResponseClient")
|
||||
OAuth2AccessTokenResponseClient<
|
||||
OAuth2AuthorizationCodeGrantRequest> dingTalkDelegate) {
|
||||
this(
|
||||
new DefaultAuthorizationCodeTokenResponseClient(),
|
||||
dingTalkDelegate);
|
||||
}
|
||||
|
||||
ProviderAwareAccessTokenResponseClient(
|
||||
OAuth2AccessTokenResponseClient<
|
||||
OAuth2AuthorizationCodeGrantRequest> standardDelegate,
|
||||
OAuth2AccessTokenResponseClient<
|
||||
OAuth2AuthorizationCodeGrantRequest> dingTalkDelegate) {
|
||||
this.standardDelegate = standardDelegate;
|
||||
this.dingTalkDelegate = dingTalkDelegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AccessTokenResponse getTokenResponse(
|
||||
OAuth2AuthorizationCodeGrantRequest request) {
|
||||
if (request != null
|
||||
&& DingTalkOAuth2Constants.REGISTRATION_ID.equals(
|
||||
request.getClientRegistration().getRegistrationId())) {
|
||||
return dingTalkDelegate.getTokenResponse(request);
|
||||
}
|
||||
return standardDelegate.getTokenResponse(request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Selects the trusted user-info transport for each browser registration. */
|
||||
@Component
|
||||
final class ProviderAwareOAuth2UserService
|
||||
implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
|
||||
|
||||
private final OAuth2UserService<OAuth2UserRequest, OAuth2User>
|
||||
standardDelegate;
|
||||
private final OAuth2UserService<OAuth2UserRequest, OAuth2User>
|
||||
dingTalkDelegate;
|
||||
|
||||
@Autowired
|
||||
ProviderAwareOAuth2UserService(
|
||||
@Qualifier("dingTalkOAuth2UserService")
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User>
|
||||
dingTalkDelegate) {
|
||||
this(new DefaultOAuth2UserService(), dingTalkDelegate);
|
||||
}
|
||||
|
||||
ProviderAwareOAuth2UserService(
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User>
|
||||
standardDelegate,
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User>
|
||||
dingTalkDelegate) {
|
||||
this.standardDelegate = standardDelegate;
|
||||
this.dingTalkDelegate = dingTalkDelegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2User loadUser(OAuth2UserRequest request) {
|
||||
if (request != null
|
||||
&& DingTalkOAuth2Constants.REGISTRATION_ID.equals(
|
||||
request.getClientRegistration().getRegistrationId())) {
|
||||
return dingTalkDelegate.loadUser(request);
|
||||
}
|
||||
return standardDelegate.loadUser(request);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ package com.iflytek.skillhub.auth.identity;
|
|||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2Constants;
|
||||
import com.iflytek.skillhub.auth.oauth.DingTalkProperties;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -159,6 +161,108 @@ class StaticTrustedProviderDescriptorSourceTest {
|
|||
.isEqualTo(IdentityFailureCode.PROVIDER_DISABLED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesDingTalkOnlyWhenEnabledAndUsesTypedUnionIdAliases() {
|
||||
ClientRegistration dingtalk = dingtalk();
|
||||
DingTalkProperties dingTalkProperties = new DingTalkProperties();
|
||||
dingTalkProperties.setEnabled(true);
|
||||
dingTalkProperties.setAuthority("dingtalk.corp");
|
||||
OAuth2ClientProperties properties =
|
||||
new OAuth2ClientProperties();
|
||||
properties.getRegistration().put(
|
||||
"dingtalk",
|
||||
properties("client-id", "client-secret", "DingTalk"));
|
||||
StaticTrustedProviderDescriptorSource source =
|
||||
new StaticTrustedProviderDescriptorSource(
|
||||
properties,
|
||||
new InMemoryClientRegistrationRepository(dingtalk),
|
||||
Set.of("dingtalk"),
|
||||
new IdentityProviderPolicyProperties(),
|
||||
dingTalkProperties);
|
||||
|
||||
ProviderDescriptor descriptor = descriptor(source, "dingtalk");
|
||||
|
||||
assertThat(descriptor.protocol()).isEqualTo("dingtalk-oauth2");
|
||||
assertThat(descriptor.canonicalAuthority())
|
||||
.isEqualTo("dingtalk.corp");
|
||||
assertThat(descriptor.primarySubjectType())
|
||||
.isEqualTo("dingtalk_union_id");
|
||||
assertThat(descriptor.canonicalizerFor("dingtalk_union_id"))
|
||||
.isEqualTo(SubjectCanonicalizer.EXACT);
|
||||
assertThat(descriptor.canonicalizerFor("dingtalk_open_id"))
|
||||
.isEqualTo(SubjectCanonicalizer.EXACT);
|
||||
assertThat(descriptor.canonicalizerFor("dingtalk_user_id"))
|
||||
.isEqualTo(SubjectCanonicalizer.EXACT);
|
||||
assertThat(descriptor.emailAssuranceLimit())
|
||||
.isEqualTo(EmailAssurance.PROVIDER_ASSERTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hidesDingTalkWhenDisabledOrEndpointsAreNotOfficial() {
|
||||
ClientRegistration dingtalk = dingtalk();
|
||||
OAuth2ClientProperties properties = new OAuth2ClientProperties();
|
||||
properties.getRegistration().put(
|
||||
"dingtalk",
|
||||
properties("client-id", "client-secret", "DingTalk"));
|
||||
|
||||
StaticTrustedProviderDescriptorSource disabled =
|
||||
new StaticTrustedProviderDescriptorSource(
|
||||
properties,
|
||||
new InMemoryClientRegistrationRepository(dingtalk),
|
||||
Set.of("dingtalk"),
|
||||
new IdentityProviderPolicyProperties(),
|
||||
new DingTalkProperties());
|
||||
assertThat(disabled.configuredDescriptors()).isEmpty();
|
||||
|
||||
DingTalkProperties enabled = new DingTalkProperties();
|
||||
enabled.setEnabled(true);
|
||||
enabled.setAuthority("dingtalk.corp");
|
||||
ClientRegistration altered = ClientRegistration.withRegistrationId(
|
||||
"dingtalk")
|
||||
.clientId("client-id")
|
||||
.clientSecret("client-secret")
|
||||
.clientName("DingTalk")
|
||||
.authorizationGrantType(
|
||||
AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.authorizationUri("https://attacker.example/authorize")
|
||||
.tokenUri(DingTalkOAuth2Constants.TOKEN_URI)
|
||||
.userInfoUri(DingTalkOAuth2Constants.USER_INFO_URI)
|
||||
.userNameAttributeName(
|
||||
DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)
|
||||
.build();
|
||||
StaticTrustedProviderDescriptorSource alteredSource =
|
||||
new StaticTrustedProviderDescriptorSource(
|
||||
properties,
|
||||
new InMemoryClientRegistrationRepository(altered),
|
||||
Set.of("dingtalk"),
|
||||
new IdentityProviderPolicyProperties(),
|
||||
enabled);
|
||||
assertThat(alteredSource.configuredDescriptors()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void hidesDingTalkWhenClientSecretIsMissingOrPlaceholder() {
|
||||
ClientRegistration dingtalk = dingtalk();
|
||||
OAuth2ClientProperties properties = new OAuth2ClientProperties();
|
||||
properties.getRegistration().put(
|
||||
"dingtalk",
|
||||
properties("client-id", "placeholder", "DingTalk"));
|
||||
DingTalkProperties enabled = new DingTalkProperties();
|
||||
enabled.setEnabled(true);
|
||||
enabled.setAuthority("dingtalk.corp");
|
||||
|
||||
StaticTrustedProviderDescriptorSource source =
|
||||
new StaticTrustedProviderDescriptorSource(
|
||||
properties,
|
||||
new InMemoryClientRegistrationRepository(dingtalk),
|
||||
Set.of("dingtalk"),
|
||||
new IdentityProviderPolicyProperties(),
|
||||
enabled);
|
||||
|
||||
assertThat(source.configuredDescriptors()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRegistrationThatIsBothOidcAndBackedByOAuthExtractor() {
|
||||
ClientRegistration ambiguous = oidc(
|
||||
|
|
@ -202,9 +306,17 @@ class StaticTrustedProviderDescriptorSourceTest {
|
|||
private static OAuth2ClientProperties.Registration properties(
|
||||
String clientId,
|
||||
String clientName) {
|
||||
return properties(clientId, "client-secret", clientName);
|
||||
}
|
||||
|
||||
private static OAuth2ClientProperties.Registration properties(
|
||||
String clientId,
|
||||
String clientSecret,
|
||||
String clientName) {
|
||||
OAuth2ClientProperties.Registration registration =
|
||||
new OAuth2ClientProperties.Registration();
|
||||
registration.setClientId(clientId);
|
||||
registration.setClientSecret(clientSecret);
|
||||
registration.setClientName(clientName);
|
||||
return registration;
|
||||
}
|
||||
|
|
@ -226,6 +338,24 @@ class StaticTrustedProviderDescriptorSourceTest {
|
|||
.build();
|
||||
}
|
||||
|
||||
private static ClientRegistration dingtalk() {
|
||||
return ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("client-id")
|
||||
.clientSecret("client-secret")
|
||||
.clientName("DingTalk")
|
||||
.authorizationGrantType(
|
||||
AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope("openid")
|
||||
.authorizationUri(
|
||||
DingTalkOAuth2Constants.AUTHORIZATION_URI)
|
||||
.tokenUri(DingTalkOAuth2Constants.TOKEN_URI)
|
||||
.userInfoUri(DingTalkOAuth2Constants.USER_INFO_URI)
|
||||
.userNameAttributeName(
|
||||
DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static ClientRegistration gitlab(String authority) {
|
||||
return ClientRegistration.withRegistrationId("gitlab")
|
||||
.clientId("client-id")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
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.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
|
||||
class DingTalkClaimsExtractorTest {
|
||||
|
||||
@Test
|
||||
void mapsStableUnionIdAndSameResponseAliasesToUnifiedIdentityFacts() {
|
||||
DingTalkClaimsExtractor extractor = new DingTalkClaimsExtractor();
|
||||
|
||||
ProviderAuthenticationResult result = extractor.extract(
|
||||
userRequest(),
|
||||
new DefaultOAuth2User(
|
||||
List.of(),
|
||||
Map.of(
|
||||
"unionId", "union-123",
|
||||
"openId", "open-456",
|
||||
"userId", "user-789",
|
||||
"nick", "Alice",
|
||||
"email", "alice@example.com",
|
||||
"avatarUrl", "https://example.com/alice.png"),
|
||||
"unionId"));
|
||||
|
||||
assertThat(result.primarySubject())
|
||||
.isEqualTo(new SubjectCandidate(
|
||||
"dingtalk_union_id", "union-123"));
|
||||
assertThat(result.alternateSubjects()).containsExactly(
|
||||
new SubjectCandidate("dingtalk_open_id", "open-456"),
|
||||
new SubjectCandidate("dingtalk_user_id", "user-789"));
|
||||
assertThat(result.attributes().get("dingtalk_email"))
|
||||
.singleElement()
|
||||
.satisfies(value -> {
|
||||
assertThat(value.value()).isEqualTo("alice@example.com");
|
||||
assertThat(value.trust())
|
||||
.isEqualTo(ProviderAttributeTrust.ASSERTED);
|
||||
});
|
||||
assertThat(result.evidence().protocol())
|
||||
.isEqualTo("dingtalk-oauth2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsResponseWithoutStableUnionIdEvenWhenOtherIdsExist() {
|
||||
DingTalkClaimsExtractor extractor = new DingTalkClaimsExtractor();
|
||||
DefaultOAuth2User upstreamUser = new DefaultOAuth2User(
|
||||
List.of(),
|
||||
Map.of(
|
||||
"openId", "open-456",
|
||||
"userId", "user-789"),
|
||||
"openId");
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
extractor.extract(userRequest(), upstreamUser))
|
||||
.isInstanceOfSatisfying(
|
||||
OAuth2AuthenticationException.class,
|
||||
exception -> assertThat(
|
||||
exception.getError().getErrorCode())
|
||||
.isEqualTo("missing_stable_subject"));
|
||||
}
|
||||
|
||||
private static OAuth2UserRequest userRequest() {
|
||||
ClientRegistration registration =
|
||||
ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("client-id")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(
|
||||
AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri(
|
||||
"{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope("openid")
|
||||
.authorizationUri(
|
||||
DingTalkOAuth2Constants.AUTHORIZATION_URI)
|
||||
.tokenUri(DingTalkOAuth2Constants.TOKEN_URI)
|
||||
.userInfoUri(
|
||||
DingTalkOAuth2Constants.USER_INFO_URI)
|
||||
.userNameAttributeName("dingtalkSubject")
|
||||
.clientName("DingTalk")
|
||||
.build();
|
||||
Instant issuedAt = Instant.parse("2026-08-03T00:00:00Z");
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"token-123",
|
||||
issuedAt,
|
||||
issuedAt.plusSeconds(3600));
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
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.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.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.RestTemplate;
|
||||
|
||||
class DingTalkOAuth2UserServiceTest {
|
||||
|
||||
@Test
|
||||
void loadsOfficialUserInfoWithDingTalkAccessTokenHeader() {
|
||||
DingTalkProperties properties = enabledProperties();
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
MockRestServiceServer server =
|
||||
MockRestServiceServer.bindTo(restTemplate).build();
|
||||
server.expect(requestTo(DingTalkOAuth2Constants.USER_INFO_URI))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(
|
||||
DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER,
|
||||
"token-123"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"unionId":"union-123",
|
||||
"openId":"open-456",
|
||||
"userId":"user-789",
|
||||
"nick":"Alice",
|
||||
"email":"alice@example.com"
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
OAuth2User user = new DingTalkOAuth2UserService(
|
||||
properties,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper(),
|
||||
restTemplate).loadUser(userRequest());
|
||||
|
||||
assertThat(user.getName()).isEqualTo("union-123");
|
||||
assertThat(user.getAttributes())
|
||||
.containsEntry("unionId", "union-123")
|
||||
.containsEntry("openId", "open-456");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledProviderFailsBeforeMakingUserInfoRequest() {
|
||||
DingTalkProperties properties = new DingTalkProperties();
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
MockRestServiceServer server =
|
||||
MockRestServiceServer.bindTo(restTemplate).build();
|
||||
|
||||
assertThatThrownBy(() -> new DingTalkOAuth2UserService(
|
||||
properties,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper(),
|
||||
restTemplate).loadUser(userRequest()))
|
||||
.isInstanceOfSatisfying(
|
||||
OAuth2AuthenticationException.class,
|
||||
exception -> assertThat(
|
||||
exception.getError().getErrorCode())
|
||||
.isEqualTo("dingtalk_provider_misconfigured"));
|
||||
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAccessTokenFailsBeforeMakingUserInfoRequest() {
|
||||
DingTalkProperties properties = enabledProperties();
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
MockRestServiceServer server =
|
||||
MockRestServiceServer.bindTo(restTemplate).build();
|
||||
OAuth2UserRequest request = mock(OAuth2UserRequest.class);
|
||||
when(request.getClientRegistration())
|
||||
.thenReturn(userRequest().getClientRegistration());
|
||||
when(request.getAccessToken()).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> new DingTalkOAuth2UserService(
|
||||
properties,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper(),
|
||||
restTemplate).loadUser(request))
|
||||
.isInstanceOfSatisfying(
|
||||
OAuth2AuthenticationException.class,
|
||||
exception -> assertThat(
|
||||
exception.getError().getErrorCode())
|
||||
.isEqualTo("access_token_missing"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedUserInfoResponseFailsWithoutParsingOrReturningUpstreamData() {
|
||||
DingTalkProperties properties = enabledProperties();
|
||||
properties.setMaxResponseBytes(1024);
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
MockRestServiceServer server =
|
||||
MockRestServiceServer.bindTo(restTemplate).build();
|
||||
server.expect(requestTo(DingTalkOAuth2Constants.USER_INFO_URI))
|
||||
.andRespond(withSuccess(
|
||||
"{" + "\"unionId\":\"union-123\",\"padding\":\""
|
||||
+ "x".repeat(1100) + "\"}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThatThrownBy(() -> new DingTalkOAuth2UserService(
|
||||
properties,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper(),
|
||||
restTemplate).loadUser(userRequest()))
|
||||
.isInstanceOfSatisfying(
|
||||
OAuth2AuthenticationException.class,
|
||||
exception -> assertThat(
|
||||
exception.getError().getErrorCode())
|
||||
.isEqualTo("userinfo_response_too_large"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRegistrationWithUntrustedAuthorizationEndpointBeforeNetwork() {
|
||||
DingTalkProperties properties = enabledProperties();
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
MockRestServiceServer server =
|
||||
MockRestServiceServer.bindTo(restTemplate).build();
|
||||
|
||||
assertThatThrownBy(() -> new DingTalkOAuth2UserService(
|
||||
properties,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper(),
|
||||
restTemplate).loadUser(userRequest(
|
||||
"https://attacker.example/authorize",
|
||||
DingTalkOAuth2Constants.TOKEN_URI,
|
||||
DingTalkOAuth2Constants.USER_INFO_URI)))
|
||||
.isInstanceOfSatisfying(
|
||||
OAuth2AuthenticationException.class,
|
||||
exception -> assertThat(
|
||||
exception.getError().getErrorCode())
|
||||
.isEqualTo("dingtalk_provider_misconfigured"));
|
||||
|
||||
server.verify();
|
||||
}
|
||||
|
||||
private static DingTalkProperties enabledProperties() {
|
||||
DingTalkProperties properties = new DingTalkProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setAuthority("dingtalk.corp");
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static OAuth2UserRequest userRequest() {
|
||||
return userRequest(
|
||||
DingTalkOAuth2Constants.AUTHORIZATION_URI,
|
||||
DingTalkOAuth2Constants.TOKEN_URI,
|
||||
DingTalkOAuth2Constants.USER_INFO_URI);
|
||||
}
|
||||
|
||||
private static OAuth2UserRequest userRequest(
|
||||
String authorizationUri,
|
||||
String tokenUri,
|
||||
String userInfoUri) {
|
||||
ClientRegistration registration =
|
||||
ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("client-id")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(
|
||||
AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri(
|
||||
"{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope("openid")
|
||||
.authorizationUri(authorizationUri)
|
||||
.tokenUri(tokenUri)
|
||||
.userInfoUri(userInfoUri)
|
||||
.userNameAttributeName("dingtalkSubject")
|
||||
.clientName("DingTalk")
|
||||
.build();
|
||||
Instant issuedAt = Instant.parse("2026-08-03T00:00:00Z");
|
||||
OAuth2AccessToken token = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"token-123",
|
||||
issuedAt,
|
||||
issuedAt.plusSeconds(3600));
|
||||
return new OAuth2UserRequest(registration, token);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
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.content;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
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.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 {
|
||||
|
||||
@Test
|
||||
void exchangesAuthorizationCodeAsDingTalkJsonAndValidatesExpiry() {
|
||||
DingTalkProperties properties = enabledProperties();
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
MockRestServiceServer server =
|
||||
MockRestServiceServer.bindTo(restTemplate).build();
|
||||
server.expect(requestTo(DingTalkOAuth2Constants.TOKEN_URI))
|
||||
.andExpect(method(HttpMethod.POST))
|
||||
.andExpect(header(
|
||||
HttpHeaders.CONTENT_TYPE,
|
||||
MediaType.APPLICATION_JSON_VALUE))
|
||||
.andExpect(content().json(
|
||||
"""
|
||||
{
|
||||
"clientId":"client-id",
|
||||
"clientSecret":"client-secret",
|
||||
"code":"code-123",
|
||||
"grantType":"authorization_code"
|
||||
}
|
||||
"""))
|
||||
.andRespond(withSuccess(
|
||||
"{\"accessToken\":\"access-123\",\"expireIn\":3600}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
OAuth2AccessTokenResponse response = new DingTalkTokenResponseClient(
|
||||
properties,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper(),
|
||||
restTemplate).getTokenResponse(request("code-123"));
|
||||
|
||||
assertThat(response.getAccessToken().getTokenValue())
|
||||
.isEqualTo("access-123");
|
||||
assertThat(response.getAccessToken().getExpiresAt())
|
||||
.isAfter(Instant.now());
|
||||
assertThat(response.getAdditionalParameters())
|
||||
.containsEntry("expireIn", 3600L);
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonPositiveOrUnreasonablyLongExpiry() {
|
||||
DingTalkProperties properties = enabledProperties();
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
MockRestServiceServer server =
|
||||
MockRestServiceServer.bindTo(restTemplate).build();
|
||||
server.expect(requestTo(DingTalkOAuth2Constants.TOKEN_URI))
|
||||
.andRespond(withSuccess(
|
||||
"{\"accessToken\":\"access-123\",\"expireIn\":0}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThatThrownBy(() -> new DingTalkTokenResponseClient(
|
||||
properties,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper(),
|
||||
restTemplate).getTokenResponse(request("code-123")))
|
||||
.isInstanceOfSatisfying(
|
||||
OAuth2AuthenticationException.class,
|
||||
exception -> assertThat(
|
||||
exception.getError().getErrorCode())
|
||||
.isEqualTo("token_response_invalid"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedTokenResponseWithoutIncludingResponseBodyInError() {
|
||||
DingTalkProperties properties = enabledProperties();
|
||||
properties.setMaxResponseBytes(1024);
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
MockRestServiceServer server =
|
||||
MockRestServiceServer.bindTo(restTemplate).build();
|
||||
server.expect(requestTo(DingTalkOAuth2Constants.TOKEN_URI))
|
||||
.andRespond(withSuccess(
|
||||
"{\"accessToken\":\"access-123\",\"padding\":\""
|
||||
+ "x".repeat(1100) + "\"}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThatThrownBy(() -> new DingTalkTokenResponseClient(
|
||||
properties,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper(),
|
||||
restTemplate).getTokenResponse(request("code-123")))
|
||||
.isInstanceOfSatisfying(
|
||||
OAuth2AuthenticationException.class,
|
||||
exception -> {
|
||||
assertThat(exception.getError().getErrorCode())
|
||||
.isEqualTo("token_response_too_large");
|
||||
assertThat(exception.toString())
|
||||
.doesNotContain("access-123")
|
||||
.doesNotContain("padding");
|
||||
});
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsIncompleteClientCredentialsBeforeMakingTokenRequest() {
|
||||
DingTalkProperties properties = enabledProperties();
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
MockRestServiceServer server =
|
||||
MockRestServiceServer.bindTo(restTemplate).build();
|
||||
|
||||
assertThatThrownBy(() -> new DingTalkTokenResponseClient(
|
||||
properties,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper(),
|
||||
restTemplate).getTokenResponse(
|
||||
request("code-123", "client-id", "")))
|
||||
.isInstanceOfSatisfying(
|
||||
OAuth2AuthenticationException.class,
|
||||
exception -> assertThat(
|
||||
exception.getError().getErrorCode())
|
||||
.isEqualTo("dingtalk_provider_misconfigured"));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
private static DingTalkProperties enabledProperties() {
|
||||
DingTalkProperties properties = new DingTalkProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setAuthority("dingtalk.corp");
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static OAuth2AuthorizationCodeGrantRequest request(String code) {
|
||||
return request(code, "client-id", "client-secret");
|
||||
}
|
||||
|
||||
private static OAuth2AuthorizationCodeGrantRequest request(
|
||||
String code,
|
||||
String clientId,
|
||||
String clientSecret) {
|
||||
ClientRegistration registration =
|
||||
ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId(clientId)
|
||||
.clientSecret(clientSecret)
|
||||
.authorizationGrantType(
|
||||
AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri(
|
||||
"{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope("openid")
|
||||
.authorizationUri(
|
||||
DingTalkOAuth2Constants.AUTHORIZATION_URI)
|
||||
.tokenUri(DingTalkOAuth2Constants.TOKEN_URI)
|
||||
.userInfoUri(
|
||||
DingTalkOAuth2Constants.USER_INFO_URI)
|
||||
.userNameAttributeName(
|
||||
DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)
|
||||
.clientName("DingTalk")
|
||||
.build();
|
||||
OAuth2AuthorizationRequest authorizationRequest =
|
||||
OAuth2AuthorizationRequest.authorizationCode()
|
||||
.authorizationUri(
|
||||
DingTalkOAuth2Constants.AUTHORIZATION_URI)
|
||||
.clientId("client-id")
|
||||
.redirectUri(
|
||||
"https://skillhub.example/login/oauth2/code/dingtalk")
|
||||
.scope(DingTalkOAuth2Constants.AUTHORIZATION_SCOPE)
|
||||
.state("state-123")
|
||||
.build();
|
||||
OAuth2AuthorizationResponse authorizationResponse =
|
||||
OAuth2AuthorizationResponse.success(code)
|
||||
.redirectUri(
|
||||
"https://skillhub.example/login/oauth2/code/dingtalk")
|
||||
.state("state-123")
|
||||
.build();
|
||||
return new OAuth2AuthorizationCodeGrantRequest(
|
||||
registration,
|
||||
new OAuth2AuthorizationExchange(
|
||||
authorizationRequest,
|
||||
authorizationResponse));
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,9 @@ class OAuthAdapterBoundaryTest {
|
|||
OAuthClaimsExtractor.class,
|
||||
GitHubClaimsExtractor.class,
|
||||
GitLabClaimsExtractor.class,
|
||||
DingTalkClaimsExtractor.class,
|
||||
DingTalkOAuth2UserService.class,
|
||||
DingTalkTokenResponseClient.class,
|
||||
CustomOAuth2UserService.class,
|
||||
CustomOidcUserService.class);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import static org.mockito.Mockito.never;
|
|||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
import com.iflytek.skillhub.auth.identity.ExternalIdentityLoginService;
|
||||
import com.iflytek.skillhub.auth.identity.ExternalIdentityLinkService;
|
||||
|
|
@ -25,6 +29,8 @@ import com.iflytek.skillhub.auth.identity.IdentityLoginOutcome;
|
|||
import com.iflytek.skillhub.auth.identity.IdentityLinkSessionManager;
|
||||
import com.iflytek.skillhub.auth.identity.ProtocolAuthenticationEvidence;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAttributeTrust;
|
||||
import com.iflytek.skillhub.auth.identity.ProviderAttributeValue;
|
||||
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandle;
|
||||
import com.iflytek.skillhub.auth.identity.ResolvedProviderHandleTestFixture;
|
||||
import com.iflytek.skillhub.auth.identity.SubjectCandidate;
|
||||
|
|
@ -48,16 +54,21 @@ import java.util.UUID;
|
|||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
|
||||
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.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
class OAuthLoginFlowServiceTest {
|
||||
|
||||
|
|
@ -161,6 +172,95 @@ class OAuthLoginFlowServiceTest {
|
|||
verify(extractor, never()).authenticate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dingtalkCallbackUsesNativeUserInfoAndUnifiedIdentityCore() {
|
||||
OAuthClaimsExtractor extractor = new DingTalkClaimsExtractor();
|
||||
TrustedProviderRouteResolver resolver =
|
||||
mock(TrustedProviderRouteResolver.class);
|
||||
ExternalIdentityLoginService identityLoginService =
|
||||
mock(ExternalIdentityLoginService.class);
|
||||
ClientRegistration registration = dingtalkRegistration();
|
||||
ResolvedProviderHandle provider =
|
||||
ResolvedProviderHandleTestFixture.handle("dingtalk");
|
||||
when(resolver.resolve(registration)).thenReturn(provider);
|
||||
when(identityLoginService.authenticate(
|
||||
eq(provider),
|
||||
any(ProviderAuthenticationResult.class),
|
||||
eq(context())))
|
||||
.thenReturn(new IdentityLoginOutcome.Authenticated(
|
||||
principal(),
|
||||
false,
|
||||
false));
|
||||
|
||||
DingTalkProperties properties = new DingTalkProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setAuthority("dingtalk.corp");
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
MockRestServiceServer server =
|
||||
MockRestServiceServer.bindTo(restTemplate).build();
|
||||
server.expect(requestTo(DingTalkOAuth2Constants.USER_INFO_URI))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(
|
||||
DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER,
|
||||
"access-token"))
|
||||
.andRespond(withSuccess(
|
||||
"""
|
||||
{
|
||||
"unionId":"union-123",
|
||||
"openId":"open-456",
|
||||
"userId":"user-789",
|
||||
"nick":"Alice",
|
||||
"email":"alice@example.com"
|
||||
}
|
||||
""",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"access-token",
|
||||
Instant.parse("2026-08-03T00:00:00Z"),
|
||||
Instant.parse("2026-08-03T01:00:00Z"));
|
||||
OAuth2UserRequest request = new OAuth2UserRequest(
|
||||
registration,
|
||||
accessToken);
|
||||
OAuthLoginFlowService service = new OAuthLoginFlowService(
|
||||
List.of(extractor),
|
||||
resolver,
|
||||
identityLoginService,
|
||||
mock(ExternalIdentityLinkService.class),
|
||||
mock(IdentityLinkSessionManager.class),
|
||||
mock(AccountMergeSessionManager.class),
|
||||
mock(AccountMergeProviderProofService.class),
|
||||
new DingTalkOAuth2UserService(
|
||||
properties,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper(),
|
||||
restTemplate));
|
||||
|
||||
service.loadLoginContext(request, context());
|
||||
|
||||
var result = org.mockito.ArgumentCaptor.forClass(
|
||||
ProviderAuthenticationResult.class);
|
||||
verify(identityLoginService).authenticate(
|
||||
eq(provider),
|
||||
result.capture(),
|
||||
eq(context()));
|
||||
assertThat(result.getValue().primarySubject())
|
||||
.isEqualTo(new SubjectCandidate(
|
||||
"dingtalk_union_id",
|
||||
"union-123"));
|
||||
assertThat(result.getValue().alternateSubjects())
|
||||
.containsExactly(
|
||||
new SubjectCandidate("dingtalk_open_id", "open-456"),
|
||||
new SubjectCandidate("dingtalk_user_id", "user-789"));
|
||||
assertThat(result.getValue().attributes())
|
||||
.containsEntry(
|
||||
"dingtalk_email",
|
||||
List.of(new ProviderAttributeValue(
|
||||
"alice@example.com",
|
||||
ProviderAttributeTrust.ASSERTED)));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticateReturnsPrincipalOnlyForAuthenticatedOutcome() {
|
||||
TrustedProviderRouteResolver resolver =
|
||||
|
|
@ -652,4 +752,22 @@ class OAuthLoginFlowServiceTest {
|
|||
.clientName("GitHub")
|
||||
.build();
|
||||
}
|
||||
|
||||
private static ClientRegistration dingtalkRegistration() {
|
||||
return ClientRegistration.withRegistrationId("dingtalk")
|
||||
.clientId("client-id")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(
|
||||
AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri(
|
||||
"{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope(DingTalkOAuth2Constants.AUTHORIZATION_SCOPE)
|
||||
.authorizationUri(DingTalkOAuth2Constants.AUTHORIZATION_URI)
|
||||
.tokenUri(DingTalkOAuth2Constants.TOKEN_URI)
|
||||
.userInfoUri(DingTalkOAuth2Constants.USER_INFO_URI)
|
||||
.userNameAttributeName(
|
||||
DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)
|
||||
.clientName("DingTalk")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
package com.iflytek.skillhub.auth.oauth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
class ProviderAwareOAuthDelegatesTest {
|
||||
|
||||
@Test
|
||||
void routesOnlyDingTalkRegistrationToNativeUserInfoAdapter() {
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User> standard = mock();
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User> dingtalk = mock();
|
||||
OAuth2UserRequest request = mock();
|
||||
OAuth2User result = mock();
|
||||
when(request.getClientRegistration())
|
||||
.thenReturn(registration("dingtalk"));
|
||||
when(dingtalk.loadUser(request)).thenReturn(result);
|
||||
|
||||
OAuth2User actual = new ProviderAwareOAuth2UserService(
|
||||
standard,
|
||||
dingtalk).loadUser(request);
|
||||
|
||||
assertThat(actual).isSameAs(result);
|
||||
verify(dingtalk).loadUser(request);
|
||||
verifyNoInteractions(standard);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesStandardUserInfoAdapterForOtherRegistrations() {
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User> standard = mock();
|
||||
OAuth2UserService<OAuth2UserRequest, OAuth2User> dingtalk = mock();
|
||||
OAuth2UserRequest request = mock();
|
||||
OAuth2User result = mock();
|
||||
when(request.getClientRegistration())
|
||||
.thenReturn(registration("github"));
|
||||
when(standard.loadUser(request)).thenReturn(result);
|
||||
|
||||
OAuth2User actual = new ProviderAwareOAuth2UserService(
|
||||
standard,
|
||||
dingtalk).loadUser(request);
|
||||
|
||||
assertThat(actual).isSameAs(result);
|
||||
verify(standard).loadUser(request);
|
||||
verifyNoInteractions(dingtalk);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesOnlyDingTalkRegistrationToNativeTokenAdapter() {
|
||||
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest>
|
||||
standard = mock();
|
||||
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest>
|
||||
dingtalk = mock();
|
||||
OAuth2AuthorizationCodeGrantRequest request = mock();
|
||||
OAuth2AccessTokenResponse result =
|
||||
OAuth2AccessTokenResponse.withToken("access-123")
|
||||
.tokenType(TokenType.BEARER)
|
||||
.build();
|
||||
when(request.getClientRegistration())
|
||||
.thenReturn(registration("dingtalk"));
|
||||
when(dingtalk.getTokenResponse(request)).thenReturn(result);
|
||||
|
||||
OAuth2AccessTokenResponse actual =
|
||||
new ProviderAwareAccessTokenResponseClient(
|
||||
standard,
|
||||
dingtalk).getTokenResponse(request);
|
||||
|
||||
assertThat(actual).isSameAs(result);
|
||||
verify(dingtalk).getTokenResponse(request);
|
||||
verifyNoInteractions(standard);
|
||||
}
|
||||
|
||||
private static ClientRegistration registration(String registrationId) {
|
||||
return ClientRegistration.withRegistrationId(registrationId)
|
||||
.clientId("client-id")
|
||||
.clientSecret("client-secret")
|
||||
.clientName(registrationId)
|
||||
.authorizationGrantType(
|
||||
AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.authorizationUri("https://login.example/authorize")
|
||||
.tokenUri("https://login.example/token")
|
||||
.userInfoUri("https://login.example/userinfo")
|
||||
.userNameAttributeName("id")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue