diff --git a/.env.release.example b/.env.release.example index 59387e0f8..86a30b612 100644 --- a/.env.release.example +++ b/.env.release.example @@ -7,6 +7,7 @@ SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web SKILLHUB_SCANNER_IMAGE=ghcr.io/iflytek/skillhub-scanner POSTGRES_IMAGE=postgres:16-alpine REDIS_IMAGE=redis:7-alpine +SPRING_PROFILES_ACTIVE=docker # Public entrypoint seen by browsers/CLI, no trailing slash. # Default to localhost so `runtime.sh up` works as a zero-config quickstart. @@ -92,6 +93,14 @@ OAUTH2_GITLAB_CLIENT_SECRET= OAUTH2_GITLAB_BASE_URI=https://gitlab.com OAUTH2_GITLAB_DISPLAY_NAME=GitLab +# Optional: configure DingTalk (钉钉) OAuth2 login. +# Add dingtalk to SPRING_PROFILES_ACTIVE (for example: docker,dingtalk) to enable it. +# Register your app at https://open-dev.dingtalk.com and request the Contact.User.Read scope. +# SkillHub uses the official minimal authorization scope "openid". +OAUTH2_DINGTALK_CLIENT_ID= +OAUTH2_DINGTALK_CLIENT_SECRET= +OAUTH2_DINGTALK_DISPLAY_NAME=钉钉 + # Optional: OIDC login (e.g. Keycloak, Okta, Azure AD). # Replace "OIDC" in variable names with your registration id (uppercase). # The registration id becomes identity_binding.provider_code — keep it stable. diff --git a/compose.release.yml b/compose.release.yml index 733187688..613d2024c 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -49,7 +49,7 @@ services: ports: - "${API_PORT:-8080}:8080" environment: - SPRING_PROFILES_ACTIVE: docker + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-docker} SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-skillhub} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-skillhub} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo} @@ -96,6 +96,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:-} + OAUTH2_DINGTALK_CLIENT_SECRET: ${OAUTH2_DINGTALK_CLIENT_SECRET:-} + OAUTH2_DINGTALK_DISPLAY_NAME: ${OAUTH2_DINGTALK_DISPLAY_NAME:-钉钉} SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-} SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25} SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-} diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index 0ed6db14f..2dfa31266 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -64,6 +64,8 @@ cp secret.yaml.example secret.yaml | bootstrap-admin-password | 管理员密码 | 是 | | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | +| oauth2-dingtalk-client-id | 钉钉 OAuth AppKey | 否 | +| oauth2-dingtalk-client-secret | 钉钉 OAuth AppSecret | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | | skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | | skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 | @@ -213,6 +215,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ | redis-connect-timeout | 未设置 | Redis 建连超时 | | redis-timeout | 未设置 | Redis 命令超时 | | redis-client-name | 未设置 | Redis 客户端名称 | +| spring-profiles-active | docker | Spring profile;启用钉钉时改为 `docker,dingtalk` | | storage-base-path | /var/lib/skillhub/storage | 技能存储路径 | | skillhub-storage-provider | local | 存储类型(local/s3) | | skill-scanner-enabled | true | 是否启用扫描器 | @@ -224,6 +227,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ | bootstrap-admin-display-name | Platform Admin | 管理员显示名称 | | bootstrap-admin-email | admin@example.com | 管理员邮箱 | | session-cookie-secure | false | HTTPS 环境设为 true | +| oauth2-dingtalk-display-name | 钉钉 | 钉钉登录入口显示名称 | ### Secret 配置项 @@ -237,10 +241,25 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ | bootstrap-admin-password | 管理员密码 | 是 | | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | +| oauth2-dingtalk-client-id | 钉钉 OAuth AppKey | 否 | +| oauth2-dingtalk-client-secret | 钉钉 OAuth AppSecret | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | | skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | | skill-scanner-llm-model | LLM 模型名称 | 否 | +### 钉钉 OAuth2 + +钉钉登录默认关闭。启用时: + +1. 将 `base/configmap.yaml` 的 `spring-profiles-active` 改为 `docker,dingtalk` +2. 在 `base/secret.yaml` 填写 `oauth2-dingtalk-client-id` 和 + `oauth2-dingtalk-client-secret` +3. 在钉钉开放平台将回调地址配置为 + `{站点公网地址}/login/oauth2/code/dingtalk` + +授权 scope 固定为 `openid`。详细契约参见 +[钉钉官方教程](https://developers.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information)。 + ### 存储配置 **本地存储(默认)** diff --git a/deploy/k8s/base/backend-deployment.yaml b/deploy/k8s/base/backend-deployment.yaml index 52ddc1858..a2d47a98e 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -23,7 +23,10 @@ spec: name: http env: - name: SPRING_PROFILES_ACTIVE - value: docker + valueFrom: + configMapKeyRef: + name: skillhub-config + key: spring-profiles-active # Database - name: SPRING_DATASOURCE_URL @@ -217,6 +220,25 @@ spec: key: oauth2-github-client-secret optional: true + # DingTalk OAuth2 (optional; requires the dingtalk Spring profile) + - name: OAUTH2_DINGTALK_CLIENT_ID + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-dingtalk-client-id + optional: true + - name: OAUTH2_DINGTALK_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-dingtalk-client-secret + optional: true + - name: OAUTH2_DINGTALK_DISPLAY_NAME + valueFrom: + configMapKeyRef: + name: skillhub-config + key: oauth2-dingtalk-display-name + volumeMounts: - name: skillhub-storage mountPath: /var/lib/skillhub/storage diff --git a/deploy/k8s/base/configmap.yaml b/deploy/k8s/base/configmap.yaml index 06dbbaa5e..227ddd45f 100644 --- a/deploy/k8s/base/configmap.yaml +++ b/deploy/k8s/base/configmap.yaml @@ -3,6 +3,9 @@ kind: ConfigMap metadata: name: skillhub-config data: + # Add dingtalk to enable DingTalk OAuth2, for example: docker,dingtalk + spring-profiles-active: docker + # Redis 配置 # 使用外部 Redis:修改为外部主机地址 # 使用内置 Redis(overlays/with-infra):保持 redis @@ -46,6 +49,9 @@ data: # Session 配置 # HTTP 环境设为 false,HTTPS 环境设为 true session-cookie-secure: "false" + + # DingTalk OAuth2 display name (credentials are stored in Secret) + oauth2-dingtalk-display-name: 钉钉 --- apiVersion: v1 kind: PersistentVolumeClaim diff --git a/deploy/k8s/base/secret.yaml.example b/deploy/k8s/base/secret.yaml.example index c2b93d45a..36892c96f 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 OAuth(可选;同时在 ConfigMap 中启用 dingtalk profile) + oauth2-dingtalk-client-id: "" + oauth2-dingtalk-client-secret: "" + # LLM 配置(可选,用于技能扫描) skill-scanner-llm-api-key: "" skill-scanner-llm-base-url: "" diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 2f4b77052..dda25b9f8 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -283,6 +283,26 @@ Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 2. `CustomOAuth2UserService` 中按 `registrationId` 分支处理用户属性映射 3. 前端登录页增加对应按钮(通过 `/api/v1/auth/providers` 自动发现) +### 3.7 钉钉 OAuth2 契约 + +钉钉接入遵循[获取用户个人信息教程](https://developers.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information)中的新版 OAuth2 契约:授权地址使用 +`https://login.dingtalk.com/oauth2/auth`,授权 scope 固定为最小可用值 +`openid`,token 与用户信息端点分别使用 `/v1.0/oauth2/userAccessToken` 和 +`/v1.0/contact/users/me`。`corpid` 不能单独作为授权 scope。 + +钉钉的 `openid` 是 OAuth2 授权参数,不表示其 token 响应是 OIDC。适配器在外发 +授权 URL 中保留 `scope=openid`,但在 Spring Security 内部将该 registration 按 +普通 OAuth2 处理,避免框架转入要求 `id_token` 的 OIDC 分支。其他真正的 OIDC +registration 仍保留 `openid` 和 nonce。 + +身份映射遵循以下约束: + +- 稳定 subject 按 `unionId -> openId -> userId` 回退 +- identity binding 始终使用 `provider=dingtalk` 与稳定 subject,不依赖邮箱 +- 用户信息端点没有返回真实邮箱时传 `null`,且 `emailVerified=false` +- 即使端点返回邮箱,也不能视为钉钉已验证邮箱,`emailVerified` 仍为 `false` +- provider 默认关闭,仅在显式启用 `dingtalk` Spring profile 并配置凭证时注册 + ## 4. 核心接口设计 ```java diff --git a/docs/09-deployment.md b/docs/09-deployment.md index bcf362894..8d3072f7d 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -48,6 +48,7 @@ |---------|------|------| | `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 | | `docker` | 容器运行时能力 | 启用容器运行时相关能力,不会自动打开首登管理员 | +| `dingtalk` | 钉钉 OAuth2 登录 | 默认关闭;必须与运行 profile 组合并配置 AppKey/AppSecret | 单机交付环境使用 `SPRING_PROFILES_ACTIVE=docker`,原因如下: @@ -249,7 +250,33 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se - 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` - 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md` -## 8 OIDC 登录配置 +## 8 外部身份源配置 + +### 8.1 钉钉 OAuth2 + +钉钉 Provider 默认不注册。启用时在 `.env.release` 中设置: + +```bash +SPRING_PROFILES_ACTIVE=docker,dingtalk +OAUTH2_DINGTALK_CLIENT_ID=your-app-key +OAUTH2_DINGTALK_CLIENT_SECRET=your-app-secret +OAUTH2_DINGTALK_DISPLAY_NAME=钉钉 +``` + +在钉钉开放平台将回调地址配置为 +`{SKILLHUB_PUBLIC_BASE_URL}/login/oauth2/code/dingtalk`,开通读取个人信息所需权限并 +发布应用。授权 scope 固定为官方新版 OAuth2 契约的 `openid`;不要改为单独的 +`corpid`。契约参见[钉钉官方教程](https://developers.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information)。 + +Compose 会将 profile 与三个 `OAUTH2_DINGTALK_*` 变量传给 Server 容器。 +`make validate-release-config` 会拒绝“启用 profile 但缺少凭证”和“配置凭证但未启用 +profile”两类不完整配置。 + +Kubernetes 部署需要将 ConfigMap 的 `spring-profiles-active` 改为 +`docker,dingtalk`,并在 Secret 中填写 `oauth2-dingtalk-client-id` 与 +`oauth2-dingtalk-client-secret`。Deployment 已将这些配置映射到相同的运行时环境变量。 + +### 8.2 OIDC 登录 SkillHub 复用 Spring Security OAuth2 Client 的 OIDC 支持。前端不需要单独 配置回调页;登录页会从 `/api/v1/auth/methods` 读取后端暴露的 diff --git a/docs/skillhub/en/guide/kubernetes.md b/docs/skillhub/en/guide/kubernetes.md index bcb2c1d48..67ea0d5fc 100644 --- a/docs/skillhub/en/guide/kubernetes.md +++ b/docs/skillhub/en/guide/kubernetes.md @@ -62,6 +62,8 @@ cp secret.yaml.example secret.yaml | bootstrap-admin-password | Admin password | Yes | | oauth2-github-client-id | GitHub OAuth ID | No | | oauth2-github-client-secret | GitHub OAuth secret | No | +| oauth2-dingtalk-client-id | DingTalk OAuth AppKey | No | +| oauth2-dingtalk-client-secret | DingTalk OAuth AppSecret | No | | skill-scanner-llm-api-key | LLM API key | No | | skill-scanner-llm-base-url | Local/custom LLM service base URL | No | | skill-scanner-llm-model | LLM model name used by the scanner | No | @@ -171,6 +173,7 @@ kubectl apply -k overlays/with-infra/ # or overlays/external/ |---|---|---| | redis-host | redis | Redis host address | | redis-port | 6379 | Redis port | +| spring-profiles-active | docker | Set to `docker,dingtalk` to enable DingTalk login | | storage-base-path | /var/lib/skillhub/storage | Skill storage path | | skillhub-storage-provider | local | Storage type (local/s3) | | skill-scanner-enabled | true | Enable scanner | @@ -182,6 +185,18 @@ kubectl apply -k overlays/with-infra/ # or overlays/external/ | bootstrap-admin-display-name | Platform Admin | Admin display name | | bootstrap-admin-email | admin@example.com | Admin email | | session-cookie-secure | false | Set to true for HTTPS | +| oauth2-dingtalk-display-name | 钉钉 | DingTalk login display name | + +### DingTalk OAuth2 + +DingTalk login is disabled by default. Set `spring-profiles-active` in the +ConfigMap to `docker,dingtalk`, then provide `oauth2-dingtalk-client-id` and +`oauth2-dingtalk-client-secret` in the Secret. Configure the callback URL in +DingTalk Open Platform as `{public-site-url}/login/oauth2/code/dingtalk`. The +authorization scope is fixed to `openid`. + +See the [official DingTalk tutorial](https://developers.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information) +for the authorization contract. ### Storage Configuration diff --git a/docs/skillhub/guide/kubernetes.md b/docs/skillhub/guide/kubernetes.md index 2135b9a3b..bde88025e 100644 --- a/docs/skillhub/guide/kubernetes.md +++ b/docs/skillhub/guide/kubernetes.md @@ -62,6 +62,8 @@ cp secret.yaml.example secret.yaml | bootstrap-admin-password | 管理员密码 | 是 | | oauth2-github-client-id | GitHub OAuth ID | 否 | | oauth2-github-client-secret | GitHub OAuth 密钥 | 否 | +| oauth2-dingtalk-client-id | 钉钉 OAuth AppKey | 否 | +| oauth2-dingtalk-client-secret | 钉钉 OAuth AppSecret | 否 | | skill-scanner-llm-api-key | LLM API 密钥 | 否 | | skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 | | skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 | @@ -171,6 +173,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ |---|---|---| | redis-host | redis | Redis 主机地址 | | redis-port | 6379 | Redis 端口 | +| spring-profiles-active | docker | 启用钉钉登录时改为 `docker,dingtalk` | | storage-base-path | /var/lib/skillhub/storage | 技能存储路径 | | skillhub-storage-provider | local | 存储类型(local/s3) | | skill-scanner-enabled | true | 是否启用扫描器 | @@ -182,6 +185,16 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/ | bootstrap-admin-display-name | Platform Admin | 管理员显示名称 | | bootstrap-admin-email | admin@example.com | 管理员邮箱 | | session-cookie-secure | false | HTTPS 环境设为 true | +| oauth2-dingtalk-display-name | 钉钉 | 钉钉登录入口显示名称 | + +### 钉钉 OAuth2 + +钉钉登录默认关闭。将 ConfigMap 的 `spring-profiles-active` 改为 +`docker,dingtalk`,并在 Secret 中填写 `oauth2-dingtalk-client-id` 与 +`oauth2-dingtalk-client-secret` 后才会注册登录入口。钉钉开放平台的回调地址应为 +`{站点公网地址}/login/oauth2/code/dingtalk`,授权 scope 固定为 `openid`。 + +完整授权契约参见[钉钉官方教程](https://developers.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information)。 ### 存储配置 diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index bee780fbc..491ee6843 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -150,6 +150,31 @@ write_env "$invalid_redis_sentinel_check_env" "release-download-secret-32-bytes- printf '%s\n' "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST=yes" >>"$invalid_redis_sentinel_check_env" expect_fail "$invalid_redis_sentinel_check_env" "SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST must be true or false" +dingtalk_env="$tmp/dingtalk.env" +write_env "$dingtalk_env" "release-download-secret-32-bytes-minimum" +cat >>"$dingtalk_env" </dev/null + +dingtalk_missing_secret_env="$tmp/dingtalk-missing-secret.env" +write_env "$dingtalk_missing_secret_env" "release-download-secret-32-bytes-minimum" +cat >>"$dingtalk_missing_secret_env" <>"$dingtalk_disabled_env" <"$draft_env" expect_fail "$draft_env" "POSTGRES_PASSWORD" +grep -Fq 'OAUTH2_DINGTALK_CLIENT_ID: ${OAUTH2_DINGTALK_CLIENT_ID:-}' "$REPO_ROOT/compose.release.yml" \ + || fail "compose.release.yml does not pass OAUTH2_DINGTALK_CLIENT_ID" +grep -Fq 'key: oauth2-dingtalk-client-secret' "$REPO_ROOT/deploy/k8s/base/backend-deployment.yaml" \ + || fail "Kubernetes deployment does not pass the DingTalk client secret" +grep -Fq 'spring-profiles-active: docker' "$REPO_ROOT/deploy/k8s/base/configmap.yaml" \ + || fail "Kubernetes config does not expose Spring profile activation" + echo "validate-release-config-test passed" diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 7c7940a39..50b5aa48f 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -290,6 +290,23 @@ if [ -n "$oauth_secret" ] && [ -z "$oauth_id" ]; then error "OAUTH2_GITHUB_CLIENT_ID is required when OAUTH2_GITHUB_CLIENT_SECRET is set" fi +dingtalk_profiles=",${SPRING_PROFILES_ACTIVE:-docker}," +dingtalk_id="${OAUTH2_DINGTALK_CLIENT_ID:-}" +dingtalk_secret="${OAUTH2_DINGTALK_CLIENT_SECRET:-}" +case "$dingtalk_profiles" in + *,dingtalk,*) + 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" + ;; + *) + if [ -n "$dingtalk_id" ] || [ -n "$dingtalk_secret" ]; then + error "SPRING_PROFILES_ACTIVE must include dingtalk when DingTalk OAuth2 credentials are set" + fi + ;; +esac + if [ "$errors" -gt 0 ]; then echo "Release config validation failed: $errors error(s), $warnings warning(s)." >&2 exit 1 diff --git a/server/skillhub-app/src/main/resources/application-dingtalk.yml b/server/skillhub-app/src/main/resources/application-dingtalk.yml new file mode 100644 index 000000000..c30ee773a --- /dev/null +++ b/server/skillhub-app/src/main/resources/application-dingtalk.yml @@ -0,0 +1,22 @@ +spring: + config: + activate: + on-profile: dingtalk + security: + oauth2: + client: + registration: + dingtalk: + client-id: ${OAUTH2_DINGTALK_CLIENT_ID} + client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET} + scope: + - openid + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉} + provider: + 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: unionId diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2CallbackIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2CallbackIntegrationTest.java new file mode 100644 index 000000000..a245a3466 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2CallbackIntegrationTest.java @@ -0,0 +1,169 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"test", "dingtalk"}) +@TestPropertySource(properties = { + "OAUTH2_DINGTALK_CLIENT_ID=test-dingtalk-client", + "OAUTH2_DINGTALK_CLIENT_SECRET=test-dingtalk-secret", + "spring.security.oauth2.client.registration.oidc.client-id=test-oidc-client", + "spring.security.oauth2.client.registration.oidc.client-secret=test-oidc-secret", + "spring.security.oauth2.client.registration.oidc.provider=oidc", + "spring.security.oauth2.client.registration.oidc.authorization-grant-type=authorization_code", + "spring.security.oauth2.client.registration.oidc.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}", + "spring.security.oauth2.client.registration.oidc.scope=openid,profile,email", + "spring.security.oauth2.client.provider.oidc.authorization-uri=https://idp.example.test/oauth2/authorize", + "spring.security.oauth2.client.provider.oidc.token-uri=https://idp.example.test/oauth2/token", + "spring.security.oauth2.client.provider.oidc.jwk-set-uri=https://idp.example.test/oauth2/jwks", + "spring.security.oauth2.client.provider.oidc.user-info-uri=https://idp.example.test/userinfo", + "spring.security.oauth2.client.provider.oidc.user-name-attribute=sub" +}) +class DingTalkOAuth2CallbackIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ClientRegistrationRepository clientRegistrationRepository; + + @Autowired + private DingTalkTokenResponseClient tokenResponseClient; + + @Autowired + private DingTalkOAuth2UserService userService; + + @MockBean + private OAuthLoginFlowService oauthLoginFlowService; + + private MockRestServiceServer tokenServer; + private MockRestServiceServer userInfoServer; + + @BeforeEach + void setUp() { + RestTemplate tokenRestTemplate = (RestTemplate) ReflectionTestUtils.getField( + tokenResponseClient, "restTemplate"); + RestTemplate userInfoRestTemplate = (RestTemplate) ReflectionTestUtils.getField( + userService, "restTemplate"); + assertThat(tokenRestTemplate).isNotNull(); + assertThat(userInfoRestTemplate).isNotNull(); + tokenServer = MockRestServiceServer.bindTo(tokenRestTemplate).build(); + userInfoServer = MockRestServiceServer.bindTo(userInfoRestTemplate).build(); + } + + @Test + void dingtalkProfileExposesProviderAndCompletesOAuth2Callback() throws Exception { + assertThat(clientRegistrationRepository.findByRegistrationId("dingtalk")).isNotNull(); + mockMvc.perform(get("/api/v1/auth/providers")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.id=='dingtalk')]").isNotEmpty()); + + MvcResult authorizationResult = mockMvc.perform(get("/oauth2/authorization/dingtalk")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", org.hamcrest.Matchers.containsString("scope=openid"))) + .andReturn(); + + String authorizationLocation = authorizationResult.getResponse().getRedirectedUrl(); + assertThat(authorizationLocation).isNotNull(); + String encodedState = UriComponentsBuilder.fromUri(URI.create(authorizationLocation)) + .build() + .getQueryParams() + .getFirst("state"); + String state = UriUtils.decode(encodedState, StandardCharsets.UTF_8); + assertThat(state).isNotBlank(); + MockHttpSession session = (MockHttpSession) authorizationResult.getRequest().getSession(false); + assertThat(session).isNotNull(); + + tokenServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess( + """ + {"accessToken":"dingtalk-access-token","expireIn":7200} + """, + MediaType.APPLICATION_JSON)); + userInfoServer.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER, "dingtalk-access-token")) + .andRespond(withSuccess( + """ + {"openId":"stable-open-id","nick":"DingTalk User"} + """, + MediaType.APPLICATION_JSON)); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-dingtalk", "DingTalk User", null, null, "dingtalk", Set.of("USER")); + when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal); + + mockMvc.perform(get("/login/oauth2/code/dingtalk") + .param("code", "authorization-code") + .param("state", state) + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", "/dashboard")); + + assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal); + tokenServer.verify(); + userInfoServer.verify(); + } + + @Test + void standardOAuth2AndOidcAuthorizationRoutesRemainIntact() throws Exception { + assertAuthorizationRedirectScopes("github", Set.of("read:user", "user:email"), false); + assertAuthorizationRedirectScopes("gitlab", Set.of("read_user", "email"), false); + assertAuthorizationRedirectScopes("oidc", Set.of("openid", "profile", "email"), true); + } + + private void assertAuthorizationRedirectScopes( + String registrationId, + Set expectedScopes, + boolean expectsNonce) throws Exception { + MvcResult result = mockMvc.perform(get("/oauth2/authorization/{registrationId}", registrationId)) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String location = result.getResponse().getRedirectedUrl(); + assertThat(location).isNotNull(); + var query = UriComponentsBuilder.fromUri(URI.create(location)).build().getQueryParams(); + String encodedScope = query.getFirst("scope"); + assertThat(encodedScope).isNotNull(); + assertThat(Arrays.asList(UriUtils.decode(encodedScope, StandardCharsets.UTF_8).split(" "))) + .containsExactlyInAnyOrderElementsOf(expectedScopes); + assertThat(query.containsKey("nonce")).isEqualTo(expectsNonce); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index 8e4118c6c..6f454af69 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -152,6 +152,7 @@ void providersShouldExposeGithubLoginEntry() throws Exception { .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.length()").value(1)) .andExpect(jsonPath("$.data[*].id", hasItems("github"))) + .andExpect(jsonPath("$.data[?(@.id=='dingtalk')]").isEmpty()) .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems("/oauth2/authorization/github"))) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); 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 8c2ff2dca..2ae7fe6c5 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 @@ -2,6 +2,9 @@ import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; import com.iflytek.skillhub.auth.oauth.CustomOidcUserService; +import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2Constants; +import com.iflytek.skillhub.auth.oauth.DingTalkOAuth2UserService; +import com.iflytek.skillhub.auth.oauth.DingTalkTokenResponseClient; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; @@ -19,6 +22,13 @@ import org.springframework.http.MediaType; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +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.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; @@ -57,6 +67,8 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final CustomOidcUserService customOidcUserService; + private final DingTalkOAuth2UserService dingTalkOAuth2UserService; + private final DingTalkTokenResponseClient dingTalkTokenResponseClient; private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver; private final OAuth2LoginSuccessHandler successHandler; private final OAuth2LoginFailureHandler failureHandler; @@ -69,6 +81,8 @@ public class SecurityConfig { public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomOidcUserService customOidcUserService, + DingTalkOAuth2UserService dingTalkOAuth2UserService, + DingTalkTokenResponseClient dingTalkTokenResponseClient, SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, OAuth2LoginSuccessHandler successHandler, OAuth2LoginFailureHandler failureHandler, @@ -80,6 +94,8 @@ public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) { this.customOAuth2UserService = customOAuth2UserService; this.customOidcUserService = customOidcUserService; + this.dingTalkOAuth2UserService = dingTalkOAuth2UserService; + this.dingTalkTokenResponseClient = dingTalkTokenResponseClient; this.authorizationRequestResolver = authorizationRequestResolver; this.successHandler = successHandler; this.failureHandler = failureHandler; @@ -120,8 +136,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { }) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver)) + .tokenEndpoint(token -> token.accessTokenResponseClient( + new DelegatingAccessTokenResponseClient(dingTalkTokenResponseClient, new DefaultAuthorizationCodeTokenResponseClient()))) .userInfoEndpoint(userInfo -> userInfo - .userService(customOAuth2UserService) + .userService(new DelegatingOAuth2UserService(customOAuth2UserService, dingTalkOAuth2UserService)) .oidcUserService(customOidcUserService)) .successHandler(successHandler) .failureHandler(failureHandler) @@ -201,4 +219,52 @@ static boolean hasSessionCookie(HttpServletRequest request) { } return false; } + + /** + * Delegates OAuth2 user info loading to the appropriate service based on + * the registrationId. DingTalk uses a custom service due to its non-standard + * user info endpoint; all other providers use the standard service. + */ + private static class DelegatingOAuth2UserService implements OAuth2UserService { + private final CustomOAuth2UserService defaultService; + private final DingTalkOAuth2UserService dingTalkService; + + DelegatingOAuth2UserService(CustomOAuth2UserService defaultService, DingTalkOAuth2UserService dingTalkService) { + this.defaultService = defaultService; + this.dingTalkService = dingTalkService; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) { + if (DingTalkOAuth2Constants.REGISTRATION_ID.equals( + userRequest.getClientRegistration().getRegistrationId())) { + return dingTalkService.loadUser(userRequest); + } + return defaultService.loadUser(userRequest); + } + } + + /** + * Delegates token exchange to the appropriate client based on the + * registrationId. DingTalk requires a JSON body instead of form-urlencoded; + * all other providers use the standard client. + */ + private static class DelegatingAccessTokenResponseClient implements OAuth2AccessTokenResponseClient { + private final DingTalkTokenResponseClient dingTalkClient; + private final DefaultAuthorizationCodeTokenResponseClient defaultClient; + + DelegatingAccessTokenResponseClient(DingTalkTokenResponseClient dingTalkClient, DefaultAuthorizationCodeTokenResponseClient defaultClient) { + this.dingTalkClient = dingTalkClient; + this.defaultClient = defaultClient; + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + if (DingTalkOAuth2Constants.REGISTRATION_ID.equals( + authorizationCodeGrantRequest.getClientRegistration().getRegistrationId())) { + return dingTalkClient.getTokenResponse(authorizationCodeGrantRequest); + } + return defaultClient.getTokenResponse(authorizationCodeGrantRequest); + } + } } 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 000000000..438957959 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java @@ -0,0 +1,86 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.Map; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; + +/** + * Provider-specific claims extractor for DingTalk (钉钉). + * + *

Maps DingTalk's non-standard user info fields into normalized {@link OAuthClaims} + * for downstream account provisioning and access policy evaluation. + * + *

Field mapping: + *

    + *
  • subject → unionId, falling back to openId and userId
  • + *
  • email → optional real email returned by DingTalk
  • + *
  • emailVerified → false because this endpoint does not attest email ownership
  • + *
  • providerLogin → nick
  • + *
+ * + *

unionId is preferred because it is stable across apps under the same developer. + * The fallbacks preserve login availability when DingTalk omits that optional field. + */ +@Component +public class DingTalkClaimsExtractor implements OAuthClaimsExtractor { + + @Override + public String getProvider() { + return DingTalkOAuth2Constants.REGISTRATION_ID; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { + Map attrs = oAuth2User.getAttributes(); + + String subject = resolveSubject(attrs); + + String email = stringValue(attrs.get("email")); + String providerLogin = firstNonBlank(attrs, "nick", "name"); + if (providerLogin == null) { + providerLogin = subject; + } + + return new OAuthClaims( + DingTalkOAuth2Constants.REGISTRATION_ID, + subject, + email, + false, + providerLogin, + attrs + ); + } + + String resolveSubject(Map attributes) { + String subject = firstNonBlank( + attributes, + DingTalkOAuth2Constants.SUBJECT_CLAIM_NAMES.toArray(String[]::new)); + if (subject == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error("missing_subject", + "DingTalk response is missing unionId, openId, and userId", null)); + } + return subject; + } + + private static String firstNonBlank(Map attributes, String... keys) { + for (String key : keys) { + String value = stringValue(attributes.get(key)); + if (value != null) { + return value; + } + } + return null; + } + + private static String stringValue(Object value) { + if (value == null) { + return null; + } + String stringValue = String.valueOf(value).trim(); + return stringValue.isEmpty() ? null : stringValue; + } +} 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 000000000..1f8b7241a --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2Constants.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.List; + +/** Shared protocol constants for the DingTalk OAuth2 adapter. */ +public final class DingTalkOAuth2Constants { + + public static final String REGISTRATION_ID = "dingtalk"; + public static final String AUTHORIZATION_SCOPE = "openid"; + public static final String ACCESS_TOKEN_HEADER = "x-acs-dingtalk-access-token"; + public static final String SUBJECT_ATTRIBUTE = "dingtalkSubject"; + static final List SUBJECT_CLAIM_NAMES = List.of("unionId", "openId", "userId"); + + 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 000000000..81dc4abcf --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -0,0 +1,129 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import java.time.Duration; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +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.RestClientResponseException; +import org.springframework.web.client.RestTemplate; + +/** + * OAuth2UserService for DingTalk — handles DingTalk's non-standard user info + * endpoint which uses a custom header {@code x-acs-dingtalk-access-token} + * instead of the standard {@code Authorization: Bearer} header. + * + *

After fetching user info, this service delegates to + * {@link OAuthLoginFlowService#authenticate(OAuthClaims)} for access policy + * evaluation and identity binding, consistent with the standard OAuth2 flow. + */ +@Component +public class DingTalkOAuth2UserService implements OAuth2UserService { + + private final RestTemplate restTemplate; + private final DingTalkClaimsExtractor claimsExtractor; + private final OAuthLoginFlowService oauthLoginFlowService; + + @Autowired + public DingTalkOAuth2UserService(DingTalkClaimsExtractor claimsExtractor, + OAuthLoginFlowService oauthLoginFlowService) { + this.restTemplate = buildRestTemplate(); + this.claimsExtractor = claimsExtractor; + this.oauthLoginFlowService = oauthLoginFlowService; + } + + /** Package-visible constructor for unit testing with a mock RestTemplate. */ + DingTalkOAuth2UserService(DingTalkClaimsExtractor claimsExtractor, + OAuthLoginFlowService oauthLoginFlowService, + RestTemplate restTemplate) { + this.restTemplate = restTemplate; + this.claimsExtractor = claimsExtractor; + this.oauthLoginFlowService = oauthLoginFlowService; + } + + private static RestTemplate buildRestTemplate() { + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(5)); + factory.setReadTimeout(Duration.ofSeconds(10)); + return new RestTemplate(factory); + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) { + String accessToken = userRequest.getAccessToken().getTokenValue(); + String userInfoUri = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUri(); + + // Fetch user info using DingTalk's custom header + HttpHeaders headers = new HttpHeaders(); + headers.set(DingTalkOAuth2Constants.ACCESS_TOKEN_HEADER, accessToken); + HttpEntity requestEntity = new HttpEntity<>(headers); + + ResponseEntity> response; + try { + response = restTemplate.exchange( + userInfoUri, + HttpMethod.GET, + requestEntity, + new ParameterizedTypeReference<>() { + } + ); + } catch (RestClientResponseException e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("user_info_request_failed", + "DingTalk user-info request failed with HTTP " + e.getStatusCode().value(), null)); + } catch (RestClientException e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("user_info_request_failed", + "DingTalk user-info request failed", null)); + } + + Map attributes = response.getBody() != null ? response.getBody() : Map.of(); + + Map userAttributes = new HashMap<>(attributes); + if (attributes.get("avatarUrl") != null) { + userAttributes.putIfAbsent("avatar_url", attributes.get("avatarUrl")); + } + + String subject = claimsExtractor.resolveSubject(userAttributes); + userAttributes.put(DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE, subject); + + OAuthClaims claims = claimsExtractor.extract(userRequest, new DefaultOAuth2User( + java.util.Collections.emptyList(), userAttributes, DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)); + + // Delegate to OAuthLoginFlowService for access policy evaluation and identity binding + PlatformPrincipal principal = oauthLoginFlowService.authenticate(claims); + + // Build OAuth2User with principal and authorities, consistent with CustomOAuth2UserService + userAttributes.put("platformPrincipal", principal); + userAttributes.put("providerLogin", principal.userId()); + + var authorities = new LinkedHashSet(); + principal.platformRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .forEach(authorities::add); + + return new DefaultOAuth2User( + authorities, + userAttributes, + "providerLogin" + ); + } +} 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 000000000..08237f2d1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java @@ -0,0 +1,138 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.Map; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; + +/** + * Custom token response client for DingTalk (钉钉). + * + *

DingTalk requires a JSON body for token exchange instead of the standard + * form-urlencoded format. This client adapts the request accordingly. + * + *

Request body format: + *

{ "clientId": "...", "clientSecret": "...", "code": "...", "grantType": "authorization_code" }
+ */ +@Component +public class DingTalkTokenResponseClient implements OAuth2AccessTokenResponseClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final RestTemplate restTemplate; + + public DingTalkTokenResponseClient() { + this.restTemplate = buildRestTemplate(); + } + + /** Package-visible constructor for unit testing with a mock RestTemplate. */ + DingTalkTokenResponseClient(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + private static RestTemplate buildRestTemplate() { + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(5)); + factory.setReadTimeout(Duration.ofSeconds(10)); + return new RestTemplate(factory); + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) + throws OAuth2AuthenticationException { + String tokenUri = authorizationCodeGrantRequest.getClientRegistration().getProviderDetails().getTokenUri(); + String clientId = authorizationCodeGrantRequest.getClientRegistration().getClientId(); + String clientSecret = authorizationCodeGrantRequest.getClientRegistration().getClientSecret(); + String code = authorizationCodeGrantRequest.getAuthorizationExchange() + .getAuthorizationResponse() + .getCode(); + + Map tokenRequest = Map.of( + "clientId", clientId, + "clientSecret", clientSecret, + "code", code, + "grantType", "authorization_code" + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response; + try { + response = restTemplate.postForEntity(tokenUri, new HttpEntity<>(tokenRequest, headers), String.class); + } catch (RestClientResponseException e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_exchange_io_error", + "DingTalk token exchange failed with HTTP " + e.getStatusCode().value(), null)); + } catch (RestClientException e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_exchange_io_error", + "DingTalk token exchange request failed", null)); + } + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + try { + JsonNode json = MAPPER.readTree(response.getBody()); + + JsonNode accessTokenNode = json.get("accessToken"); + if (accessTokenNode == null || accessTokenNode.isNull()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_response_missing_field", + "DingTalk token response missing accessToken field", null)); + } + String accessToken = accessTokenNode.asText(); + if (accessToken.isBlank()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_response_missing_field", + "DingTalk token response has empty accessToken", null)); + } + + JsonNode expireInNode = json.get("expireIn"); + if (expireInNode == null || !expireInNode.isIntegralNumber() || !expireInNode.canConvertToLong()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_response_invalid_expiry", + "DingTalk token response has invalid expireIn field", null)); + } + long expireInSeconds = expireInNode.longValue(); + if (expireInSeconds <= 0) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_response_invalid_expiry", + "DingTalk token response has non-positive expireIn field", null)); + } + + // Only include non-sensitive fields in additional parameters. + Map safeParams = Map.of("expireIn", expireInSeconds); + + return OAuth2AccessTokenResponse.withToken(accessToken) + .tokenType(OAuth2AccessToken.TokenType.BEARER) + .expiresIn(expireInSeconds) + .additionalParameters(safeParams) + .build(); + } catch (OAuth2AuthenticationException e) { + throw e; + } catch (Exception e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("token_parse_error", + "Failed to parse DingTalk token response", null)); + } + } + + throw new OAuth2AuthenticationException( + new OAuth2Error("token_exchange_failed", + "DingTalk token exchange failed: HTTP " + response.getStatusCode(), null)); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java index 14beac753..659b4d5ba 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java @@ -3,12 +3,14 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; -import java.io.IOException; - /** * Failure handler for OAuth logins that normalizes policy and account-state * failures into predictable user-facing redirects. @@ -16,6 +18,8 @@ @Component public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { + private static final Logger log = LoggerFactory.getLogger(OAuth2LoginFailureHandler.class); + private final OAuthLoginFlowService oauthLoginFlowService; public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) { @@ -26,6 +30,15 @@ public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) { public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + String errorCode = exception instanceof OAuth2AuthenticationException oauth2Exception + ? oauth2Exception.getError().getErrorCode() + : "unknown"; + log.error( + "OAuth2 login failed: path={}, type={}, errorCode={}", + request.getRequestURI(), + exception.getClass().getSimpleName(), + errorCode); + String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false)); String redirectTarget = oauthLoginFlowService.resolveFailureRedirect(exception, returnTo); if (redirectTarget != null) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java index c72b1d9d6..60e82cded 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java @@ -1,14 +1,19 @@ package com.iflytek.skillhub.auth.oauth; import jakarta.servlet.http.HttpServletRequest; +import java.util.LinkedHashSet; +import java.util.Set; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; /** - * OAuth2 authorization request resolver that preserves a sanitized post-login redirect target in - * the HTTP session. + * OAuth2 authorization request resolver that preserves a sanitized post-login + * redirect target in the HTTP session. */ @Component public class SkillHubOAuth2AuthorizationRequestResolver @@ -30,13 +35,36 @@ public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository c public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request); oauthLoginFlowService.rememberReturnTo(request); - return authorizationRequest; + return adaptDingTalkRequest(authorizationRequest); } @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId); oauthLoginFlowService.rememberReturnTo(request); - return authorizationRequest; + return adaptDingTalkRequest(authorizationRequest); + } + + private static OAuth2AuthorizationRequest adaptDingTalkRequest( + OAuth2AuthorizationRequest authorizationRequest) { + if (authorizationRequest == null + || !DingTalkOAuth2Constants.REGISTRATION_ID.equals( + authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID))) { + return authorizationRequest; + } + + Set oauth2Scopes = new LinkedHashSet<>(authorizationRequest.getScopes()); + oauth2Scopes.remove(DingTalkOAuth2Constants.AUTHORIZATION_SCOPE); + String authorizationRequestUri = UriComponentsBuilder + .fromUriString(authorizationRequest.getAuthorizationRequestUri()) + .replaceQueryParam(OidcParameterNames.NONCE) + .build(true) + .toUriString(); + return OAuth2AuthorizationRequest.from(authorizationRequest) + .scopes(oauth2Scopes) + .additionalParameters(parameters -> parameters.remove(OidcParameterNames.NONCE)) + .attributes(attributes -> attributes.remove(OidcParameterNames.NONCE)) + .authorizationRequestUri(authorizationRequestUri) + .build(); } } 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 000000000..9d573f237 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractorTest.java @@ -0,0 +1,136 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.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 { + + private final DingTalkClaimsExtractor extractor = new DingTalkClaimsExtractor(); + + @Test + void extract_usesUnionIdAsSubject() { + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "unionId", "union123", + "openId", "open456", + "nick", "测试用户" + ), + "unionId" + ) + ); + + assertThat(claims.provider()).isEqualTo("dingtalk"); + assertThat(claims.subject()).isEqualTo("union123"); + assertThat(claims.email()).isNull(); + assertThat(claims.emailVerified()).isFalse(); + assertThat(claims.providerLogin()).isEqualTo("测试用户"); + } + + @Test + void extract_fallsBackToOpenId() { + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "openId", "open456", + "nick", "测试用户" + ), + "openId" + ) + ); + + assertThat(claims.subject()).isEqualTo("open456"); + } + + @Test + void extract_fallsBackToUserIdWhenHigherPriorityIdentifiersAreBlank() { + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "unionId", " ", + "openId", "", + "userId", "user789", + "nick", "测试用户" + ), + "userId" + ) + ); + + assertThat(claims.subject()).isEqualTo("user789"); + } + + @Test + void extract_preservesRealEmailWithoutClaimingVerification() { + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "unionId", "union123", + "email", "user@example.com" + ), + "unionId" + ) + ); + + assertThat(claims.email()).isEqualTo("user@example.com"); + assertThat(claims.emailVerified()).isFalse(); + } + + @Test + void extract_throwsWhenAllStableIdentifiersAreMissing() { + assertThatThrownBy(() -> extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of("nick", "测试用户"), + "nick" + ) + )).isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex) + .getError().getErrorCode()).isEqualTo("missing_subject")); + } + + @Test + void getProvider_returnsDingtalk() { + assertThat(extractor.getProvider()).isEqualTo("dingtalk"); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk") + .clientId("dingzgzf3b9k7jv74iq2") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid") + .authorizationUri("https://login.dingtalk.com/oauth2/auth") + .tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken") + .userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me") + .userNameAttributeName("unionId") + .clientName("钉钉") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "test-access-token", + Instant.now(), + Instant.now().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 000000000..8b321617b --- /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.ArgumentMatchers.any; +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 static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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 { + + private DingTalkOAuth2UserService service; + private DingTalkClaimsExtractor claimsExtractor; + private OAuthLoginFlowService oauthLoginFlowService; + private MockRestServiceServer mockServer; + private RestTemplate restTemplate; + + @BeforeEach + void setUp() { + claimsExtractor = new DingTalkClaimsExtractor(); + oauthLoginFlowService = mock(OAuthLoginFlowService.class); + restTemplate = new RestTemplate(); + mockServer = MockRestServiceServer.createServer(restTemplate); + service = new DingTalkOAuth2UserService(claimsExtractor, oauthLoginFlowService, restTemplate); + } + + @Test + void loadUser_fetchesUserInfoWithCustomHeaderAndReturnsOAuth2User() { + // Mock DingTalk user info API response + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("x-acs-dingtalk-access-token", "test-access-token")) + .andRespond(withSuccess( + """ + { + "unionId": "union123", + "openId": "open456", + "nick": "测试用户", + "avatarUrl": "https://example.com/avatar.jpg" + } + """, + MediaType.APPLICATION_JSON + )); + + // Mock OAuthLoginFlowService to return a principal + PlatformPrincipal principal = new PlatformPrincipal( + "user-union123", "测试用户", null, + "https://example.com/avatar.jpg", "dingtalk", Set.of("USER") + ); + when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal); + + OAuth2User oauth2User = service.loadUser(userRequest()); + + assertThat(oauth2User.getName()).isEqualTo("user-union123"); + assertThat(oauth2User.getAttributes().get("unionId")).isEqualTo("union123"); + assertThat(oauth2User.getAttributes().get("platformPrincipal")).isEqualTo(principal); + assertThat(oauth2User.getAttributes().get("providerLogin")).isEqualTo("user-union123"); + assertThat(oauth2User.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_USER"))).isTrue(); + mockServer.verify(); + } + + @Test + void loadUser_readsUserInfoUriFromClientRegistration() { + // Use a custom userInfoUri to verify it's read from config, not hardcoded + String customUri = "https://custom-api.example.com/v1.0/contact/users/me"; + + mockServer.expect(requestTo(customUri)) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("x-acs-dingtalk-access-token", "test-access-token")) + .andRespond(withSuccess( + """ + { + "unionId": "union789", + "openId": "open012", + "nick": "自定义用户" + } + """, + MediaType.APPLICATION_JSON + )); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-union789", "自定义用户", null, + null, "dingtalk", Set.of("USER") + ); + when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal); + + OAuth2User oauth2User = service.loadUser(userRequestWithCustomUri(customUri)); + + assertThat(oauth2User.getName()).isEqualTo("user-union789"); + mockServer.verify(); + } + + @Test + void loadUser_supportsOpenIdFallbackWhenUnionIdIsMissing() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + .andRespond(withSuccess( + """ + { + "openId": "open456", + "nick": "测试用户" + } + """, + MediaType.APPLICATION_JSON + )); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-open456", "测试用户", null, null, "dingtalk", Set.of("USER") + ); + when(oauthLoginFlowService.authenticate(any(OAuthClaims.class))).thenReturn(principal); + + OAuth2User oauth2User = service.loadUser(userRequest()); + + assertThat(oauth2User.getName()).isEqualTo("user-open456"); + assertThat(oauth2User.getAttributes().get(DingTalkOAuth2Constants.SUBJECT_ATTRIBUTE)) + .isEqualTo("open456"); + } + + @Test + void loadUser_wrapsHttpFailureWithoutExposingResponseBody() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/contact/users/me")) + .andRespond(withServerError().body("sensitive-upstream-response")); + + assertThatThrownBy(() -> service.loadUser(userRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> { + OAuth2AuthenticationException oauthException = (OAuth2AuthenticationException) ex; + assertThat(oauthException.getError().getErrorCode()).isEqualTo("user_info_request_failed"); + assertThat(oauthException.getMessage()).doesNotContain("sensitive-upstream-response"); + }); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk") + .clientId("dingzgzf3b9k7jv74iq2") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid") + .authorizationUri("https://login.dingtalk.com/oauth2/auth") + .tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken") + .userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me") + .userNameAttributeName("unionId") + .clientName("钉钉") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "test-access-token", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } + + private OAuth2UserRequest userRequestWithCustomUri(String userInfoUri) { + ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk") + .clientId("dingzgzf3b9k7jv74iq2") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid") + .authorizationUri("https://login.dingtalk.com/oauth2/auth") + .tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken") + .userInfoUri(userInfoUri) + .userNameAttributeName("unionId") + .clientName("钉钉") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "test-access-token", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +} 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 000000000..ac15a660c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClientTest.java @@ -0,0 +1,201 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; + +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +class DingTalkTokenResponseClientTest { + + private DingTalkTokenResponseClient client; + private MockRestServiceServer mockServer; + + @BeforeEach + void setUp() { + RestTemplate restTemplate = new RestTemplate(); + mockServer = MockRestServiceServer.createServer(restTemplate); + client = new DingTalkTokenResponseClient(restTemplate); + } + + @Test + void getTokenResponse_returnsAccessTokenOnSuccess() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "accessToken": "dt_access_token_123", + "expireIn": 7200 + } + """, + MediaType.APPLICATION_JSON + )); + + OAuth2AccessTokenResponse response = client.getTokenResponse(authorizationCodeGrantRequest()); + + assertThat(response.getAccessToken().getTokenValue()).isEqualTo("dt_access_token_123"); + assertThat(response.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER); + assertThat(response.getAccessToken().getIssuedAt()).isNotNull(); + assertThat(response.getAccessToken().getExpiresAt()).isNotNull(); + assertThat(Duration.between( + response.getAccessToken().getIssuedAt(), + response.getAccessToken().getExpiresAt())).isEqualTo(Duration.ofSeconds(7200)); + assertThat(response.getAdditionalParameters().get("expireIn")).isEqualTo(7200L); + // Verify raw_response is NOT included (sensitive data leak fix) + assertThat(response.getAdditionalParameters().containsKey("raw_response")).isFalse(); + mockServer.verify(); + } + + @Test + void getTokenResponse_throwsWhenAccessTokenFieldMissing() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "expireIn": 7200 + } + """, + MediaType.APPLICATION_JSON + )); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field")); + } + + @Test + void getTokenResponse_throwsWhenAccessTokenIsNull() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "accessToken": null, + "expireIn": 7200 + } + """, + MediaType.APPLICATION_JSON + )); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field")); + } + + @Test + void getTokenResponse_throwsWhenAccessTokenIsEmpty() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "accessToken": "", + "expireIn": 7200 + } + """, + MediaType.APPLICATION_JSON + )); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()).isEqualTo("token_response_missing_field")); + } + + @Test + void getTokenResponse_throwsOnHttpError() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withServerError().body("sensitive-upstream-response")); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> { + OAuth2AuthenticationException oauthException = (OAuth2AuthenticationException) ex; + assertThat(oauthException.getError().getErrorCode()).isEqualTo("token_exchange_io_error"); + assertThat(oauthException.getMessage()).doesNotContain("sensitive-upstream-response"); + }); + } + + @Test + void getTokenResponse_throwsWhenExpireInIsMissing() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "accessToken": "dt_access_token_123" + } + """, + MediaType.APPLICATION_JSON + )); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex) + .getError().getErrorCode()).isEqualTo("token_response_invalid_expiry")); + } + + @Test + void getTokenResponse_throwsWhenExpireInIsNonPositive() { + mockServer.expect(requestTo("https://api.dingtalk.com/v1.0/oauth2/userAccessToken")) + .andRespond(withSuccess( + """ + { + "accessToken": "dt_access_token_123", + "expireIn": 0 + } + """, + MediaType.APPLICATION_JSON + )); + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex) + .getError().getErrorCode()).isEqualTo("token_response_invalid_expiry")); + } + + private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("dingtalk") + .clientId("dingzgzf3b9k7jv74iq2") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid") + .authorizationUri("https://login.dingtalk.com/oauth2/auth") + .tokenUri("https://api.dingtalk.com/v1.0/oauth2/userAccessToken") + .userInfoUri("https://api.dingtalk.com/v1.0/contact/users/me") + .userNameAttributeName("unionId") + .clientName("钉钉") + .build(); + + OAuth2AuthorizationRequest authRequest = OAuth2AuthorizationRequest.authorizationCode() + .clientId(registration.getClientId()) + .authorizationUri(registration.getProviderDetails().getAuthorizationUri()) + .redirectUri(registration.getRedirectUri()) + .scopes(registration.getScopes()) + .state("test-state") + .build(); + + OAuth2AuthorizationResponse authResponse = OAuth2AuthorizationResponse.success("test-code") + .redirectUri(registration.getRedirectUri()) + .state("test-state") + .build(); + + return new OAuth2AuthorizationCodeGrantRequest( + registration, + new OAuth2AuthorizationExchange(authRequest, authResponse) + ); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java index 357ada331..3cb7b42c4 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java @@ -8,6 +8,8 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -18,29 +20,36 @@ class OAuth2AuthorizationRequestResolverTest { @BeforeEach void setUp() { - ClientRegistration github = ClientRegistration.withRegistrationId("github") - .clientId("client") - .clientSecret("secret") - .authorizationUri("https://example.test/oauth/authorize") - .tokenUri("https://example.test/oauth/token") - .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") - .userInfoUri("https://example.test/user") - .userNameAttributeName("id") - .authorizationGrantType(org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE) - .scope("read:user") - .clientName("GitHub") - .build(); + ClientRegistration github = clientRegistration("github", "read:user"); + ClientRegistration gitlab = clientRegistration("gitlab", "read_user"); + ClientRegistration dingtalk = clientRegistration("dingtalk", "openid"); + ClientRegistration oidc = clientRegistration("oidc", "openid"); OAuthLoginFlowService oauthLoginFlowService = new OAuthLoginFlowService( java.util.List.of(), mock(AccessPolicy.class), mock(IdentityBindingService.class) ); resolver = new SkillHubOAuth2AuthorizationRequestResolver( - new InMemoryClientRegistrationRepository(github), + new InMemoryClientRegistrationRepository(github, gitlab, dingtalk, oidc), oauthLoginFlowService ); } + private static ClientRegistration clientRegistration(String registrationId, String scope) { + return ClientRegistration.withRegistrationId(registrationId) + .clientId("client") + .clientSecret("secret") + .authorizationUri("https://example.test/oauth/authorize") + .tokenUri("https://example.test/oauth/token") + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .userInfoUri("https://example.test/user") + .userNameAttributeName("id") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .scope(scope) + .clientName(registrationId) + .build(); + } + @Test void resolve_storesSanitizedReturnToInSession() { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); @@ -65,4 +74,47 @@ void resolve_ignoresUnsafeReturnTo() { assertThat(session).isNotNull(); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); } + + @Test + void resolve_sendsDingTalkOpenIdScopeWithoutTriggeringOidc() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/dingtalk"); + + OAuth2AuthorizationRequest authorizationRequest = resolver.resolve(request, "dingtalk"); + + assertThat(authorizationRequest).isNotNull(); + assertThat(authorizationRequest.getAuthorizationRequestUri()).contains("scope=openid"); + assertThat(authorizationRequest.getScopes()).doesNotContain("openid"); + assertThat(authorizationRequest.getAdditionalParameters()).doesNotContainKey("nonce"); + } + + @Test + void resolve_preservesStandardOAuth2ProviderScopes() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); + + OAuth2AuthorizationRequest authorizationRequest = resolver.resolve(request, "github"); + + assertThat(authorizationRequest).isNotNull(); + assertThat(authorizationRequest.getScopes()).containsExactly("read:user"); + } + + @Test + void resolve_preservesGitLabOAuth2Scopes() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/gitlab"); + + OAuth2AuthorizationRequest authorizationRequest = resolver.resolve(request, "gitlab"); + + assertThat(authorizationRequest).isNotNull(); + assertThat(authorizationRequest.getScopes()).containsExactly("read_user"); + } + + @Test + void resolve_preservesOpenIdForRealOidcProviders() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/oidc"); + + OAuth2AuthorizationRequest authorizationRequest = resolver.resolve(request, "oidc"); + + assertThat(authorizationRequest).isNotNull(); + assertThat(authorizationRequest.getScopes()).containsExactly("openid"); + assertThat(authorizationRequest.getAdditionalParameters()).containsKey("nonce"); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java index 52c0077bd..350277aa9 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -2,6 +2,9 @@ import jakarta.servlet.http.HttpSession; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -20,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +@ExtendWith(OutputCaptureExtension.class) class OAuth2LoginHandlersTest { @Test @@ -130,4 +134,29 @@ void failureHandler_redirectsBackToLoginWithReturnTo() throws Exception { assertThat(response.getRedirectedUrl()).isEqualTo("/login?returnTo=%2Fsettings%2Faccounts"); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); } + + @Test + void failureHandler_logsErrorCodeWithoutSensitiveExceptionDetails(CapturedOutput output) throws Exception { + OAuthLoginFlowService oauthLoginFlowService = mock(OAuthLoginFlowService.class); + OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(oauthLoginFlowService); + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", "/login/oauth2/code/dingtalk"); + MockHttpServletResponse response = new MockHttpServletResponse(); + org.mockito.Mockito.when(oauthLoginFlowService.resolveFailureRedirect( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.isNull())) + .thenReturn(null); + + handler.onAuthenticationFailure( + request, + response, + new OAuth2AuthenticationException(new OAuth2Error( + "user_info_request_failed", "sensitive-upstream-response", null)) + ); + + assertThat(output).contains( + "OAuth2 login failed: path=/login/oauth2/code/dingtalk, " + + "type=OAuth2AuthenticationException, errorCode=user_info_request_failed"); + assertThat(output).doesNotContain("sensitive-upstream-response"); + } } diff --git a/web/public/dingtalk-logo.svg b/web/public/dingtalk-logo.svg new file mode 100644 index 000000000..b1a268d15 --- /dev/null +++ b/web/public/dingtalk-logo.svg @@ -0,0 +1,3 @@ + + +