diff --git a/.env.release.example b/.env.release.example index c5ac104a..73800c73 100644 --- a/.env.release.example +++ b/.env.release.example @@ -117,6 +117,27 @@ OAUTH2_GITLAB_CLIENT_SECRET= OAUTH2_GITLAB_BASE_URI=https://gitlab.com OAUTH2_GITLAB_DISPLAY_NAME=GitLab +# Optional: Feishu (Lark) login as a public sign-in provider. Leaving the client id empty keeps +# the button off the login page. Grant contact:user.base:readonly and +# contact:user.email:readonly on the Feishu open-platform app itself; scopes are not sent here. +# Full Feishu endpoints are configurable for Lark international, private deployments, and gateways. +# Legacy OAUTH2_FEISHU_AUTHORIZE_URI/OAUTH2_FEISHU_BASE_URI remain supported as base-URI fallbacks. +# The token endpoint must accept Feishu's JSON authorization-code exchange contract. Supported +# token protocols are v2 and v3; v3 is the default. Selection is explicit and never falls back. +# Feishu emails are admin-imported and never confirmed with the user, so emailVerified is always +# false. If you set skillhub.access-policy.mode=EMAIL_DOMAIN in application.yml, that policy +# denies every unverified email and Feishu login will always fail; keep the default OPEN mode, +# or use another policy, when enabling this provider. +OAUTH2_FEISHU_CLIENT_ID= +OAUTH2_FEISHU_CLIENT_SECRET= +OAUTH2_FEISHU_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize +OAUTH2_FEISHU_PROTOCOL_VERSION=v3 +OAUTH2_FEISHU_TOKEN_URI=https://accounts.feishu.cn/oauth/v3/token +OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info +# Optional; defaults to {baseUrl}/login/oauth2/code/feishu. Set explicitly for local previews or reverse proxies. +OAUTH2_FEISHU_REDIRECT_URI= +OAUTH2_FEISHU_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/charts/skillhub/templates/secret.yaml b/charts/skillhub/templates/secret.yaml index 8e28c911..d00c41a4 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 Feishu (optional) + {{- if .Values.secrets.oauth2FeishuClientId }} + oauth2-feishu-client-id: {{ .Values.secrets.oauth2FeishuClientId | quote }} + {{- end }} + {{- if .Values.secrets.oauth2FeishuClientSecret }} + oauth2-feishu-client-secret: {{ .Values.secrets.oauth2FeishuClientSecret | quote }} + {{- end }} + # Scanner LLM 配置 (optional) {{- if .Values.secrets.scannerLlmApiKey }} skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey | quote }} diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index a6e4e788..7e635fe8 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -355,6 +355,32 @@ spec: key: oauth2-github-client-secret optional: true + # OAuth2 Feishu (optional) + - name: OAUTH2_FEISHU_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: oauth2-feishu-client-id + optional: true + - name: OAUTH2_FEISHU_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "skillhub.secretName" . }} + key: oauth2-feishu-client-secret + optional: true + - name: OAUTH2_FEISHU_AUTHORIZATION_URI + value: {{ .Values.oauth2.feishu.authorizationUri | default "https://accounts.feishu.cn/open-apis/authen/v1/authorize" | quote }} + - name: OAUTH2_FEISHU_PROTOCOL_VERSION + value: {{ .Values.oauth2.feishu.protocolVersion | default "v3" | quote }} + - name: OAUTH2_FEISHU_TOKEN_URI + value: {{ .Values.oauth2.feishu.tokenUri | default "https://accounts.feishu.cn/oauth/v3/token" | quote }} + - name: OAUTH2_FEISHU_USER_INFO_URI + value: {{ .Values.oauth2.feishu.userInfoUri | default "https://open.feishu.cn/open-apis/authen/v1/user_info" | quote }} + {{- with .Values.oauth2.feishu.redirectUri }} + - name: OAUTH2_FEISHU_REDIRECT_URI + value: {{ . | quote }} + {{- end }} + {{- if .Values.server.javaOpts }} - name: JAVA_OPTS value: {{ .Values.server.javaOpts }} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index cd297881..a2c628ab 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -39,6 +39,16 @@ grep -Fq 'fsGroupChangePolicy: OnRootMismatch' "$TMP_DIR/default.yaml" grep -Fq 'type: Recreate' "$TMP_DIR/default.yaml" grep -A1 -F 'name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED' "$TMP_DIR/default.yaml" \ | grep -Fq 'value: "false"' +if grep -Fq 'name: OAUTH2_FEISHU_REDIRECT_URI' "$TMP_DIR/default.yaml"; then + fail "default Helm rendering must omit an empty Feishu redirect URI so Spring can derive baseUrl" +fi + +render feishu-redirect "$CHART_DIR" \ + --set-string oauth2.feishu.redirectUri=https://skills.example.com/login/oauth2/code/feishu \ + --show-only templates/server-deployment.yaml >"$TMP_DIR/feishu-redirect.yaml" +grep -A1 -F 'name: OAUTH2_FEISHU_REDIRECT_URI' "$TMP_DIR/feishu-redirect.yaml" \ + | grep -Fq 'value: "https://skills.example.com/login/oauth2/code/feishu"' \ + || fail "Helm must inject an explicitly configured Feishu redirect URI" render suite-review-enabled "$CHART_DIR" \ --set server.suiteReviewWritesEnabled=true \ diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 1bede63b..e7bb6b47 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -34,6 +34,25 @@ } } }, + "oauth2": { + "type": "object", + "additionalProperties": false, + "required": ["feishu"], + "properties": { + "feishu": { + "type": "object", + "additionalProperties": false, + "required": ["protocolVersion", "tokenUri"], + "properties": { + "authorizationUri": { "type": "string", "format": "uri" }, + "protocolVersion": { "type": "string", "enum": ["v2", "v3"] }, + "tokenUri": { "type": "string", "format": "uri" }, + "userInfoUri": { "type": "string", "format": "uri" }, + "redirectUri": { "type": "string" } + } + } + } + }, "builtinSkills": { "type": "object", "additionalProperties": false, @@ -156,6 +175,8 @@ "downloadAnonCookieSecret": { "type": "string" }, "oauth2GithubClientId": { "type": "string" }, "oauth2GithubClientSecret": { "type": "string" }, + "oauth2FeishuClientId": { "type": "string" }, + "oauth2FeishuClientSecret": { "type": "string" }, "scannerLlmApiKey": { "type": "string" }, "scannerLlmBaseUrl": { "type": "string" }, "scannerLlmModel": { "type": "string" } diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 092e73e4..f004f3f0 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -22,6 +22,14 @@ auth: enabled: true provider: local +oauth2: + feishu: + authorizationUri: https://accounts.feishu.cn/open-apis/authen/v1/authorize + protocolVersion: v3 + tokenUri: https://accounts.feishu.cn/oauth/v3/token + userInfoUri: https://open.feishu.cn/open-apis/authen/v1/user_info + redirectUri: "" + builtinSkills: enabled: true @@ -93,6 +101,8 @@ secrets: downloadAnonCookieSecret: "" oauth2GithubClientId: "" oauth2GithubClientSecret: "" + oauth2FeishuClientId: "" + oauth2FeishuClientSecret: "" scannerLlmApiKey: "" scannerLlmBaseUrl: "" scannerLlmModel: "" diff --git a/compose.release.yml b/compose.release.yml index c707db08..6789ddeb 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -87,6 +87,7 @@ services: SKILLHUB_STORAGE_S3_SECRET_KEY: ${SKILLHUB_STORAGE_S3_SECRET_KEY:-} SKILLHUB_STORAGE_S3_REGION: ${SKILLHUB_STORAGE_S3_REGION:-us-east-1} SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE: ${SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE:-false} + SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING: ${SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING:-false} SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET: ${SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET:-false} SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY: ${SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY:-PT10M} SKILLHUB_SECURITY_SCANNER_ENABLED: ${SKILLHUB_SECURITY_SCANNER_ENABLED:-true} @@ -115,6 +116,18 @@ 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_GITLAB_CLIENT_ID: ${OAUTH2_GITLAB_CLIENT_ID:-local-placeholder} + OAUTH2_GITLAB_CLIENT_SECRET: ${OAUTH2_GITLAB_CLIENT_SECRET:-local-placeholder} + OAUTH2_GITLAB_BASE_URI: ${OAUTH2_GITLAB_BASE_URI:-https://gitlab.com} + OAUTH2_GITLAB_DISPLAY_NAME: ${OAUTH2_GITLAB_DISPLAY_NAME:-GitLab} + OAUTH2_FEISHU_CLIENT_ID: ${OAUTH2_FEISHU_CLIENT_ID:-local-placeholder} + OAUTH2_FEISHU_CLIENT_SECRET: ${OAUTH2_FEISHU_CLIENT_SECRET:-local-placeholder} + OAUTH2_FEISHU_AUTHORIZATION_URI: ${OAUTH2_FEISHU_AUTHORIZATION_URI:-${OAUTH2_FEISHU_AUTHORIZE_URI:-https://accounts.feishu.cn}/open-apis/authen/v1/authorize} + OAUTH2_FEISHU_PROTOCOL_VERSION: ${OAUTH2_FEISHU_PROTOCOL_VERSION:-v3} + OAUTH2_FEISHU_TOKEN_URI: ${OAUTH2_FEISHU_TOKEN_URI:-https://accounts.feishu.cn/oauth/v3/token} + OAUTH2_FEISHU_USER_INFO_URI: ${OAUTH2_FEISHU_USER_INFO_URI:-${OAUTH2_FEISHU_BASE_URI:-https://open.feishu.cn}/open-apis/authen/v1/user_info} + OAUTH2_FEISHU_REDIRECT_URI: ${OAUTH2_FEISHU_REDIRECT_URI:-${SKILLHUB_PUBLIC_BASE_URL:-http://localhost}/login/oauth2/code/feishu} + OAUTH2_FEISHU_DISPLAY_NAME: ${OAUTH2_FEISHU_DISPLAY_NAME:-飞书} 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 816ffed3..52e53b12 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -227,6 +227,27 @@ spec: key: oauth2-github-client-secret optional: true + # OAuth2 Feishu (optional) + - name: OAUTH2_FEISHU_CLIENT_ID + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-feishu-client-id + optional: true + - name: OAUTH2_FEISHU_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-feishu-client-secret + optional: true + - name: OAUTH2_FEISHU_AUTHORIZATION_URI + value: "https://accounts.feishu.cn/open-apis/authen/v1/authorize" + - name: OAUTH2_FEISHU_PROTOCOL_VERSION + value: "v3" + - name: OAUTH2_FEISHU_TOKEN_URI + value: "https://accounts.feishu.cn/oauth/v3/token" + - name: OAUTH2_FEISHU_USER_INFO_URI + value: "https://open.feishu.cn/open-apis/authen/v1/user_info" volumeMounts: - name: skillhub-storage mountPath: /var/lib/skillhub/storage diff --git a/deploy/k8s/base/secret.yaml.example b/deploy/k8s/base/secret.yaml.example index c2b93d45..f9c119d6 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: "" + # 飞书 OAuth(可选,用于飞书登录;留空则登录页不展示该入口) + oauth2-feishu-client-id: "" + oauth2-feishu-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 4c8ef11e..ef5aa98a 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -271,18 +271,69 @@ spring: client-id: ${OAUTH2_GITHUB_CLIENT_ID} client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET} scope: read:user,user:email - # 二期扩展示例: - # gitlab: - # client-id: ... - # authorization-grant-type: authorization_code - # google: - # client-id: ... + gitlab: + client-id: ${OAUTH2_GITLAB_CLIENT_ID} + client-secret: ${OAUTH2_GITLAB_CLIENT_SECRET} + authorization-grant-type: authorization_code + feishu: + provider: feishu + client-id: ${OAUTH2_FEISHU_CLIENT_ID} + client-secret: ${OAUTH2_FEISHU_CLIENT_SECRET} + # 飞书的 scope 配在开放平台应用上,不在这里传 + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + provider: + feishu: + # Full endpoints are configurable for Lark, private deployments, and gateways. + authorization-uri: ${OAUTH2_FEISHU_AUTHORIZATION_URI:${OAUTH2_FEISHU_AUTHORIZE_URI:https://accounts.feishu.cn}/open-apis/authen/v1/authorize} + # OAUTH2_FEISHU_PROTOCOL_VERSION supports v2 and v3; default is v3. + token-uri: ${OAUTH2_FEISHU_TOKEN_URI:https://accounts.feishu.cn/oauth/v3/token} + user-info-uri: ${OAUTH2_FEISHU_USER_INFO_URI:${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info} ``` Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 只需: -1. `application.yml` 添加 registration 配置 -2. `CustomOAuth2UserService` 中按 `registrationId` 分支处理用户属性映射 -3. 前端登录页增加对应按钮(通过 `/api/v1/auth/providers` 自动发现) +1. `application.yml` 添加 registration 与 provider 配置 +2. 实现一个 `OAuthClaimsExtractor`,把该 Provider 的属性映射成统一的 `OAuthClaims` +3. 登录页无需改代码:`/api/v1/auth/methods` 只返回配置了真实 client id 的注册, + 图标按 provider 名解析为 `/{provider}-logo.svg` + +第 2 步是按 Provider 注册一个 Bean,而不是在某个类里按 `registrationId` 分支。 +账号匹配、建号、资料权威和账号守卫都在 `OAuthClaims` 之后共享,Provider 自己不做这些决策。 + +如果该 Provider 的 userinfo 响应不是标准的扁平结构(例如飞书用 +`{code, msg, data}` 信封,且以 HTTP 200 返回错误),再额外实现一个 +`ProviderOAuth2UserService`:它声明自己负责哪个 `registrationId`, +接管 userinfo 的加载步骤,其余流程不变。该覆盖运行在 +`RemoteIdentityIoExecutor` 边界内,因此 Provider 的 HTTP 调用不会持有数据库事务。 + +Provider 侧还需遵守:subject 必须稳定(不要用可能在两次登录间变化的字段做 +fallback,否则同一个人会被拆成两个平台账号)、只有在 Provider 真正证明了邮箱 +所有权时才置 `emailVerified=true`、远程调用要有超时与响应大小上限、 +claims 提取过程不记录 subject/email/token。 + +#### 飞书 token 协议版本 + +飞书 token client 支持显式选择 `v2` 或 `v3`,默认值为 `v3`: + +```bash +OAUTH2_FEISHU_PROTOCOL_VERSION=v3 +OAUTH2_FEISHU_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize +OAUTH2_FEISHU_TOKEN_URI=https://accounts.feishu.cn/oauth/v3/token +OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info +OAUTH2_FEISHU_REDIRECT_URI= + +# 历史 v2 应用可显式切换: +# OAUTH2_FEISHU_PROTOCOL_VERSION=v2 +# OAUTH2_FEISHU_TOKEN_URI=https://open.feishu.cn/open-apis/authen/v2/oauth/token +``` + +两个版本都使用 JSON authorization-code exchange,当前实现会根据协议版本 +选择对应的标准 token endpoint;如需代理、区域或私有化 endpoint,可通过 +`OAUTH2_FEISHU_TOKEN_URI` 覆盖。授权和 userinfo endpoint 也分别通过 +`OAUTH2_FEISHU_AUTHORIZATION_URI`、`OAUTH2_FEISHU_USER_INFO_URI` 配置。协议版本不合法 +时发布配置校验失败,应用也会拒绝启动。不会在 v3 失败后自动使用 v2,因为 authorization code 只能使用一次, +自动重试可能造成重复请求并掩盖配置错误。旧的 `OAUTH2_FEISHU_AUTHORIZE_URI` 和 +`OAUTH2_FEISHU_BASE_URI` 仍作为 base-URI 兼容回退,但新部署应使用完整 endpoint 变量。 ## 4. 核心接口设计 diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 2bbf6c8c..76117217 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -163,7 +163,8 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se - 使用发布镜像,不在用户机器上执行本地构建 - 负责拉起 PostgreSQL、Redis、server、web - PostgreSQL、Redis 默认只绑定到 `127.0.0.1` - - Web 和后端都支持运行时环境变量注入,不需要为每个环境重建镜像 + - Web 和后端都支持运行时环境变量注入,不需要为每个环境重建镜像;S3/OSS 的 + `SKILLHUB_STORAGE_S3_*` 变量会透传到 server - `.env.release.example` - 运行时变量模板 - 包含镜像名、镜像版本、端口、数据库凭证、外部 OSS、站点公网地址和首登管理员参数 @@ -171,6 +172,18 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se - 在启动前校验 `.env.release` - 可提前拦截占位值、URL 格式错误、缺失的 OSS 凭据、危险的明文默认值 +阿里云 OSS 等不支持 AWS chunked encoding 的对象存储,需要在 `.env.release` 中设置: + +```dotenv +SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING=true +``` + +该变量由 `compose.release.yml` 透传到 server;修改后需要重新创建 server 容器: + +```bash +docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate server +``` + ### 5.5 镜像标签约定 - `edge` @@ -283,7 +296,61 @@ services: - `SKILLHUB_WEB_API_BASE_URL=/skillhub` - `SKILLHUB_PUBLIC_BASE_URL=https://example.com/skillhub` 网关可以在转发到 Web 容器前将该前缀重写掉,但公网 URL 仍必须保留前缀,确保 OAuth、CLI 和 registry 链接正确。 -- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` +- 如果要开放真实登录,再补充对应 Provider 的 client id/secret: + - GitHub:`OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` + - GitLab:`OAUTH2_GITLAB_CLIENT_ID` / `OAUTH2_GITLAB_CLIENT_SECRET`(自建实例再设 `OAUTH2_GITLAB_BASE_URI`) + - 飞书:`OAUTH2_FEISHU_CLIENT_ID` / `OAUTH2_FEISHU_CLIENT_SECRET`。 + Endpoint 默认配置为: + - `OAUTH2_FEISHU_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize` + - `OAUTH2_FEISHU_PROTOCOL_VERSION=v3` + - `OAUTH2_FEISHU_TOKEN_URI=https://accounts.feishu.cn/oauth/v3/token` + - `OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info` + - `OAUTH2_FEISHU_REDIRECT_URI=`(可选;Compose 默认根据 + `SKILLHUB_PUBLIC_BASE_URL` 生成 `/login/oauth2/code/feishu`,Helm/K8s 未设置时由 + Spring 使用 `{baseUrl}`;经过特殊反向代理或本地动态端口时应显式设置完整回调 URL) + + Lark 国际版、私有化部署或企业网关可分别覆盖这三个完整 endpoint;历史的 + `OAUTH2_FEISHU_AUTHORIZE_URI` / `OAUTH2_FEISHU_BASE_URI` 仍可作为 base-URI + 兼容回退。`OAUTH2_FEISHU_TOKEN_URI` 必须指向支持 JSON authorization-code + exchange 的 endpoint。`OAUTH2_FEISHU_PROTOCOL_VERSION` 只允许 `v2` 或 `v3`, + 默认 `v3`,不会自动 fallback。 + + 留空即不展示该入口,无需改配置文件。注意:飞书邮箱由企业管理员导入、未经用户 + 确认,因此 `emailVerified` 恒为 false;若在 `application.yml` 中把 + `skillhub.access-policy.mode` 设为 `EMAIL_DOMAIN`,该策略会拒绝所有未验证邮箱, + 飞书登录将一律失败。启用飞书时请保留默认的 `OPEN` 或改用其他准入模式。 + + 启用飞书前,使用一个测试租户完成一次真实回调验收。不要把真实 client secret + 写入仓库、报告或聊天记录;只在受控的 `.env.release`、CI Secret 或 Kubernetes + Secret 中注入: + + 1. 在飞书自建应用中登记 + `https://<公网域名>/login/oauth2/code/feishu`,并开启用户信息所需权限;如果使用 + 本地预览,则把 `OAUTH2_FEISHU_REDIRECT_URI` 设置为预览 Web 地址对应的完整回调 URL。 + 2. 在受控环境设置 `OAUTH2_FEISHU_CLIENT_ID`、`OAUTH2_FEISHU_CLIENT_SECRET`,确认 + `OAUTH2_FEISHU_PROTOCOL_VERSION` 与 token endpoint 匹配,然后运行: + + ```bash + make validate-release-config + docker compose --env-file .env.release -f compose.release.yml up -d + curl -fsS http://127.0.0.1:8080/actuator/health + curl -fsS http://127.0.0.1:8080/api/v1/auth/methods + ``` + + 3. 在登录页选择“飞书”,确认浏览器跳转到配置的授权域名;完成授权后应回到 + `/login/oauth2/code/feishu`,最终进入 `/` 或原始的 root-relative `returnTo`。 + 4. 用同一个飞书账号再次登录,确认仍绑定同一个 SkillHub 账号;再用已禁用的 + SkillHub 账号登录,预期跳转 `/access-denied`,且不创建新 Session。 + 5. 检查日志中只有 provider、HTTP 状态、错误码和阶段信息,不应出现 client secret、 + authorization code、access token、`open_id` 或上游错误文本: + + ```bash + docker compose -f compose.release.yml logs --tail=200 server \ + | rg -i 'client_secret|authorization code|access[_-]?token|open_id|secret|token' + ``` + + 本地 mock 回调只能证明 SkillHub 与协议形状的集成,不能替代上述真实租户验收。 + 没有可用飞书租户时,应将该项记录为“未验证”,不要宣称 Feishu 登录已通过。 - 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md` ## 8 OIDC 登录配置 diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index acfe6d89..c00abdf8 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -190,9 +190,14 @@ A: Skill names are generally in English; Chinese names are not currently support A: As long as you have permission to view it, it can generally be downloaded. -## Q: How do I hide or remove the GitHub / GitLab SSO login options on the login page? +## Q: How do I hide or remove third-party SSO login options on the login page? -A: Edit `application.yml` and comment out or delete the `github` and `gitlab` blocks under `spring.security.oauth2.client.registration`, along with their corresponding `provider` sections. Spring Boot then won't create these registrations at startup, and the login page won't show those entries. +A: Login entries are config-driven: `/api/v1/auth/methods` only returns registrations that have a real client id. When a client id is empty or contains `placeholder`, that entry never reaches the login page. + +So there are two ways to hide one: + +- Leave the matching environment variable unset (for example, omit `OAUTH2_FEISHU_CLIENT_ID`). No config file change needed. +- Or edit `application.yml` and comment out or delete the relevant registration block (`github`, `gitlab`, `feishu`) under `spring.security.oauth2.client.registration`, along with its `provider` section. Spring Boot then won't create that registration at startup. ## Q: Is SkillHub's security scanning (Skill Scanner) developed in-house by iFLYTEK? What license does it use? diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index 8906150c..75054ca0 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -190,9 +190,17 @@ A: skill name 一般使用英文,目前不支持中文名(在 OpenClaw 中 A: 只要拥有可查看的权限,一般都可以下载。 -## Q: 如何隐藏或删除登录页的 GitHub / GitLab SSO 登录方式? +## Q: 如何隐藏或删除登录页的第三方 SSO 登录方式? -A: 修改 `application.yml`,注释或删除 `spring.security.oauth2.client.registration` 下的 `github` 和 `gitlab` 两块,并删除对应的 `provider` 段。Spring Boot 启动时便不会创建这两个注册,登录页也不会再显示对应入口。 +A: 登录入口是配置驱动的:`/api/v1/auth/methods` 只返回配置了真实 client id 的 +注册,client id 为空或包含 `placeholder` 时该入口不会出现在登录页。 + +所以隐藏某个入口有两种方式: + +- 留空对应的环境变量即可(例如不设置 `OAUTH2_FEISHU_CLIENT_ID`),无需改动配置文件。 +- 或修改 `application.yml`,注释/删除 `spring.security.oauth2.client.registration` + 下对应的注册块(`github`、`gitlab`、`feishu`)以及对应的 `provider` 段, + Spring Boot 启动时便不会创建该注册。 ## Q: SkillHub 的安全扫描(Skill Scanner)是讯飞自研的吗?使用什么协议? diff --git a/scripts/tests/validate-release-config-test.sh b/scripts/tests/validate-release-config-test.sh index 1ec54043..e18e2648 100755 --- a/scripts/tests/validate-release-config-test.sh +++ b/scripts/tests/validate-release-config-test.sh @@ -68,8 +68,45 @@ tmp="$(new_tmp)" valid_env="$tmp/valid.env" write_env "$valid_env" "release-download-secret-32-bytes-minimum" +printf '%s\n' "SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING=true" >>"$valid_env" "$SCRIPT" "$valid_env" >/dev/null +compose_default_redirect="$tmp/compose-default-redirect.txt" +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=release-download-secret-32-bytes-minimum \ +SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com \ + docker compose -f "$REPO_ROOT/compose.release.yml" config \ + | grep -A1 'OAUTH2_FEISHU_REDIRECT_URI:' >"$compose_default_redirect" +grep -Fq 'https://skillhub.example.com/login/oauth2/code/feishu' "$compose_default_redirect" \ + || fail "compose must derive the default Feishu redirect URI from SKILLHUB_PUBLIC_BASE_URL" + +valid_feishu_env="$tmp/valid-feishu.env" +write_env "$valid_feishu_env" "release-download-secret-32-bytes-minimum" +cat >>"$valid_feishu_env" <<'EOF' +OAUTH2_FEISHU_CLIENT_ID=cli_test +OAUTH2_FEISHU_CLIENT_SECRET=secret_test +OAUTH2_FEISHU_PROTOCOL_VERSION=v2 +OAUTH2_FEISHU_AUTHORIZATION_URI=https://accounts.feishu.cn/open-apis/authen/v1/authorize +OAUTH2_FEISHU_TOKEN_URI=https://open.feishu.cn/open-apis/authen/v2/oauth/token +OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info +OAUTH2_FEISHU_REDIRECT_URI=http://127.0.0.1:55041/login/oauth2/code/feishu +EOF +"$SCRIPT" "$valid_feishu_env" >/dev/null + +invalid_feishu_protocol_env="$tmp/invalid-feishu-protocol.env" +write_env "$invalid_feishu_protocol_env" "release-download-secret-32-bytes-minimum" +printf '%s\n' "OAUTH2_FEISHU_PROTOCOL_VERSION=v1" >>"$invalid_feishu_protocol_env" +expect_fail "$invalid_feishu_protocol_env" "OAUTH2_FEISHU_PROTOCOL_VERSION must be either v2 or v3" + +invalid_feishu_endpoint_env="$tmp/invalid-feishu-endpoint.env" +write_env "$invalid_feishu_endpoint_env" "release-download-secret-32-bytes-minimum" +printf '%s\n' "OAUTH2_FEISHU_TOKEN_URI=https://open.feishu.cn/oauth/token?tenant=prod" >>"$invalid_feishu_endpoint_env" +expect_fail "$invalid_feishu_endpoint_env" "OAUTH2_FEISHU_TOKEN_URI must not contain a query" + +invalid_feishu_redirect_env="$tmp/invalid-feishu-redirect.env" +write_env "$invalid_feishu_redirect_env" "release-download-secret-32-bytes-minimum" +printf '%s\n' "OAUTH2_FEISHU_REDIRECT_URI=https://skillhub.example.com/login/oauth2/code/feishu?bad=1" >>"$invalid_feishu_redirect_env" +expect_fail "$invalid_feishu_redirect_env" "OAUTH2_FEISHU_REDIRECT_URI must not contain a query" + disabled_builtin_skills_env="$tmp/disabled-builtin-skills.env" write_env "$disabled_builtin_skills_env" "release-download-secret-32-bytes-minimum" printf '%s\n' "SKILLHUB_BUILTIN_SKILLS_ENABLED=false" >>"$disabled_builtin_skills_env" @@ -253,6 +290,29 @@ 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" +# An OAuth client id without its secret (or vice versa) leaves the provider half-configured: +# the login button renders but the exchange fails. Checked for every supported provider. +for provider in GITHUB GITLAB FEISHU; do + missing_oauth_secret_env="$tmp/missing-oauth-secret.env" + write_env "$missing_oauth_secret_env" "release-download-secret-32-bytes-minimum" + printf 'OAUTH2_%s_CLIENT_ID=real-client-id\n' "$provider" >>"$missing_oauth_secret_env" + expect_fail "$missing_oauth_secret_env" "OAUTH2_${provider}_CLIENT_SECRET is required" + + missing_oauth_id_env="$tmp/missing-oauth-id.env" + write_env "$missing_oauth_id_env" "release-download-secret-32-bytes-minimum" + printf 'OAUTH2_%s_CLIENT_SECRET=real-client-secret\n' "$provider" >>"$missing_oauth_id_env" + expect_fail "$missing_oauth_id_env" "OAUTH2_${provider}_CLIENT_ID is required" +done + +# A fully configured provider pair must pass. +valid_oauth_env="$tmp/valid-oauth.env" +write_env "$valid_oauth_env" "release-download-secret-32-bytes-minimum" +cat >>"$valid_oauth_env" <<'EOF' +OAUTH2_FEISHU_CLIENT_ID=cli_release_example +OAUTH2_FEISHU_CLIENT_SECRET=release-feishu-secret +EOF +"$SCRIPT" "$valid_oauth_env" >/dev/null + draft_env="$tmp/draft.env" while IFS= read -r line || [[ -n "$line" ]]; do case "$line" in diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index eaaca6b1..e9a85899 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -380,14 +380,31 @@ if [ "${REDIS_BIND_ADDRESS:-127.0.0.1}" != "127.0.0.1" ]; then warn "REDIS_BIND_ADDRESS is not 127.0.0.1; confirm Redis exposure is intended" fi -oauth_id="${OAUTH2_GITHUB_CLIENT_ID:-}" -oauth_secret="${OAUTH2_GITHUB_CLIENT_SECRET:-}" -if [ -n "$oauth_id" ] && [ -z "$oauth_secret" ]; then - error "OAUTH2_GITHUB_CLIENT_SECRET is required when OAUTH2_GITHUB_CLIENT_ID is set" -fi -if [ -n "$oauth_secret" ] && [ -z "$oauth_id" ]; then - error "OAUTH2_GITHUB_CLIENT_ID is required when OAUTH2_GITHUB_CLIENT_SECRET is set" -fi +for provider in GITHUB GITLAB FEISHU; do + eval "oauth_id=\"\${OAUTH2_${provider}_CLIENT_ID:-}\"" + eval "oauth_secret=\"\${OAUTH2_${provider}_CLIENT_SECRET:-}\"" + if [ -n "$oauth_id" ] && [ -z "$oauth_secret" ]; then + error "OAUTH2_${provider}_CLIENT_SECRET is required when OAUTH2_${provider}_CLIENT_ID is set" + fi + if [ -n "$oauth_secret" ] && [ -z "$oauth_id" ]; then + error "OAUTH2_${provider}_CLIENT_ID is required when OAUTH2_${provider}_CLIENT_SECRET is set" + fi +done + +feishu_protocol="${OAUTH2_FEISHU_PROTOCOL_VERSION:-v3}" +case "$feishu_protocol" in + v2|v3) ;; + *) error "OAUTH2_FEISHU_PROTOCOL_VERSION must be either v2 or v3" ;; +esac + +# OAuth endpoints are sent directly to the provider. Validate them here so a +# typo fails before the release container starts. +for feishu_endpoint in OAUTH2_FEISHU_AUTHORIZATION_URI OAUTH2_FEISHU_TOKEN_URI OAUTH2_FEISHU_USER_INFO_URI OAUTH2_FEISHU_REDIRECT_URI; do + eval "feishu_endpoint_value=\${$feishu_endpoint:-}" + if [ -n "$feishu_endpoint_value" ]; then + validate_url "$feishu_endpoint" + fi +done if [ "$errors" -gt 0 ]; then echo "Release config validation failed: $errors error(s), $warnings warning(s)." >&2 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index 551c7fb8..00e08dba 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -14,6 +14,7 @@ import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingResponseWrapper; import java.io.IOException; +import java.util.Locale; import java.util.Set; /** @@ -54,7 +55,7 @@ public class RequestLoggingFilter extends OncePerRequestFilter { private void logRequest(ContentCachingRequestWrapper request, ContentCachingResponseWrapper response, long duration) { String requestUri = request.getRequestURI(); String queryString = request.getQueryString(); - String fullUrl = queryString != null ? requestUri + "?" + queryString : requestUri; + String fullUrl = queryString != null ? requestUri + "?" + sanitizeQueryString(queryString) : requestUri; String contentType = request.getContentType(); String userAgent = request.getHeader("User-Agent"); @@ -74,6 +75,26 @@ public class RequestLoggingFilter extends OncePerRequestFilter { log.info(sb.toString()); } + private String sanitizeQueryString(String queryString) { + return java.util.Arrays.stream(queryString.split("&", -1)) + .map(parameter -> { + int separator = parameter.indexOf('='); + if (separator < 0) { + return parameter; + } + String name = parameter.substring(0, separator).toLowerCase(Locale.ROOT); + return isSensitiveQueryParameter(name) + ? parameter.substring(0, separator) + "=[REDACTED]" + : parameter; + }) + .collect(java.util.stream.Collectors.joining("&")); + } + + private boolean isSensitiveQueryParameter(String name) { + return Set.of("code", "state", "error", "error_description", "error_uri", "access_token", + "refresh_token", "id_token", "client_secret").contains(name); + } + private boolean shouldSkip(String uri) { for (String prefix : SKIP_PREFIXES) { if (uri.startsWith(prefix)) { diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index c5507532..0328a749 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -70,6 +70,16 @@ spring: authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab} + feishu: + provider: feishu + client-id: ${OAUTH2_FEISHU_CLIENT_ID:placeholder} + client-secret: ${OAUTH2_FEISHU_CLIENT_SECRET:placeholder} + # Feishu scopes are configured on the open platform app itself + # (contact:user.base:readonly, contact:user.email:readonly). + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + redirect-uri: "${OAUTH2_FEISHU_REDIRECT_URI:{baseUrl}/login/oauth2/code/{registrationId}}" + client-name: ${OAUTH2_FEISHU_DISPLAY_NAME:飞书} provider: github: api-base-url: ${OAUTH2_GITHUB_API_BASE_URL:https://api.github.com} @@ -79,6 +89,14 @@ 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 + feishu: + # Full endpoints are configurable for Lark, private deployments, and gateways. + # The legacy base-URI variables remain as compatibility fallbacks. + authorization-uri: ${OAUTH2_FEISHU_AUTHORIZATION_URI:${OAUTH2_FEISHU_AUTHORIZE_URI:https://accounts.feishu.cn}/open-apis/authen/v1/authorize} + # Supported values: v2 and v3. V3 is the default; selection is explicit and never falls back. + token-uri: ${OAUTH2_FEISHU_TOKEN_URI:https://accounts.feishu.cn/oauth/v3/token} + user-info-uri: ${OAUTH2_FEISHU_USER_INFO_URI:${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info} + user-name-attribute: open_id servlet: multipart: max-file-size: 100MB diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/FeishuOAuthBrowserCallbackIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/FeishuOAuthBrowserCallbackIntegrationTest.java new file mode 100644 index 00000000..52108227 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/FeishuOAuthBrowserCallbackIntegrationTest.java @@ -0,0 +1,196 @@ +package com.iflytek.skillhub.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +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.mock.web.MockHttpSession; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Exercises the browser-facing Feishu OAuth flow against a local protocol-compatible provider. + * The mock intentionally implements the authorization redirect, JSON token exchange, and wrapped + * user-info response rather than mocking Spring Security internals. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class FeishuOAuthBrowserCallbackIntegrationTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final HttpServer PROVIDER_SERVER = startProviderServer(); + private static final String PROVIDER_BASE_URI = "http://127.0.0.1:" + PROVIDER_SERVER.getAddress().getPort(); + private static final AtomicReference TOKEN_REQUEST_CONTENT_TYPE = new AtomicReference<>(); + private static final AtomicReference TOKEN_REQUEST_BODY = new AtomicReference<>(); + private static final AtomicReference USERINFO_AUTHORIZATION = new AtomicReference<>(); + + @Autowired + private MockMvc mockMvc; + + @MockBean + private GlobalNamespaceMembershipService globalNamespaceMembershipService; + + @BeforeAll + static void startProvider() { + PROVIDER_SERVER.start(); + } + + @AfterAll + static void stopProvider() { + PROVIDER_SERVER.stop(0); + } + + @DynamicPropertySource + static void feishuProperties(DynamicPropertyRegistry registry) { + registry.add("spring.security.oauth2.client.registration.feishu.client-id", + () -> "mock-feishu-client"); + registry.add("spring.security.oauth2.client.registration.feishu.client-secret", + () -> "mock-feishu-secret"); + registry.add("spring.security.oauth2.client.provider.feishu.authorization-uri", + () -> PROVIDER_BASE_URI + "/authorize"); + registry.add("spring.security.oauth2.client.provider.feishu.token-uri", + () -> PROVIDER_BASE_URI + "/oauth/v3/token"); + registry.add("spring.security.oauth2.client.provider.feishu.user-info-uri", + () -> PROVIDER_BASE_URI + "/open-apis/authen/v1/user_info"); + registry.add("spring.security.oauth2.client.provider.feishu.user-name-attribute", + () -> "open_id"); + } + + @Test + void browserAuthorizationCallbackExchangesJsonTokenLoadsUserAndCreatesSession() throws Exception { + TOKEN_REQUEST_CONTENT_TYPE.set(null); + TOKEN_REQUEST_BODY.set(null); + USERINFO_AUTHORIZATION.set(null); + + MvcResult authorization = mockMvc.perform(get("/oauth2/authorization/feishu") + .param("returnTo", "/dashboard")) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + URI providerAuthorization = URI.create(authorization.getResponse().getHeader("Location")); + assertThat(providerAuthorization.getPath()).isEqualTo("/authorize"); + Map authorizationParameters = queryParameters(providerAuthorization.getRawQuery()); + assertThat(authorizationParameters.get("client_id")).isEqualTo("mock-feishu-client"); + assertThat(authorizationParameters.get("redirect_uri")) + .isEqualTo("http://localhost/login/oauth2/code/feishu"); + assertThat(authorizationParameters.get("state")).isNotBlank(); + + HttpResponse providerAuthorizationResponse = HttpClient.newHttpClient().send( + HttpRequest.newBuilder(providerAuthorization).GET().build(), + HttpResponse.BodyHandlers.discarding()); + assertThat(providerAuthorizationResponse.statusCode()).isEqualTo(302); + URI callback = URI.create(providerAuthorizationResponse.headers().firstValue("Location").orElseThrow()); + assertThat(queryParameters(callback.getRawQuery())) + .containsEntry("code", "mock-authorization-code") + .containsEntry("state", authorizationParameters.get("state")); + + MockHttpSession session = (MockHttpSession) authorization.getRequest().getSession(false); + MvcResult callbackResult = mockMvc.perform(get(callback.getPath() + "?" + callback.getRawQuery()) + .session(session)) + .andExpect(redirectedUrl("/dashboard")) + .andReturn(); + + assertThat(TOKEN_REQUEST_CONTENT_TYPE).hasValue("application/json;charset=utf-8"); + JsonNode tokenRequest = OBJECT_MAPPER.readTree(TOKEN_REQUEST_BODY.get()); + assertThat(tokenRequest.path("grant_type").asText()).isEqualTo("authorization_code"); + assertThat(tokenRequest.path("client_id").asText()).isEqualTo("mock-feishu-client"); + assertThat(tokenRequest.path("client_secret").asText()).isEqualTo("mock-feishu-secret"); + assertThat(tokenRequest.path("code").asText()).isEqualTo("mock-authorization-code"); + assertThat(USERINFO_AUTHORIZATION).hasValue("Bearer mock-access-token"); + assertThat(callbackResult.getRequest().getSession(false)).isSameAs(session); + } + + private static HttpServer startProviderServer() { + try { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/authorize", FeishuOAuthBrowserCallbackIntegrationTest::authorize); + server.createContext("/oauth/v3/token", FeishuOAuthBrowserCallbackIntegrationTest::token); + server.createContext("/open-apis/authen/v1/user_info", FeishuOAuthBrowserCallbackIntegrationTest::userInfo); + return server; + } catch (IOException exception) { + throw new ExceptionInInitializerError(exception); + } + } + + private static void authorize(HttpExchange exchange) throws IOException { + Map parameters = queryParameters(exchange.getRequestURI().getRawQuery()); + URI redirect = URI.create(parameters.get("redirect_uri")); + String separator = redirect.getRawQuery() == null ? "?" : "&"; + URI callback = URI.create(redirect + separator + "code=mock-authorization-code&state=" + + parameters.get("state")); + redirect(exchange, callback.toString()); + } + + private static void token(HttpExchange exchange) throws IOException { + TOKEN_REQUEST_CONTENT_TYPE.set(exchange.getRequestHeaders().getFirst("Content-Type")); + TOKEN_REQUEST_BODY.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + respond(exchange, 200, """ + {"code":0,"access_token":"mock-access-token","token_type":"Bearer",\n"expires_in":3600,"scope":"contact:user.base:readonly"} + """.replace("\n", "")); + } + + private static void userInfo(HttpExchange exchange) throws IOException { + USERINFO_AUTHORIZATION.set(exchange.getRequestHeaders().getFirst("Authorization")); + respond(exchange, 200, """ + {"code":0,"msg":"ok","data":{"open_id":"mock-open-id","name":"Mock Feishu User","email":"mock@example.com"}} + """); + } + + private static void redirect(HttpExchange exchange, String location) throws IOException { + exchange.getResponseHeaders().set("Location", location); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + private static Map queryParameters(String rawQuery) { + Map parameters = new HashMap<>(); + if (rawQuery == null || rawQuery.isBlank()) { + return parameters; + } + for (String pair : rawQuery.split("&")) { + String[] keyValue = pair.split("=", 2); + parameters.put(urlDecode(keyValue[0]), keyValue.length == 2 ? urlDecode(keyValue[1]) : ""); + } + return parameters; + } + + private static String urlDecode(String value) { + return java.net.URLDecoder.decode(value, StandardCharsets.UTF_8); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index b3a4b476..9b748099 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -104,6 +104,28 @@ class RequestLoggingFilterTest { assertThat(loggedMessages()).noneMatch(message -> message.contains("Headers: {")); } + @Test + void doFilterInternal_redactsOAuthCallbackQueryParameters() throws Exception { + RequestLoggingFilter filter = new RequestLoggingFilter(); + attachAppender(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/login/oauth2/code/feishu"); + request.setQueryString("code=authorization-code&state=csrf-state&scope=contact:user.base:readonly"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, (req, res) -> {}); + + String message = loggedMessages().stream() + .filter(entry -> entry.contains("GET /login/oauth2/code/feishu")) + .findFirst() + .orElseThrow(); + assertThat(message).contains("code=[REDACTED]"); + assertThat(message).contains("state=[REDACTED]"); + assertThat(message).contains("scope=contact:user.base:readonly"); + assertThat(message).doesNotContain("authorization-code"); + assertThat(message).doesNotContain("csrf-state"); + } + @Test void doFilterInternal_shouldKeepCachingWrapperForRegularApiResponses() throws Exception { RequestLoggingFilter filter = new RequestLoggingFilter(); 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 91942f56..5880f58a 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,7 @@ package com.iflytek.skillhub.auth.config; import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; import com.iflytek.skillhub.auth.oauth.CustomOidcUserService; +import com.iflytek.skillhub.auth.oauth.FeishuOAuth2AccessTokenResponseClient; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; @@ -61,6 +62,7 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final CustomOidcUserService customOidcUserService; + private final FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient; private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver; private final OAuth2LoginSuccessHandler successHandler; private final OAuth2LoginFailureHandler failureHandler; @@ -75,6 +77,7 @@ public class SecurityConfig { public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomOidcUserService customOidcUserService, + FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient, SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, OAuth2LoginSuccessHandler successHandler, OAuth2LoginFailureHandler failureHandler, @@ -88,6 +91,7 @@ public class SecurityConfig { @Value("${server.servlet.session.cookie.name:SESSION}") String sessionCookieName) { this.customOAuth2UserService = customOAuth2UserService; this.customOidcUserService = customOidcUserService; + this.feishuOAuth2AccessTokenResponseClient = feishuOAuth2AccessTokenResponseClient; this.authorizationRequestResolver = authorizationRequestResolver; this.successHandler = successHandler; this.failureHandler = failureHandler; @@ -132,6 +136,8 @@ public class SecurityConfig { }) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver)) + .tokenEndpoint(tokenEndpoint -> tokenEndpoint + .accessTokenResponseClient(feishuOAuth2AccessTokenResponseClient)) .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService) .oidcUserService(customOidcUserService)) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java new file mode 100644 index 00000000..ba7fded0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java @@ -0,0 +1,71 @@ +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 Feishu (Lark) OAuth users. Attributes are already + * unwrapped from the Feishu response envelope by {@link FeishuOAuth2UserService}. + * + *

Like the GitHub and GitLab extractors, this class logs nothing: the subject, display name + * and email it handles are exactly the values that must stay out of the logs. + */ +@Component +public class FeishuClaimsExtractor implements OAuthClaimsExtractor { + + @Override + public String getProvider() { + return FeishuOAuth2UserService.PROVIDER; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { + Map attrs = oAuth2User.getAttributes(); + + // open_id is the stable primary subject: unique per user within one Feishu app, and it is + // what Feishu guarantees to keep across logins. union_id stays in extra rather than acting + // as a fallback -- a subject that can silently change identity between logins would bind + // the same person to two platform accounts. Promoting union_id later needs an explicit + // alias migration, not a fallback here. + String subject = requireText(attrs.get("open_id"), "open_id"); + + String email = (String) attrs.get("enterprise_email"); + if (email == null) { + email = (String) attrs.get("email"); + } + // Feishu emails are imported by the organization admin and not verified with the user + // in real time, so they carry no verification signal; keep emailVerified false. + boolean emailVerified = false; + + // name -> en_name and stop, matching the GitHub and GitLab extractors. Falling back to the + // subject would write it into UserAccount.displayName and into UserActivatedEvent, pushing + // the external subject somewhere event consumers may log it. + String username = (String) attrs.get("name"); + if (username == null || username.isBlank()) { + username = (String) attrs.get("en_name"); + } + + return new OAuthClaims( + FeishuOAuth2UserService.PROVIDER, + subject, + email, + emailVerified, + username, + attrs + ); + } + + private static String requireText(Object value, String attribute) { + String text = value == null ? null : String.valueOf(value).trim(); + if (text == null || text.isEmpty()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("missing_subject", "Feishu user info is missing " + attribute, null) + ); + } + return text; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java new file mode 100644 index 00000000..401fa98e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClient.java @@ -0,0 +1,241 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.security.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.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +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.RestClient; + +/** + * Provider-aware authorization-code token client. Feishu's token endpoint accepts a JSON request + * and returns business errors in a HTTP-200 response, unlike the form-based OAuth client used by + * the other providers. + */ +@Component +public class FeishuOAuth2AccessTokenResponseClient + implements OAuth2AccessTokenResponseClient { + + private static final Logger log = LoggerFactory.getLogger(FeishuOAuth2AccessTokenResponseClient.class); + private static final String FEISHU_PROVIDER = "feishu"; + private static final String V2 = "v2"; + private static final String V3 = "v3"; + private static final String DEFAULT_V2_TOKEN_URI = "https://open.feishu.cn/open-apis/authen/v2/oauth/token"; + private static final String DEFAULT_V3_TOKEN_URI = "https://accounts.feishu.cn/oauth/v3/token"; + private static final String INVALID_TOKEN_RESPONSE = "feishu_invalid_token_response"; + private static final int MAX_RESPONSE_BYTES = 64 * 1024; + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(10); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final RestClient restClient; + private final OAuth2AccessTokenResponseClient standardClient; + private final String protocolVersion; + + @Autowired + public FeishuOAuth2AccessTokenResponseClient( + @Value("${OAUTH2_FEISHU_PROTOCOL_VERSION:v3}") String protocolVersion) { + this(RestClient.builder().requestFactory(defaultRequestFactory()), + new DefaultAuthorizationCodeTokenResponseClient(), protocolVersion); + } + + FeishuOAuth2AccessTokenResponseClient( + RestClient.Builder restClientBuilder) { + this(restClientBuilder, new DefaultAuthorizationCodeTokenResponseClient(), V3); + } + + FeishuOAuth2AccessTokenResponseClient( + RestClient.Builder restClientBuilder, + OAuth2AccessTokenResponseClient standardClient) { + this(restClientBuilder, standardClient, V3); + } + + FeishuOAuth2AccessTokenResponseClient( + RestClient.Builder restClientBuilder, + OAuth2AccessTokenResponseClient standardClient, + String protocolVersion) { + this.restClient = restClientBuilder.build(); + this.standardClient = standardClient; + this.protocolVersion = normalizeProtocolVersion(protocolVersion); + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse( + OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) { + if (!FEISHU_PROVIDER.equals(authorizationCodeGrantRequest.getClientRegistration().getRegistrationId())) { + return standardClient.getTokenResponse(authorizationCodeGrantRequest); + } + + Map requestBody = new LinkedHashMap<>(); + requestBody.put("grant_type", "authorization_code"); + requestBody.put("client_id", authorizationCodeGrantRequest.getClientRegistration().getClientId()); + requestBody.put("client_secret", authorizationCodeGrantRequest.getClientRegistration().getClientSecret()); + requestBody.put("code", authorizationCodeGrantRequest.getAuthorizationExchange() + .getAuthorizationResponse().getCode()); + + String redirectUri = authorizationCodeGrantRequest.getAuthorizationExchange() + .getAuthorizationRequest().getRedirectUri(); + if (redirectUri != null && !redirectUri.isBlank()) { + requestBody.put("redirect_uri", redirectUri); + } + Object codeVerifier = authorizationCodeGrantRequest.getAuthorizationExchange() + .getAuthorizationRequest().getAttribute("code_verifier"); + if (codeVerifier instanceof String verifier && !verifier.isBlank()) { + requestBody.put("code_verifier", verifier); + } + + String tokenEndpoint = tokenUri(authorizationCodeGrantRequest); + log.info("Feishu token exchange started: protocolVersion={}, endpointHost={}, redirectUriPresent={}, pkcePresent={}", + protocolVersion, + endpointHost(tokenEndpoint), + redirectUri != null && !redirectUri.isBlank(), + codeVerifier instanceof String verifier && !verifier.isBlank()); + try { + return restClient.post() + .uri(tokenEndpoint) + .contentType(MediaType.parseMediaType("application/json; charset=utf-8")) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .body(requestBody) + .exchange((request, response) -> { + int status = response.getStatusCode().value(); + log.info("Feishu token exchange response: httpStatus={}", status); + if (!response.getStatusCode().is2xxSuccessful()) { + throw tokenError("Feishu token endpoint returned HTTP " + status); + } + return parseResponse(readBounded(response.getBody())); + }); + } catch (OAuth2AuthorizationException exception) { + throw exception; + } catch (Exception exception) { + throw tokenError("Feishu token exchange failed", exception); + } + } + + private static OAuth2AccessTokenResponse parseResponse(byte[] responseBytes) { + try { + JsonNode response = OBJECT_MAPPER.readTree(responseBytes); + int code = response.path("code").asInt(-1); + if (code != 0) { + throw tokenError("Feishu token endpoint returned business error code " + code); + } + + String accessToken = text(response, "access_token"); + if (accessToken == null) { + throw tokenError("Feishu token endpoint returned no access token"); + } + + String tokenType = text(response, "token_type"); + if (tokenType != null && !"Bearer".equalsIgnoreCase(tokenType)) { + throw tokenError("Feishu token endpoint returned unsupported token type"); + } + long expiresIn = response.path("expires_in").asLong(-1); + if (expiresIn <= 0) { + throw tokenError("Feishu token endpoint returned invalid expires_in"); + } + + OAuth2AccessTokenResponse.Builder tokenResponse = OAuth2AccessTokenResponse + .withToken(accessToken) + .tokenType(OAuth2AccessToken.TokenType.BEARER) + .expiresIn(expiresIn); + String refreshToken = text(response, "refresh_token"); + if (refreshToken != null) { + tokenResponse.refreshToken(refreshToken); + } + String scope = text(response, "scope"); + if (scope != null) { + tokenResponse.scopes(Set.of(scope.trim().split("\\s+"))); + } + log.info("Feishu token exchange parsed: businessCode=0, accessTokenPresent={}, refreshTokenPresent={}, expiresInSeconds={}, scopePresent={}", + accessToken != null, + refreshToken != null, + expiresIn, + scope != null); + return tokenResponse.build(); + } catch (OAuth2AuthorizationException exception) { + throw exception; + } catch (Exception exception) { + throw tokenError("Feishu token endpoint returned an invalid response", exception); + } + } + + private String tokenUri(OAuth2AuthorizationCodeGrantRequest request) { + String configuredUri = request.getClientRegistration().getProviderDetails().getTokenUri(); + if (V2.equals(protocolVersion) && DEFAULT_V3_TOKEN_URI.equals(configuredUri)) { + return DEFAULT_V2_TOKEN_URI; + } + if (V3.equals(protocolVersion) && DEFAULT_V2_TOKEN_URI.equals(configuredUri)) { + return DEFAULT_V3_TOKEN_URI; + } + return configuredUri; + } + + private static String normalizeProtocolVersion(String value) { + String normalized = value == null ? V3 : value.trim().toLowerCase(java.util.Locale.ROOT); + if (!V2.equals(normalized) && !V3.equals(normalized)) { + throw new IllegalArgumentException( + "OAUTH2_FEISHU_PROTOCOL_VERSION must be either v2 or v3"); + } + return normalized; + } + + private static String endpointHost(String endpoint) { + try { + return java.net.URI.create(endpoint).getHost(); + } catch (IllegalArgumentException exception) { + return "invalid"; + } + } + + private static String text(JsonNode node, String field) { + JsonNode value = node.get(field); + return value != null && value.isTextual() && !value.textValue().isBlank() + ? value.textValue() + : null; + } + + private static ClientHttpRequestFactory defaultRequestFactory() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT); + factory.setReadTimeout(READ_TIMEOUT); + return factory; + } + + private static byte[] readBounded(InputStream body) throws IOException { + if (body == null) { + throw new IOException("empty response body"); + } + byte[] bytes = body.readNBytes(MAX_RESPONSE_BYTES + 1); + if (bytes.length > MAX_RESPONSE_BYTES) { + throw new IOException("response body exceeds configured limit"); + } + return bytes; + } + + private static OAuth2AuthorizationException tokenError(String description) { + return tokenError(description, null); + } + + private static OAuth2AuthorizationException tokenError(String description, Throwable cause) { + OAuth2Error error = new OAuth2Error(INVALID_TOKEN_RESPONSE, description, null); + return cause == null ? new OAuth2AuthorizationException(error) : new OAuth2AuthorizationException(error, cause); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java new file mode 100644 index 00000000..a2608cca --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java @@ -0,0 +1,201 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * Loads Feishu (Lark) user info, which deviates from the standard OAuth format: the response is + * wrapped in a {@code {code, msg, data}} envelope and errors are reported with HTTP 200. + */ +@Component +public class FeishuOAuth2UserService implements ProviderOAuth2UserService { + + private static final Logger log = LoggerFactory.getLogger(FeishuOAuth2UserService.class); + + static final String PROVIDER = "feishu"; + + private final RestClient restClient; + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(10); + + /** A Feishu user_info payload is well under 1 KB; this only needs to stop an unbounded body. */ + private static final int MAX_RESPONSE_BYTES = 64 * 1024; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Uses an external-service client that is intentionally not customized with application + * tracing. Trace context must not be propagated to the external Feishu service. + */ + @Autowired + public FeishuOAuth2UserService() { + this(RestClient.builder().requestFactory(defaultRequestFactory())); + } + + public FeishuOAuth2UserService(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + /** + * Bounds the userinfo call so an unresponsive Feishu endpoint cannot hold a login thread. The + * timeouts apply to this provider client only and do not change the shared HTTP defaults. + */ + private static ClientHttpRequestFactory defaultRequestFactory() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT); + factory.setReadTimeout(READ_TIMEOUT); + return factory; + } + + /** + * Reads at most {@link #MAX_RESPONSE_BYTES} before parsing, so a misconfigured or hostile + * A misconfigured Feishu user-info endpoint cannot stream an unbounded body into the parser. Reading one + * byte past the cap is what distinguishes an oversized payload from one that exactly fills it. + */ + private static FeishuUserResponse readBounded(InputStream body) throws IOException { + byte[] bytes = body.readNBytes(MAX_RESPONSE_BYTES + 1); + if (bytes.length > MAX_RESPONSE_BYTES) { + throw new IOException("Feishu user info response exceeds " + MAX_RESPONSE_BYTES + " bytes"); + } + return OBJECT_MAPPER.readValue(bytes, FeishuUserResponse.class); + } + + @Override + public String getProvider() { + return PROVIDER; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + String userInfoUri = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUri(); + + log.info("Feishu userinfo started: endpointHost={}, accessTokenPresent={}", + endpointHost(userInfoUri), + userRequest.getAccessToken().getTokenValue() != null + && !userRequest.getAccessToken().getTokenValue().isBlank()); + FeishuUserResponse response; + try { + response = restClient.get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + userRequest.getAccessToken().getTokenValue()) + .exchange((request, clientResponse) -> { + log.info("Feishu userinfo response: httpStatus={}", clientResponse.getStatusCode().value()); + return readBounded(clientResponse.getBody()); + }); + } catch (Exception e) { + // Exception class only: the message can quote the request URI, which holds the token. + // Nothing downstream logs this failure, so without this line it would be silent. + log.warn("Feishu user info request failed with {}", e.getClass().getSimpleName()); + // The cause carries the detail for operators; the OAuth2Error description stays generic + // for the same reason the log line is. + throw new OAuth2AuthenticationException( + new OAuth2Error("feishu_userinfo_error", "Failed to load Feishu user info", null), + e + ); + } + + if (response == null || response.code() != 0 || response.data() == null) { + // Feishu's own error code is safe to record; its msg text is not. + log.warn( + "Feishu user info returned error code {}", + response == null ? "none" : response.code() + ); + throw new OAuth2AuthenticationException( + new OAuth2Error( + "feishu_userinfo_error", + "Feishu user info error, code " + (response == null ? "none" : response.code()), + null + ) + ); + } + + log.info("Feishu userinfo parsed: businessCode=0, openIdPresent={}, unionIdPresent={}, emailPresent={}, displayNamePresent={}", + response.data().openId() != null && !response.data().openId().isBlank(), + response.data().unionId() != null && !response.data().unionId().isBlank(), + (response.data().enterpriseEmail() != null && !response.data().enterpriseEmail().isBlank()) + || (response.data().email() != null && !response.data().email().isBlank()), + (response.data().name() != null && !response.data().name().isBlank()) + || (response.data().enName() != null && !response.data().enName().isBlank())); + + String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUserNameAttributeName(); + + Map attributes = flatten(response.data(), userNameAttributeName); + return new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), + attributes, + userNameAttributeName + ); + } + + private Map flatten(FeishuUserData data, String userNameAttributeName) { + Map attributes = new LinkedHashMap<>(); + putIfPresent(attributes, "open_id", data.openId()); + putIfPresent(attributes, "union_id", data.unionId()); + putIfPresent(attributes, "name", data.name()); + putIfPresent(attributes, "en_name", data.enName()); + putIfPresent(attributes, "avatar_url", data.avatarUrl()); + putIfPresent(attributes, "email", data.email()); + putIfPresent(attributes, "enterprise_email", data.enterpriseEmail()); + if (!attributes.containsKey(userNameAttributeName)) { + throw new OAuth2AuthenticationException( + new OAuth2Error("feishu_userinfo_error", "Feishu user info missing " + userNameAttributeName, null) + ); + } + return attributes; + } + + private static String endpointHost(String endpoint) { + try { + return java.net.URI.create(endpoint).getHost(); + } catch (IllegalArgumentException exception) { + return "invalid"; + } + } + + private void putIfPresent(Map attributes, String key, String value) { + if (value != null && !value.isBlank()) { + attributes.put(key, value); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record FeishuUserResponse(int code, String msg, @JsonProperty("data") FeishuUserData data) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + record FeishuUserData( + @JsonProperty("open_id") String openId, + @JsonProperty("union_id") String unionId, + @JsonProperty("name") String name, + @JsonProperty("en_name") String enName, + @JsonProperty("avatar_url") String avatarUrl, + @JsonProperty("email") String email, + @JsonProperty("enterprise_email") String enterpriseEmail + ) {} +} 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 14beac75..a6923ccf 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 @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.oauth; import jakarta.servlet.ServletException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.core.AuthenticationException; @@ -16,6 +18,8 @@ import java.io.IOException; @Component public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { + private static final Logger log = LoggerFactory.getLogger(OAuth2LoginFailureHandler.class); + private final OAuthLoginFlowService oauthLoginFlowService; public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) { @@ -28,6 +32,8 @@ public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHan throws IOException, ServletException { String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false)); String redirectTarget = oauthLoginFlowService.resolveFailureRedirect(exception, returnTo); + log.warn("OAuth login failed: exceptionType={}, returnToPresent={}, redirectPath={}", + exception.getClass().getSimpleName(), returnTo != null, redirectTarget); if (redirectTarget != null) { getRedirectStrategy().sendRedirect(request, response, redirectTarget); return; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java index a75f2457..3f1290d1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java @@ -6,6 +6,8 @@ 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.Authentication; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; @@ -22,6 +24,8 @@ import org.springframework.stereotype.Component; @Component public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private static final Logger log = LoggerFactory.getLogger(OAuth2LoginSuccessHandler.class); + private final PlatformSessionService platformSessionService; private final OAuthLoginFlowService oauthLoginFlowService; @@ -43,6 +47,8 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan } String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false)); if (returnTo != null) { + log.info("OAuth login succeeded: redirectPath={}, returnToPresent=true, sessionAttached=true", + returnTo); // returnTo is a root-relative path (web client strips the base path). The redirect // strategy (DefaultRedirectStrategy) already prepends the request context path, which // reflects X-Forwarded-Prefix under forward-headers-strategy=framework — so the browser @@ -52,6 +58,7 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan clearAuthenticationAttributes(request); return; } + log.info("OAuth login succeeded: redirectPath={}, returnToPresent=false, sessionAttached=true", "/"); super.onAuthenticationSuccess(request, response, authentication); } } 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 9a2c2824..3c987dc2 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 @@ -17,6 +17,8 @@ import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.AuthenticationException; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; @@ -35,7 +37,10 @@ import org.springframework.stereotype.Service; @Service public class OAuthLoginFlowService { + private static final Logger log = LoggerFactory.getLogger(OAuthLoginFlowService.class); + private final Map extractors; + private final Map userServiceOverrides; private final AccessPolicy accessPolicy; private final IdentityBindingService identityBindingService; private final LegacyPlatformIdentityCore identityCore; @@ -44,12 +49,14 @@ public class OAuthLoginFlowService { @Autowired public OAuthLoginFlowService(List extractorList, + List userServiceList, AccessPolicy accessPolicy, IdentityBindingService identityBindingService, LegacyPlatformIdentityCore identityCore, RemoteIdentityIoExecutor remoteIdentityIo) { this( extractorList, + userServiceList, accessPolicy, identityBindingService, identityCore, @@ -59,6 +66,7 @@ public class OAuthLoginFlowService { } OAuthLoginFlowService(List extractorList, + List userServiceList, AccessPolicy accessPolicy, IdentityBindingService identityBindingService, LegacyPlatformIdentityCore identityCore, @@ -66,6 +74,8 @@ public class OAuthLoginFlowService { RemoteIdentityIoExecutor remoteIdentityIo) { this.extractors = extractorList.stream() .collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity())); + this.userServiceOverrides = userServiceList.stream() + .collect(Collectors.toMap(ProviderOAuth2UserService::getProvider, Function.identity())); this.accessPolicy = accessPolicy; this.identityBindingService = identityBindingService; this.identityCore = identityCore; @@ -79,6 +89,7 @@ public class OAuthLoginFlowService { LegacyPlatformIdentityCore identityCore) { this( extractorList, + List.of(), accessPolicy, identityBindingService, identityCore, @@ -95,27 +106,34 @@ public class OAuthLoginFlowService { public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) { LoadedProviderIdentity loadedIdentity = remoteIdentityIo.execute(() -> { - OAuth2User upstreamUser = delegate.loadUser(request); String registrationId = request.getClientRegistration().getRegistrationId(); + ProviderOAuth2UserService override = userServiceOverrides.get(registrationId); + OAuth2User upstreamUser = (override != null ? override : delegate).loadUser(request); OAuthClaimsExtractor extractor = extractors.get(registrationId); if (extractor == null) { throw new OAuth2AuthenticationException( new OAuth2Error("unsupported_provider", "Unsupported: " + registrationId, null) ); } - return new LoadedProviderIdentity( - upstreamUser, - extractor.extract(request, upstreamUser) - ); + OAuthClaims claims = extractor.extract(request, upstreamUser); + log.info("OAuth provider identity loaded: provider={}, subjectPresent={}, emailPresent={}, displayNamePresent={}", + registrationId, + claims.subject() != null && !claims.subject().isBlank(), + claims.email() != null && !claims.email().isBlank(), + claims.providerLogin() != null && !claims.providerLogin().isBlank()); + return new LoadedProviderIdentity(upstreamUser, claims); }); PlatformPrincipal principal = authenticate(loadedIdentity.claims()); + log.info("OAuth identity authenticated: provider={}, principalCreated=true, rolesCount={}", + loadedIdentity.claims().provider(), principal.platformRoles().size()); return new AuthenticatedLoginContext(loadedIdentity.upstreamUser(), principal); } public PlatformPrincipal authenticate(OAuthClaims claims) { AccessDecision decision = accessPolicy.evaluate(claims); + log.info("OAuth access policy evaluated: provider={}, decision={}", claims.provider(), decision); if (decision == AccessDecision.PENDING_APPROVAL) { LegacyPlatformIdentityDecision identityDecision = identityCore.evaluate(claims); ensureActiveCoreAllowsPlatformLogin(identityDecision); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java new file mode 100644 index 00000000..bf587e81 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.auth.oauth; + +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.user.OAuth2User; + +/** + * Strategy interface for provider-specific OAuth user loading. Implementations override the + * default user info loading for providers whose endpoints deviate from the standard + * flat-attribute response format. + */ +public interface ProviderOAuth2UserService extends OAuth2UserService { + String getProvider(); +} 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 5cd28902..df985f8f 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,6 +1,8 @@ package com.iflytek.skillhub.auth.oauth; import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; @@ -14,6 +16,8 @@ import org.springframework.stereotype.Component; public class SkillHubOAuth2AuthorizationRequestResolver implements org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver { + private static final Logger log = LoggerFactory.getLogger(SkillHubOAuth2AuthorizationRequestResolver.class); + private final DefaultOAuth2AuthorizationRequestResolver delegate; private final OAuthLoginFlowService oauthLoginFlowService; @@ -47,6 +51,10 @@ public class SkillHubOAuth2AuthorizationRequestResolver HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) { if (authorizationRequest != null) { oauthLoginFlowService.rememberReturnTo(request); + log.info("OAuth authorization started: provider={}, redirectUri={}, returnToPresent={}", + authorizationRequest.getAttribute("registration_id"), + authorizationRequest.getRedirectUri(), + request.getParameter("returnTo") != null); } return authorizationRequest; } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java new file mode 100644 index 00000000..6cc2239b --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java @@ -0,0 +1,143 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; + +class FeishuClaimsExtractorTest { + + private final FeishuClaimsExtractor extractor = new FeishuClaimsExtractor(); + + @Test + void extract_prefersEnterpriseEmailOverPersonalEmail() { + Map attrs = new HashMap<>(Map.of( + "open_id", "ou_123", + "name", "张三", + "email", "zhangsan@personal.example", + "enterprise_email", "zhangsan@corp.example" + )); + + OAuthClaims claims = extractor.extract(userRequest(), user(attrs)); + + assertThat(claims.provider()).isEqualTo("feishu"); + assertThat(claims.subject()).isEqualTo("ou_123"); + assertThat(claims.email()).isEqualTo("zhangsan@corp.example"); + // Feishu emails are admin-imported; the extractor must not claim verification. + assertThat(claims.emailVerified()).isFalse(); + assertThat(claims.providerLogin()).isEqualTo("张三"); + } + + @Test + void extract_allowsNullEmailAndLeavesDisplayNameUnsetWhenFeishuSendsNoName() { + Map attrs = new HashMap<>(Map.of("open_id", "ou_456")); + + OAuthClaims claims = extractor.extract(userRequest(), user(attrs)); + + assertThat(claims.subject()).isEqualTo("ou_456"); + assertThat(claims.email()).isNull(); + assertThat(claims.emailVerified()).isFalse(); + // Must not synthesize "feishu-": providerLogin is written to displayName and into + // UserActivatedEvent, so a synthesized value would carry the subject into event consumers. + assertThat(claims.providerLogin()).isNull(); + } + + @Test + void extract_fallsBackToEnglishNameWhenChineseNameBlank() { + Map attrs = new HashMap<>(Map.of( + "open_id", "ou_789", + "en_name", "Alice" + )); + + OAuthClaims claims = extractor.extract(userRequest(), user(attrs)); + + assertThat(claims.providerLogin()).isEqualTo("Alice"); + } + + @Test + void extract_rejectsBlankOpenId() { + // Blank must fail rather than become a subject. DefaultOAuth2User already rejects a + // wholly absent open_id, so a permissive OAuth2User is used to test this contract + // directly instead of relying on that upstream guard. + Map attrs = new HashMap<>(); + attrs.put("open_id", " "); + attrs.put("name", "张三"); + + assertThatThrownBy(() -> extractor.extract(userRequest(), permissiveUser(attrs))) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("open_id"); + } + + /** An {@link OAuth2User} that does not enforce the name attribute, unlike DefaultOAuth2User. */ + private OAuth2User permissiveUser(Map attrs) { + return new OAuth2User() { + @Override + public Map getAttributes() { + return attrs; + } + + @Override + public java.util.Collection + getAuthorities() { + return java.util.List.of(); + } + + @Override + public String getName() { + return String.valueOf(attrs.get("open_id")); + } + }; + } + + @Test + void extract_doesNotPromoteUnionIdToSubject() { + // union_id stays in extra: a subject that can change between logins would split one + // person across two platform accounts. + Map attrs = new HashMap<>(Map.of( + "open_id", "ou_abc", + "union_id", "on_xyz" + )); + + OAuthClaims claims = extractor.extract(userRequest(), user(attrs)); + + assertThat(claims.subject()).isEqualTo("ou_abc"); + assertThat(claims.extra()).containsEntry("union_id", "on_xyz"); + } + + private DefaultOAuth2User user(Map attrs) { + return new DefaultOAuth2User(java.util.List.of(), attrs, "open_id"); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("feishu") + .clientId("cli_test123") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize") + .tokenUri("https://accounts.feishu.cn/oauth/v3/token") + .userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info") + .userNameAttributeName("open_id") + .clientName("飞书") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "token-123", + 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/FeishuOAuth2AccessTokenResponseClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClientTest.java new file mode 100644 index 00000000..1f022424 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2AccessTokenResponseClientTest.java @@ -0,0 +1,231 @@ +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.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +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.RestClient; + +class FeishuOAuth2AccessTokenResponseClientTest { + + @Test + void getTokenResponse_postsFeishuJsonRequestAndParsesTokenResponse() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header(HttpHeaders.CONTENT_TYPE, "application/json;charset=utf-8")) + .andExpect(content().json(""" + { + "grant_type": "authorization_code", + "client_id": "cli_test", + "client_secret": "secret_test", + "code": "auth-code", + "redirect_uri": "https://skillhub.example.com/login/oauth2/code/feishu" + } + """, false)) + .andRespond(withSuccess(""" + { + "code": 0, + "access_token": "access-token", + "token_type": "Bearer", + "expires_in": 7200, + "refresh_token": "refresh-token", + "scope": "contact:user.base:readonly offline_access" + } + """, MediaType.APPLICATION_JSON)); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder); + + var response = client.getTokenResponse(grantRequest(false)); + + assertThat(response.getAccessToken().getTokenValue()).isEqualTo("access-token"); + assertThat(response.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER); + assertThat(response.getAccessToken().getScopes()) + .containsExactlyInAnyOrder("contact:user.base:readonly", "offline_access"); + assertThat(response.getRefreshToken()).isNotNull(); + assertThat(response.getRefreshToken().getTokenValue()).isEqualTo("refresh-token"); + assertThat(response.getAccessToken().getExpiresAt()).isAfter(Instant.now()); + server.verify(); + } + + @Test + void getTokenResponse_usesV2EndpointWhenConfigured() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v2/oauth/token")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header(HttpHeaders.CONTENT_TYPE, "application/json;charset=utf-8")) + .andRespond(withSuccess("{\"code\":0,\"access_token\":\"v2-access-token\"," + + "\"token_type\":\"Bearer\",\"expires_in\":3600}", + MediaType.APPLICATION_JSON)); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient( + builder, request -> OAuth2AccessTokenResponse.withToken("unused").build(), "v2"); + + assertThat(client.getTokenResponse(grantRequest(false)).getAccessToken().getTokenValue()) + .isEqualTo("v2-access-token"); + server.verify(); + } + + @Test + void constructorRejectsUnsupportedProtocolVersion() { + assertThatThrownBy(() -> new FeishuOAuth2AccessTokenResponseClient( + RestClient.builder(), request -> OAuth2AccessTokenResponse.withToken("unused").build(), "v1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("v2 or v3"); + } + + @Test + void getTokenResponse_forwardsCodeVerifierWhenAuthorizationRequestContainsIt() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token")) + .andExpect(content().json(""" + { + "grant_type": "authorization_code", + "client_id": "cli_test", + "client_secret": "secret_test", + "code": "auth-code", + "redirect_uri": "https://skillhub.example.com/login/oauth2/code/feishu", + "code_verifier": "verifier-value" + } + """, false)) + .andRespond(withSuccess("{\"code\":0,\"access_token\":\"access-token\"," + + "\"token_type\":\"Bearer\",\"expires_in\":3600}", + MediaType.APPLICATION_JSON)); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder); + + client.getTokenResponse(grantRequest(true)); + + server.verify(); + } + + @Test + void getTokenResponse_rejectsFeishuBusinessErrorReturnedAsHttp200() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token")) + .andRespond(withSuccess(""" + {"code": 20003, "error": "invalid_grant", "error_description": "secret_test rejected auth-code"} + """, MediaType.APPLICATION_JSON)); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder); + + assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false))) + .isInstanceOf(OAuth2AuthorizationException.class) + .satisfies(error -> { + var oauthError = ((OAuth2AuthorizationException) error).getError(); + assertThat(oauthError.getErrorCode()).isEqualTo("feishu_invalid_token_response"); + assertThat(oauthError.getDescription()).doesNotContain("secret_test", "auth-code", "rejected"); + }); + server.verify(); + } + + @Test + void getTokenResponse_rejectsInvalidSuccessfulResponse() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token")) + .andRespond(withSuccess("{\"code\":0,\"access_token\":\"access-token\"," + + "\"token_type\":\"mac\",\"expires_in\":3600}", MediaType.APPLICATION_JSON)); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder); + + assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false))) + .isInstanceOf(OAuth2AuthorizationException.class) + .satisfies(error -> assertThat(((OAuth2AuthorizationException) error).getError().getDescription()) + .contains("unsupported token type")); + server.verify(); + } + + @Test + void getTokenResponse_rejectsHttpErrorWithoutExposingResponseDetails() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("https://accounts.feishu.cn/oauth/v3/token")) + .andRespond(org.springframework.test.web.client.response.MockRestResponseCreators + .withStatus(org.springframework.http.HttpStatus.BAD_REQUEST) + .body("client_secret=secret_test")); + + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient(builder); + + assertThatThrownBy(() -> client.getTokenResponse(grantRequest(false))) + .isInstanceOf(OAuth2AuthorizationException.class) + .satisfies(error -> assertThat(((OAuth2AuthorizationException) error).getError().getDescription()) + .doesNotContain("secret_test", "auth-code")); + server.verify(); + } + + @Test + void getTokenResponse_delegatesNonFeishuRegistrationToStandardClient() { + OAuth2AccessTokenResponseClient delegate = request -> + org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse.withToken("github-token") + .tokenType(OAuth2AccessToken.TokenType.BEARER) + .build(); + FeishuOAuth2AccessTokenResponseClient client = new FeishuOAuth2AccessTokenResponseClient( + RestClient.builder(), delegate); + + var response = client.getTokenResponse(grantRequest("github", false)); + + assertThat(response.getAccessToken().getTokenValue()).isEqualTo("github-token"); + } + + private OAuth2AuthorizationCodeGrantRequest grantRequest(boolean withCodeVerifier) { + return grantRequest("feishu", withCodeVerifier); + } + + private OAuth2AuthorizationCodeGrantRequest grantRequest(String registrationId, boolean withCodeVerifier) { + ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId) + .clientId("cli_test") + .clientSecret("secret_test") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize") + .tokenUri("https://accounts.feishu.cn/oauth/v3/token") + .userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info") + .userNameAttributeName("open_id") + .clientName("飞书") + .build(); + OAuth2AuthorizationRequest.Builder request = OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(registration.getProviderDetails().getAuthorizationUri()) + .clientId(registration.getClientId()) + .redirectUri("https://skillhub.example.com/login/oauth2/code/feishu") + .state("state") + .attributes(attributes -> { + if (withCodeVerifier) { + attributes.put("code_verifier", "verifier-value"); + } + }); + OAuth2AuthorizationResponse response = OAuth2AuthorizationResponse.success("auth-code") + .redirectUri("https://skillhub.example.com/login/oauth2/code/feishu") + .state("state") + .build(); + return new OAuth2AuthorizationCodeGrantRequest( + registration, + new OAuth2AuthorizationExchange(request.build(), response)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java new file mode 100644 index 00000000..128d173d --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java @@ -0,0 +1,190 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +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.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +class FeishuOAuth2UserServiceTest { + + @Test + void loadUser_unwrapsFeishuEnvelopeIntoFlatAttributes() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info")) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123")) + .andRespond(withSuccess( + """ + { + "code": 0, + "msg": "success", + "data": { + "open_id": "ou_123", + "union_id": "on_456", + "name": "张三", + "avatar_url": "https://avatar.example/zhangsan.png", + "enterprise_email": "zhangsan@corp.example", + "email": "zhangsan@personal.example" + } + } + """, + MediaType.APPLICATION_JSON + )); + FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder); + + OAuth2User user = service.loadUser(userRequest()); + + assertThat(user.getName()).isEqualTo("ou_123"); + assertThat(user.getAttributes()) + .containsEntry("open_id", "ou_123") + .containsEntry("union_id", "on_456") + .containsEntry("name", "张三") + .containsEntry("avatar_url", "https://avatar.example/zhangsan.png") + .containsEntry("enterprise_email", "zhangsan@corp.example") + .doesNotContainKey("code") + .doesNotContainKey("data"); + server.verify(); + } + + @Test + void loadUser_throwsWhenFeishuReportsErrorCode() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info")) + .andRespond(withSuccess( + """ + {"code": 99991663, "msg": "invalid access token"} + """, + MediaType.APPLICATION_JSON + )); + FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder); + + assertThatThrownBy(() -> service.loadUser(userRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()) + .isEqualTo("feishu_userinfo_error")); + server.verify(); + } + + @Test + void loadUser_rejectsOversizedResponseBody() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + // 64 KB cap; pad a structurally valid envelope past it so the size check fires, not the parser. + String padding = "x".repeat(70 * 1024); + server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info")) + .andRespond(withSuccess( + "{\"code\":0,\"msg\":\"" + padding + "\",\"data\":{\"open_id\":\"ou_123\"}}", + MediaType.APPLICATION_JSON + )); + FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder); + + assertThatThrownBy(() -> service.loadUser(userRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()) + .isEqualTo("feishu_userinfo_error")); + server.verify(); + } + + @Test + void loadUser_logsErrorCodeButNeverUpstreamTextOrToken() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info")) + .andRespond(withSuccess( + """ + {"code": 99991663, "msg": "token token-123 rejected for cli_test123"} + """, + MediaType.APPLICATION_JSON + )); + FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder); + + ListAppender appender = new ListAppender<>(); + Logger logger = (Logger) LoggerFactory.getLogger(FeishuOAuth2UserService.class); + appender.start(); + logger.addAppender(appender); + try { + assertThatThrownBy(() -> service.loadUser(userRequest())) + .isInstanceOf(OAuth2AuthenticationException.class); + } finally { + logger.detachAppender(appender); + appender.stop(); + } + + String logged = appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .collect(java.util.stream.Collectors.joining("\n")); + // A failure must leave an operator-facing record... + assertThat(logged).contains("99991663"); + // ...but the upstream msg can quote the access token, so it must never be logged. + assertThat(logged).doesNotContain("token-123"); + assertThat(logged).doesNotContain("rejected"); + server.verify(); + } + + @Test + void loadUser_errorDescriptionDoesNotEchoUpstreamTextOrToken() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info")) + .andRespond(withSuccess( + """ + {"code": 99991663, "msg": "token token-123 rejected for cli_test123"} + """, + MediaType.APPLICATION_JSON + )); + FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder); + + assertThatThrownBy(() -> service.loadUser(userRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> { + String description = ((OAuth2AuthenticationException) ex).getError().getDescription(); + // The upstream message can quote the access token; only the code may surface. + assertThat(description).doesNotContain("token-123"); + assertThat(description).doesNotContain("rejected"); + assertThat(description).contains("99991663"); + }); + server.verify(); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("feishu") + .clientId("cli_test123") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize") + .tokenUri("https://accounts.feishu.cn/oauth/v3/token") + .userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info") + .userNameAttributeName("open_id") + .clientName("飞书") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "token-123", + 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/OAuthLoginFlowServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java index 29a280f1..7ece104d 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 @@ -74,6 +74,7 @@ class OAuthLoginFlowServiceTest { }; OAuthLoginFlowService service = new OAuthLoginFlowService( List.of(extractor), + List.of(), accessPolicy, identityBindingService, identityCore, @@ -102,6 +103,148 @@ class OAuthLoginFlowServiceTest { verify(delegate).loadUser(request); } + @Test + void loadLoginContext_prefersProviderUserServiceOverrideInsideRemoteIoBoundary() { + OAuthClaims claims = claims("feishu", "ou_1"); + OAuthClaimsExtractor extractor = new OAuthClaimsExtractor() { + @Override + public String getProvider() { + return "feishu"; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User user) { + return claims; + } + }; + OAuth2User overrideUser = new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("OAUTH_USER")), + Map.of("open_id", "ou_1"), + "open_id" + ); + AtomicInteger boundaryCalls = new AtomicInteger(); + AtomicInteger overrideCallsInsideBoundary = new AtomicInteger(); + RemoteIdentityIoExecutor remoteIdentityIo = new RemoteIdentityIoExecutor() { + @Override + public T execute(java.util.function.Supplier operation) { + boundaryCalls.incrementAndGet(); + return operation.get(); + } + }; + ProviderOAuth2UserService override = new ProviderOAuth2UserService() { + @Override + public String getProvider() { + return "feishu"; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest request) { + // Records the boundary state at call time: a provider override must run inside the + // remote-IO boundary, otherwise its HTTP call would hold the surrounding transaction. + if (boundaryCalls.get() == 1) { + overrideCallsInsideBoundary.incrementAndGet(); + } + return overrideUser; + } + }; + AccessPolicy accessPolicy = mock(AccessPolicy.class); + IdentityBindingService identityBindingService = mock(IdentityBindingService.class); + LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class); + OAuth2UserService delegate = mock(); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_2", "zhangsan", null, null, "feishu", Set.of("USER") + ); + OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(extractor), + List.of(override), + accessPolicy, + identityBindingService, + identityCore, + delegate, + remoteIdentityIo + ); + OAuth2UserRequest request = oauthUserRequest("feishu"); + when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW); + when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.legacy()); + when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal); + + OAuthLoginFlowService.AuthenticatedLoginContext result = service.loadLoginContext(request); + + assertThat(result.upstreamUser()).isSameAs(overrideUser); + assertThat(result.principal()).isSameAs(principal); + assertThat(boundaryCalls).hasValue(1); + assertThat(overrideCallsInsideBoundary).hasValue(1); + // The default user service must not be consulted when an override claims the registration. + verify(delegate, never()).loadUser(request); + } + + @Test + void loadLoginContext_fallsBackToDefaultUserServiceForUnclaimedProviders() { + OAuthClaims claims = claims(); + OAuthClaimsExtractor extractor = new OAuthClaimsExtractor() { + @Override + public String getProvider() { + return "github"; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User user) { + return claims; + } + }; + ProviderOAuth2UserService unrelatedOverride = new ProviderOAuth2UserService() { + @Override + public String getProvider() { + return "feishu"; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest request) { + throw new AssertionError("Feishu override must not handle a GitHub login"); + } + }; + AccessPolicy accessPolicy = mock(AccessPolicy.class); + IdentityBindingService identityBindingService = mock(IdentityBindingService.class); + LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class); + OAuth2UserService delegate = mock(); + OAuth2User upstreamUser = new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("OAUTH_USER")), + Map.of("id", "gh_1"), + "id" + ); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", "alice", "alice@example.com", null, "github", Set.of("USER") + ); + OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(extractor), + List.of(unrelatedOverride), + accessPolicy, + identityBindingService, + identityCore, + delegate, + directRemoteIo() + ); + OAuth2UserRequest request = oauthUserRequest(); + when(delegate.loadUser(request)).thenReturn(upstreamUser); + when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW); + when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.legacy()); + when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal); + + OAuthLoginFlowService.AuthenticatedLoginContext result = service.loadLoginContext(request); + + assertThat(result.upstreamUser()).isSameAs(upstreamUser); + verify(delegate).loadUser(request); + } + + private static RemoteIdentityIoExecutor directRemoteIo() { + return new RemoteIdentityIoExecutor() { + @Override + public T execute(java.util.function.Supplier operation) { + return operation.get(); + } + }; + } + @ParameterizedTest @EnumSource(IdentityCoreMode.class) void authenticate_preservesPrincipalAcrossLegacyShadowAndActiveModes(IdentityCoreMode mode) { @@ -320,12 +463,20 @@ class OAuthLoginFlowServiceTest { ); } + private static OAuthClaims claims(String provider, String subject) { + return new OAuthClaims(provider, subject, null, false, subject, Map.of()); + } + private static OAuth2UserRequest oauthUserRequest() { - ClientRegistration registration = ClientRegistration.withRegistrationId("github") + return oauthUserRequest("github"); + } + + private static OAuth2UserRequest oauthUserRequest(String registrationId) { + ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId) .clientId("client") .clientSecret("secret") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .redirectUri("https://skillhub.example/login/oauth2/code/github") + .redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId) .authorizationUri("https://github.example/oauth/authorize") .tokenUri("https://github.example/oauth/token") .userInfoUri("https://github.example/user") diff --git a/web/public/feishu-logo.svg b/web/public/feishu-logo.svg new file mode 100644 index 00000000..f929a53d --- /dev/null +++ b/web/public/feishu-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts index 4b852cd7..f7748420 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -95,6 +95,12 @@ export default defineConfig({ target: 'http://localhost:8080', changeOrigin: true, }, + '/login/oauth2': { + target: 'http://localhost:8080', + // Preserve the browser-facing localhost:3000 host so Spring's + // post-login redirect does not send the SPA to localhost:8080. + changeOrigin: false, + }, }, }, })