feat(deploy): wire DingTalk credentials into the release surfaces

Adds the DingTalk credentials to every path that actually delivers
configuration: compose.release.yml (which has no env_file, so variables must
be listed explicitly), the Helm secret template and values, the k8s
deployment and its secret example. validate-release-config.sh gains DingTalk
in its provider loop, so a half-configured pair is rejected the same way.

Documents the three-stage strategy contract in the authentication design: a
table mapping each deviation -- authorize parameters, token exchange,
userinfo loading -- to its interface and current implementations, plus the
rule that a provider must never make account decisions itself.

Deployment notes and both FAQs now cover DingTalk, including the shared trap
with Feishu: their emails are admin-recorded and never confirmed, so
emailVerified is always false and an EMAIL_DOMAIN access policy would reject
every login through either provider.

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-18 16:57:11 +08:00
parent 96f244b416
commit 75c7f9a880
13 changed files with 90 additions and 11 deletions

View file

@ -138,6 +138,16 @@ OAUTH2_FEISHU_USER_INFO_URI=https://open.feishu.cn/open-apis/authen/v1/user_info
OAUTH2_FEISHU_REDIRECT_URI=
OAUTH2_FEISHU_DISPLAY_NAME=飞书
# Optional: DingTalk login as a public sign-in provider. Leaving the client id empty keeps the
# button off the login page. Use the app's AppKey as the client id and AppSecret as the secret.
# Like Feishu, DingTalk returns an organization-recorded email without attesting ownership, so
# emailVerified is always false and the EMAIL_DOMAIN access policy would reject every login.
OAUTH2_DINGTALK_CLIENT_ID=
OAUTH2_DINGTALK_CLIENT_SECRET=
OAUTH2_DINGTALK_AUTHORIZE_URI=https://login.dingtalk.com
OAUTH2_DINGTALK_BASE_URI=https://api.dingtalk.com
OAUTH2_DINGTALK_DISPLAY_NAME=钉钉
# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD).
# Replace "OIDC" in variable names with your registration id (uppercase).
# The registration id becomes identity_binding.provider_code — keep it stable.

View file

@ -67,6 +67,14 @@ stringData:
oauth2-feishu-client-secret: {{ .Values.secrets.oauth2FeishuClientSecret | quote }}
{{- end }}
# OAuth2 DingTalk (optional)
{{- if .Values.secrets.oauth2DingtalkClientId }}
oauth2-dingtalk-client-id: {{ .Values.secrets.oauth2DingtalkClientId | quote }}
{{- end }}
{{- if .Values.secrets.oauth2DingtalkClientSecret }}
oauth2-dingtalk-client-secret: {{ .Values.secrets.oauth2DingtalkClientSecret | quote }}
{{- end }}
# Scanner LLM 配置 (optional)
{{- if .Values.secrets.scannerLlmApiKey }}
skill-scanner-llm-api-key: {{ .Values.secrets.scannerLlmApiKey | quote }}

View file

@ -381,6 +381,20 @@ spec:
value: {{ . | quote }}
{{- end }}
# OAuth2 DingTalk (optional)
- name: OAUTH2_DINGTALK_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: oauth2-dingtalk-client-id
optional: true
- name: OAUTH2_DINGTALK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "skillhub.secretName" . }}
key: oauth2-dingtalk-client-secret
optional: true
{{- if .Values.server.javaOpts }}
- name: JAVA_OPTS
value: {{ .Values.server.javaOpts }}

View file

@ -103,6 +103,8 @@ secrets:
oauth2GithubClientSecret: ""
oauth2FeishuClientId: ""
oauth2FeishuClientSecret: ""
oauth2DingtalkClientId: ""
oauth2DingtalkClientSecret: ""
scannerLlmApiKey: ""
scannerLlmBaseUrl: ""
scannerLlmModel: ""

View file

@ -128,6 +128,11 @@ services:
OAUTH2_FEISHU_USER_INFO_URI: ${OAUTH2_FEISHU_USER_INFO_URI:-${OAUTH2_FEISHU_BASE_URI:-https://open.feishu.cn}/open-apis/authen/v1/user_info}
OAUTH2_FEISHU_REDIRECT_URI: ${OAUTH2_FEISHU_REDIRECT_URI:-${SKILLHUB_PUBLIC_BASE_URL:-http://localhost}/login/oauth2/code/feishu}
OAUTH2_FEISHU_DISPLAY_NAME: ${OAUTH2_FEISHU_DISPLAY_NAME:-飞书}
OAUTH2_DINGTALK_CLIENT_ID: ${OAUTH2_DINGTALK_CLIENT_ID:-local-placeholder}
OAUTH2_DINGTALK_CLIENT_SECRET: ${OAUTH2_DINGTALK_CLIENT_SECRET:-local-placeholder}
OAUTH2_DINGTALK_AUTHORIZE_URI: ${OAUTH2_DINGTALK_AUTHORIZE_URI:-https://login.dingtalk.com}
OAUTH2_DINGTALK_BASE_URI: ${OAUTH2_DINGTALK_BASE_URI:-https://api.dingtalk.com}
OAUTH2_DINGTALK_DISPLAY_NAME: ${OAUTH2_DINGTALK_DISPLAY_NAME:-钉钉}
SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-}
SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25}
SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-}

View file

@ -248,6 +248,20 @@ spec:
value: "https://accounts.feishu.cn/oauth/v3/token"
- name: OAUTH2_FEISHU_USER_INFO_URI
value: "https://open.feishu.cn/open-apis/authen/v1/user_info"
# OAuth2 DingTalk (optional)
- name: OAUTH2_DINGTALK_CLIENT_ID
valueFrom:
secretKeyRef:
name: skillhub-secret
key: oauth2-dingtalk-client-id
optional: true
- name: OAUTH2_DINGTALK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: skillhub-secret
key: oauth2-dingtalk-client-secret
optional: true
volumeMounts:
- name: skillhub-storage
mountPath: /var/lib/skillhub/storage

View file

@ -31,6 +31,10 @@ stringData:
oauth2-feishu-client-id: ""
oauth2-feishu-client-secret: ""
# 钉钉 OAuth可选用于钉钉登录留空则登录页不展示该入口
oauth2-dingtalk-client-id: ""
oauth2-dingtalk-client-secret: ""
# LLM 配置(可选,用于技能扫描)
skill-scanner-llm-api-key: ""
skill-scanner-llm-base-url: ""

View file

@ -282,6 +282,14 @@ spring:
# 飞书的 scope 配在开放平台应用上,不在这里传
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
dingtalk:
client-id: ${OAUTH2_DINGTALK_CLIENT_ID}
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET}
# 故意不声明 scope钉钉的授权端点要 scope=openid但在这里声明会让
# Spring 把该注册当成 OIDC 客户端并附加 nonce而钉钉不接受 nonce。
# scope 由 DingTalkAuthorizationRequestCustomizer 在请求阶段补上。
client-authentication-method: none
authorization-grant-type: authorization_code
provider:
feishu:
# Full endpoints are configurable for Lark, private deployments, and gateways.
@ -300,12 +308,24 @@ Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider
第 2 步是按 Provider 注册一个 Bean而不是在某个类里按 `registrationId` 分支。
账号匹配、建号、资料权威和账号守卫都在 `OAuthClaims` 之后共享Provider 自己不做这些决策。
如果该 Provider 的 userinfo 响应不是标准的扁平结构(例如飞书用
`{code, msg, data}` 信封,且以 HTTP 200 返回错误),再额外实现一个
`ProviderOAuth2UserService`:它声明自己负责哪个 `registrationId`
接管 userinfo 的加载步骤,其余流程不变。该覆盖运行在
如果该 Provider 的协议有偏离标准之处,按偏离的环节实现对应的策略接口,
每个接口都声明自己负责哪个 `registrationId`,由框架分发,不需要在共享类里写分支:
| 偏离环节 | 策略接口 | 现有实现 |
|---|---|---|
| 授权请求参数 | `ProviderAuthorizationRequestCustomizer` | 钉钉补 `openid` scope |
| token 交换 | `ProviderTokenResponseClient` | 钉钉用 JSON body 而非表单 |
| userinfo 加载 | `ProviderOAuth2UserService` | 飞书拆信封;钉钉用自定义 token header |
以 userinfo 为例:飞书用 `{code, msg, data}` 信封且以 HTTP 200 返回错误,
钉钉则把 token 放在 `x-acs-dingtalk-access-token` 而不是 `Authorization: Bearer`
两者都只接管加载步骤,其余流程不变。该覆盖运行在
`RemoteIdentityIoExecutor` 边界内,因此 Provider 的 HTTP 调用不会持有数据库事务。
Provider 的实现**不得**自己做账号决策 —— 不建号、不绑定、不建 session。
这些一律交给统一身份核心,否则每个 Provider 都会长出一套账号逻辑,
正是统一身份认证要消除的问题。
Provider 侧还需遵守subject 必须稳定(不要用可能在两次登录间变化的字段做
fallback否则同一个人会被拆成两个平台账号、只有在 Provider 真正证明了邮箱
所有权时才置 `emailVerified=true`、远程调用要有超时与响应大小上限、

View file

@ -314,11 +314,13 @@ services:
兼容回退。`OAUTH2_FEISHU_TOKEN_URI` 必须指向支持 JSON authorization-code
exchange 的 endpoint。`OAUTH2_FEISHU_PROTOCOL_VERSION` 只允许 `v2``v3`
默认 `v3`,不会自动 fallback。
- 钉钉:`OAUTH2_DINGTALK_CLIENT_ID` / `OAUTH2_DINGTALK_CLIENT_SECRET`
(分别填应用的 AppKey 与 AppSecret
留空即不展示该入口,无需改配置文件。注意:飞书邮箱由企业管理员导入、未经用户
确认,因此 `emailVerified` 恒为 false若在 `application.yml` 中把
留空即不展示该入口,无需改配置文件。注意:飞书和钉钉的邮箱由企业管理员导入、
未经用户确认,因此 `emailVerified` 恒为 false若在 `application.yml` 中把
`skillhub.access-policy.mode` 设为 `EMAIL_DOMAIN`,该策略会拒绝所有未验证邮箱,
飞书登录将一律失败。启用飞书时请保留默认的 `OPEN` 或改用其他准入模式。
这两个入口的登录将一律失败。启用它们时请保留默认的 `OPEN` 或改用其他准入模式。
启用飞书前,使用一个测试租户完成一次真实回调验收。不要把真实 client secret
写入仓库、报告或聊天记录;只在受控的 `.env.release`、CI Secret 或 Kubernetes

View file

@ -197,7 +197,7 @@ A: Login entries are config-driven: `/api/v1/auth/methods` only returns registra
So there are two ways to hide one:
- Leave the matching environment variable unset (for example, omit `OAUTH2_FEISHU_CLIENT_ID`). No config file change needed.
- Or edit `application.yml` and comment out or delete the relevant registration block (`github`, `gitlab`, `feishu`) under `spring.security.oauth2.client.registration`, along with its `provider` section. Spring Boot then won't create that registration at startup.
- Or edit `application.yml` and comment out or delete the relevant registration block (`github`, `gitlab`, `feishu`, `dingtalk`) under `spring.security.oauth2.client.registration`, along with its `provider` section. Spring Boot then won't create that registration at startup.
## Q: Is SkillHub's security scanning (Skill Scanner) developed in-house by iFLYTEK? What license does it use?

View file

@ -199,7 +199,7 @@ A: 登录入口是配置驱动的:`/api/v1/auth/methods` 只返回配置了真
- 留空对应的环境变量即可(例如不设置 `OAUTH2_FEISHU_CLIENT_ID`),无需改动配置文件。
- 或修改 `application.yml`,注释/删除 `spring.security.oauth2.client.registration`
下对应的注册块(`github``gitlab``feishu`)以及对应的 `provider` 段,
下对应的注册块(`github``gitlab``feishu``dingtalk`)以及对应的 `provider` 段,
Spring Boot 启动时便不会创建该注册。
## Q: SkillHub 的安全扫描Skill Scanner是讯飞自研的吗使用什么协议

View file

@ -292,7 +292,7 @@ expect_fail "$invalid_redis_sentinel_check_env" "SKILLHUB_REDIS_SENTINEL_CHECK_S
# An OAuth client id without its secret (or vice versa) leaves the provider half-configured:
# the login button renders but the exchange fails. Checked for every supported provider.
for provider in GITHUB GITLAB FEISHU; do
for provider in GITHUB GITLAB FEISHU DINGTALK; do
missing_oauth_secret_env="$tmp/missing-oauth-secret.env"
write_env "$missing_oauth_secret_env" "release-download-secret-32-bytes-minimum"
printf 'OAUTH2_%s_CLIENT_ID=real-client-id\n' "$provider" >>"$missing_oauth_secret_env"

View file

@ -380,7 +380,7 @@ if [ "${REDIS_BIND_ADDRESS:-127.0.0.1}" != "127.0.0.1" ]; then
warn "REDIS_BIND_ADDRESS is not 127.0.0.1; confirm Redis exposure is intended"
fi
for provider in GITHUB GITLAB FEISHU; do
for provider in GITHUB GITLAB FEISHU DINGTALK; do
eval "oauth_id=\"\${OAUTH2_${provider}_CLIENT_ID:-}\""
eval "oauth_secret=\"\${OAUTH2_${provider}_CLIENT_SECRET:-}\""
if [ -n "$oauth_id" ] && [ -z "$oauth_secret" ]; then