From b42ffde62881c15071754bfd17f8ef9b7d39f589 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:46:22 +0800 Subject: [PATCH 1/2] feat(auth): add DingTalk OAuth2 adapter to unified identity core Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.example | 13 ++ Makefile | 5 +- charts/skillhub/README.md | 24 ++ charts/skillhub/templates/configmap.yaml | 6 + charts/skillhub/templates/secret.yaml | 8 + .../skillhub/templates/server-deployment.yaml | 42 ++++ charts/skillhub/templates/validate.yaml | 9 + .../skillhub/tests/configuration-contracts.sh | 25 ++ charts/skillhub/values.schema.json | 17 +- charts/skillhub/values.yaml | 9 + compose.release.yml | 8 + deploy/k8s/base/backend-deployment.yaml | 46 ++++ deploy/k8s/base/configmap.yaml | 9 + deploy/k8s/base/secret.yaml.example | 4 + ...6-dingtalk-unified-identity-integration.md | 221 ++++++++++++++++++ scripts/dingtalk-smoke-test.sh | 52 +++++ scripts/tests/validate-release-config-test.sh | 27 +++ scripts/validate-release-config.sh | 38 +++ .../src/main/resources/application.yml | 20 ++ .../skillhub/auth/config/SecurityConfig.java | 9 + ...StaticTrustedProviderDescriptorSource.java | 127 +++++++++- .../auth/oauth/DingTalkClaimsExtractor.java | 141 +++++++++++ .../auth/oauth/DingTalkOAuth2Constants.java | 54 +++++ .../auth/oauth/DingTalkOAuth2UserService.java | 179 ++++++++++++++ .../auth/oauth/DingTalkProperties.java | 72 ++++++ .../oauth/DingTalkTokenResponseClient.java | 216 +++++++++++++++++ .../auth/oauth/OAuthLoginFlowService.java | 24 +- ...roviderAwareAccessTokenResponseClient.java | 51 ++++ .../oauth/ProviderAwareOAuth2UserService.java | 47 ++++ ...icTrustedProviderDescriptorSourceTest.java | 130 +++++++++++ .../oauth/DingTalkClaimsExtractorTest.java | 101 ++++++++ .../oauth/DingTalkOAuth2UserServiceTest.java | 197 ++++++++++++++++ .../DingTalkTokenResponseClientTest.java | 194 +++++++++++++++ .../auth/oauth/OAuthAdapterBoundaryTest.java | 3 + .../ProviderAwareOAuthDelegatesTest.java | 99 ++++++++ 35 files changed, 2220 insertions(+), 7 deletions(-) create mode 100644 docs/26-dingtalk-unified-identity-integration.md create mode 100755 scripts/dingtalk-smoke-test.sh create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkProperties.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAwareAccessTokenResponseClient.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAwareOAuth2UserService.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/ProviderAwareOAuthDelegatesTest.java diff --git a/.env.release.example b/.env.release.example index d71eea41..4a70a892 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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. diff --git a/Makefile b/Makefile index 95d62b57..f15b095a 100644 --- a/Makefile +++ b/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 diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index f059dce8..fcae38fc 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -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)。 + ### 副本数配置 | 参数 | 描述 | 默认值 | diff --git a/charts/skillhub/templates/configmap.yaml b/charts/skillhub/templates/configmap.yaml index 75b57960..0cbe23aa 100644 --- a/charts/skillhub/templates/configmap.yaml +++ b/charts/skillhub/templates/configmap.yaml @@ -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 }} diff --git a/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index c1085544..aed7bb34 100644 --- a/charts/skillhub/templates/secret.yaml +++ b/charts/skillhub/templates/secret.yaml @@ -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 }} diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 28fc24b4..1841c784 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -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 diff --git a/charts/skillhub/templates/validate.yaml b/charts/skillhub/templates/validate.yaml index 33e1d48a..90a51dee 100644 --- a/charts/skillhub/templates/validate.yaml +++ b/charts/skillhub/templates/validate.yaml @@ -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 -}} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index fcb4ba21..fd002d07 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -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 diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index b7faf293..3096c023 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -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" }, diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 2401a310..4db1c438 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -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: "" diff --git a/compose.release.yml b/compose.release.yml index b59e8b04..7add9669 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -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:-} diff --git a/deploy/k8s/base/backend-deployment.yaml b/deploy/k8s/base/backend-deployment.yaml index 5260dd28..c2f85aeb 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -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 diff --git a/deploy/k8s/base/configmap.yaml b/deploy/k8s/base/configmap.yaml index 33675024..833dc142 100644 --- a/deploy/k8s/base/configmap.yaml +++ b/deploy/k8s/base/configmap.yaml @@ -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 diff --git a/deploy/k8s/base/secret.yaml.example b/deploy/k8s/base/secret.yaml.example index 731affcb..256c3c20 100644 --- a/deploy/k8s/base/secret.yaml.example +++ b/deploy/k8s/base/secret.yaml.example @@ -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: "" diff --git a/docs/26-dingtalk-unified-identity-integration.md b/docs/26-dingtalk-unified-identity-integration.md new file mode 100644 index 00000000..2542f221 --- /dev/null +++ b/docs/26-dingtalk-unified-identity-integration.md @@ -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) diff --git a/scripts/dingtalk-smoke-test.sh b/scripts/dingtalk-smoke-test.sh new file mode 100755 index 00000000..610e92cd --- /dev/null +++ b/scripts/dingtalk-smoke-test.sh @@ -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 diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index 3ce09fa9..d1c8f372 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -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" diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 37d9cbfb..a3179bea 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -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 diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 5dd366c0..38af6a8c 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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: diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 786932e8..993803a3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -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)) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java index 38cdcc5b..6ffe7f28 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java @@ -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 descriptors; private final Map trustedRegistrations; private final IdentityProviderPolicyProperties policyProperties; + private final DingTalkProperties dingTalkProperties; @Autowired StaticTrustedProviderDescriptorSource( OAuth2ClientProperties properties, ClientRegistrationRepository registrationRepository, List 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 extractorCodes, IdentityProviderPolicyProperties policyProperties) { + this( + properties, + registrationRepository, + extractorCodes, + policyProperties, + new DingTalkProperties()); + } + + StaticTrustedProviderDescriptorSource( + OAuth2ClientProperties properties, + ClientRegistrationRepository registrationRepository, + Set extractorCodes, + IdentityProviderPolicyProperties policyProperties, + DingTalkProperties dingTalkProperties) { this.policyProperties = policyProperties; + this.dingTalkProperties = dingTalkProperties; Map resolvedDescriptors = new LinkedHashMap<>(); Map 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 displayNameAttributes, List emailAttributes, List 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 displayNameAttributes, + List emailAttributes, + List 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); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java new file mode 100644 index 00000000..b0653cf7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java @@ -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 source = oauthUser.getAttributes(); + String unionId = requireString( + source, + DingTalkOAuth2Constants.UNION_ID_ATTRIBUTE); + + List 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> 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 attributes) { + return requireString( + attributes, + DingTalkOAuth2Constants.UNION_ID_ATTRIBUTE); + } + + private static String requireString( + Map 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 aliases, + String type, + Object rawValue) { + String value = stringValue(rawValue); + if (value != null) { + aliases.add(new SubjectCandidate(type, value)); + } + } + + private static void put( + Map> 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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java new file mode 100644 index 00000000..8d5c3e7e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java @@ -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 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() { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java new file mode 100644 index 00000000..5794eeca --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -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. + * + *

This class only verifies protocol transport and returns upstream facts. + * It does not create an account, principal, role or session.

+ */ +@Component +public final class DingTalkOAuth2UserService + implements OAuth2UserService { + + 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> 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 attributes; + try { + attributes = objectMapper.readValue( + responseBody, + RESPONSE_TYPE); + } catch (IOException exception) { + throw failure("userinfo_response_invalid"); + } + String unionId = DingTalkClaimsExtractor.requireUnionId(attributes); + Map 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)); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkProperties.java new file mode 100644 index 00000000..4decf797 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkProperties.java @@ -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. + * + *

The provider is deliberately disabled by default. The authority is a + * stable enterprise/application identity domain, not a URL supplied by an + * upstream response.

+ */ +@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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java new file mode 100644 index 00000000..ece6ee09 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java @@ -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 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)); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java index e00182b4..9fe40c82 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java @@ -64,7 +64,6 @@ public class OAuthLoginFlowService { private final AccountMergeProviderProofService accountMergeProviderProofService; - @Autowired public OAuthLoginFlowService(List extractorList, TrustedProviderRouteResolver providerRouteResolver, ExternalIdentityLoginService identityLoginService, @@ -84,6 +83,29 @@ public class OAuthLoginFlowService { new DefaultOAuth2UserService()); } + @Autowired + OAuthLoginFlowService( + List extractorList, + TrustedProviderRouteResolver providerRouteResolver, + ExternalIdentityLoginService identityLoginService, + ExternalIdentityLinkService identityLinkService, + IdentityLinkSessionManager identityLinkSessionManager, + AccountMergeSessionManager accountMergeSessionManager, + AccountMergeProviderProofService + accountMergeProviderProofService, + ProviderAwareOAuth2UserService providerAwareUserService) { + this( + extractorList, + providerRouteResolver, + identityLoginService, + identityLinkService, + identityLinkSessionManager, + accountMergeSessionManager, + accountMergeProviderProofService, + (OAuth2UserService) + providerAwareUserService); + } + OAuthLoginFlowService( List extractorList, TrustedProviderRouteResolver providerRouteResolver, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAwareAccessTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAwareAccessTokenResponseClient.java new file mode 100644 index 00000000..541beda1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAwareAccessTokenResponseClient.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAwareOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAwareOAuth2UserService.java new file mode 100644 index 00000000..ee3b2f7e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAwareOAuth2UserService.java @@ -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 { + + private final OAuth2UserService + standardDelegate; + private final OAuth2UserService + dingTalkDelegate; + + @Autowired + ProviderAwareOAuth2UserService( + @Qualifier("dingTalkOAuth2UserService") + OAuth2UserService + dingTalkDelegate) { + this(new DefaultOAuth2UserService(), dingTalkDelegate); + } + + ProviderAwareOAuth2UserService( + OAuth2UserService + standardDelegate, + OAuth2UserService + 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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java index d321f32b..25330518 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java @@ -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") diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java new file mode 100644 index 00000000..af4773ec --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java new file mode 100644 index 00000000..4e6c9fb9 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserServiceTest.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java new file mode 100644 index 00000000..2adf68ff --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java @@ -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)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthAdapterBoundaryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthAdapterBoundaryTest.java index 422d87e7..30238230 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthAdapterBoundaryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthAdapterBoundaryTest.java @@ -14,6 +14,9 @@ class OAuthAdapterBoundaryTest { OAuthClaimsExtractor.class, GitHubClaimsExtractor.class, GitLabClaimsExtractor.class, + DingTalkClaimsExtractor.class, + DingTalkOAuth2UserService.class, + DingTalkTokenResponseClient.class, CustomOAuth2UserService.class, CustomOidcUserService.class); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/ProviderAwareOAuthDelegatesTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/ProviderAwareOAuthDelegatesTest.java new file mode 100644 index 00000000..7f383df6 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/ProviderAwareOAuthDelegatesTest.java @@ -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 standard = mock(); + OAuth2UserService 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 standard = mock(); + OAuth2UserService 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 + standard = mock(); + OAuth2AccessTokenResponseClient + 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(); + } +} From aeec9bd0aa0079f64bc816a0de12607801bc6c2e Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:06:21 +0800 Subject: [PATCH 2/2] test(auth): cover DingTalk callback identity flow Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../auth/oauth/OAuthLoginFlowServiceTest.java | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java index 9d2baf66..5d1391e1 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java @@ -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(); + } }