From 7e50443bba19493b84949cd0ba312e25fb585f6f Mon Sep 17 00:00:00 2001 From: opsx Date: Thu, 22 Oct 2026 19:09:57 +0800 Subject: [PATCH] Guide Feishu app setup and surface tenant lookup codes --- backend/internal/identityprovider/admin.go | 22 +++++++++++--- .../internal/identityprovider/admin_test.go | 29 +++++++++++++++++++ docs/specs/SDD-007-built-in-feishu-issuer.md | 6 ++++ frontend/src/locales/en-US.json | 1 + frontend/src/locales/zh-CN.json | 1 + frontend/src/views/Settings.vue | 22 +++++++++++++- 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/backend/internal/identityprovider/admin.go b/backend/internal/identityprovider/admin.go index 077491c..84b9e2a 100644 --- a/backend/internal/identityprovider/admin.go +++ b/backend/internal/identityprovider/admin.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net" "net/http" @@ -19,6 +20,14 @@ import ( const feishuTenantToken = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" const feishuTenantQuery = "https://open.feishu.cn/open-apis/tenant/v2/tenant/query" +// Do not include upstream msg: it is untrusted and may contain request data. +func feishuFailure(stage string, status int, code *int) error { + if code != nil { + return fmt.Errorf("%s失败(飞书错误码 %d,HTTP %d);请按错误码检查应用权限、发布状态及接口配置", stage, *code, status) + } + return fmt.Errorf("%s失败(HTTP %d,未获得飞书错误码);请检查飞书接口配置", stage, status) +} + func secureAdmin(c *gin.Context) bool { if c.Request.TLS != nil { return true @@ -68,8 +77,12 @@ func queryFeishuTenant(ctx context.Context, creds tenantCredentials) (string, st Code *int `json:"code"` TenantAccessToken string `json:"tenant_access_token"` } - if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) != nil || token.Code == nil || *token.Code != 0 || token.TenantAccessToken == "" { - return "", "", errors.New("获取飞书访问凭据失败,请检查 App ID 和 App Secret") + decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) + if resp.StatusCode != http.StatusOK || decodeErr != nil || token.Code == nil || *token.Code != 0 { + return "", "", feishuFailure("获取飞书访问凭据", resp.StatusCode, token.Code) + } + if token.TenantAccessToken == "" { + return "", "", errors.New("飞书未返回 tenant_access_token") } req, err = http.NewRequestWithContext(ctx, http.MethodGet, feishuTenantQuery, nil) if err != nil { @@ -90,8 +103,9 @@ func queryFeishuTenant(ctx context.Context, creds tenantCredentials) (string, st } `json:"tenant"` } `json:"data"` } - if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || result.Code == nil || *result.Code != 0 { - return "", "", errors.New("查询飞书企业信息失败,请确认应用已开通企业信息读取权限") + decodeErr = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) + if resp.StatusCode != http.StatusOK || decodeErr != nil || result.Code == nil || *result.Code != 0 { + return "", "", feishuFailure("查询飞书企业信息", resp.StatusCode, result.Code) } if !validTenant(result.Data.Tenant.TenantKey) { return "", "", errors.New("飞书未返回有效的企业标识") diff --git a/backend/internal/identityprovider/admin_test.go b/backend/internal/identityprovider/admin_test.go index fef2466..edb3e5c 100644 --- a/backend/internal/identityprovider/admin_test.go +++ b/backend/internal/identityprovider/admin_test.go @@ -2,6 +2,7 @@ package identityprovider import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -123,6 +124,34 @@ func TestQueryTenantRejectsWrongAppAndUpstreamFailure(t *testing.T) { } } +func TestTenantErrorsIdentifyStageWithoutLeakingFeishuMessage(t *testing.T) { + previous := upstreamClient + defer func() { upstreamClient = previous }() + for _, tc := range []struct { + name, endpoint, payload, want string + status int + }{ + {"tenant denied", feishuTenantQuery, `{"code":99991672,"msg":"secret in upstream error"}`, "99991672", 403}, + {"unexpected tenant endpoint", feishuTenantQuery, `not json`, "HTTP 404", 404}, + {"token denied", feishuTenantToken, `{"code":10003,"msg":"secret in upstream error"}`, "10003", 401}, + } { + t.Run(tc.name, func(t *testing.T) { + upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) { + body := `{"code":0,"tenant_access_token":"temporary-token"}` + status := 200 + if req.URL.String() == tc.endpoint { + body, status = tc.payload, tc.status + } + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })} + _, _, err := queryFeishuTenant(context.Background(), tenantCredentials{AppID: "cli_test", AppSecret: "private-value"}) + if err == nil || !strings.Contains(err.Error(), tc.want) || strings.Contains(err.Error(), "secret in upstream error") || strings.Contains(err.Error(), "private-value") { + t.Fatalf("unsafe or unhelpful error: %v", err) + } + }) + } +} + func TestAdminNeverEchoesFeishuSecretAndKeepsEncryptedValue(t *testing.T) { dir := t.TempDir() t.Setenv("VPN_DATA_DIR", dir) diff --git a/docs/specs/SDD-007-built-in-feishu-issuer.md b/docs/specs/SDD-007-built-in-feishu-issuer.md index f31bccd..4a404e4 100644 --- a/docs/specs/SDD-007-built-in-feishu-issuer.md +++ b/docs/specs/SDD-007-built-in-feishu-issuer.md @@ -67,3 +67,9 @@ | AC-07-04 | 启用时企业标识留空可自动获取;查询失败不保存;手工输入兼容 | 管理接口测试与前端构建 | 发布前完成 Go 测试与前端构建;部署后检查新路由被托管 Nginx 白名单接受、未授权返回 401,并在轮换飞书 Secret 后进行真实查询。恢复可回退二进制与前端资源;不需要数据迁移,也不修改已运行专线。 + +## 8. 飞书应用引导与查询错误诊断(S3) + +当前 `tenant/v2/tenant/query` 失败时统一显示“请确认应用已开通企业信息读取权限”,掩盖接口路径、调用方式、应用发布状态、权限和网关拒绝等不同故障。页面需要针对企业自建应用给出最小配置步骤:凭据页 App ID/Secret;权限管理查找企业信息读取(`tenant:tenant:readonly`,以控制台实际名称为准)并发布版本;安全设置登记系统显示的精确 HTTPS 飞书回调;网页授权准备 `user_profile` 范围和应用可用范围。自动读取企业标识失败允许手填,但不能把未核实的企业标识视为认证成功。 + +后端只回显飞书返回的数字错误码与 HTTP 状态,不回显第三方原始 `msg`、访问令牌或凭据;页面保留错误提示以便管理员根据飞书错误码排查,不再把所有错误归因于权限。验收:模拟 `code != 0`、非 2xx、无响应码,区分令牌与企业查询阶段且不泄露上游原始信息;前端引导能看到精确回调、自动查询权限与发布要求;构建和身份模块测试通过。发布先构建和测试,再按现有 `/tmp` 双备份流程部署并检查 HTTPS、401 和 Nginx;恢复为上一版二进制,保留数据库与身份配置。 diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 8f97ae5..24d1ff1 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -575,6 +575,7 @@ }, "identity": { "tab": "Feishu identity", "title": "Built-in Feishu identity service", "description": "Issue OIDC identities for VPN using your existing HTTPS domain. Feishu app credentials stay upstream; this platform creates separate VPN client credentials.", + "guideTitle": "Configure your Feishu app", "guideApp": "In Feishu Open Platform → Enterprise apps, open your app. Copy the App ID and App Secret from Credentials & Basic Information into the fields below. Use enterprise app credentials.", "guidePermission": "In Permissions, request read access to enterprise information (search for tenant:tenant:readonly or 'get enterprise information'; use the actual name shown by Feishu) for automatic tenant lookup. Allow the user_profile scope for browser sign-in user info.", "guidePublish": "Publish or enable the new app version, complete administrator review if required, and add VPN users to the app's availability scope. Creating the app or adding a permission alone does not activate it.", "guideCallback": "In Security Settings → Redirect URLs, add this exact Feishu sign-in callback:", "guideCallbackPending": "Configure the HTTPS domain under Public Gateway first to see the callback URL.", "guideVerify": "Save and enable the identity service here, then sync its public routes and try a user in the app's availability scope. The Feishu callback differs from the VPN client callback below.", "guideManual": "If automatic lookup fails, enter a tenant_key verified from user information in the same enterprise. The Feishu error code appears below the field; retry after changing credentials or permissions.", "appId": "Feishu App ID", "appSecret": "Feishu App Secret", "secretPlaceholder": "Leave blank to retain the secret; use a rotated secret for new setup", "tenant": "Allowed Feishu tenant_key", "tenantPlaceholder": "Fetch automatically or enter manually", "fetchTenant": "Fetch tenant ID", "tenantFetched": "Feishu tenant ID retrieved", "tenantFailed": "Could not fetch Feishu tenant", "enabled": "Enable built-in issuer", "httpsOnly": "Enter app credentials only through the HTTPS management site. Switch to its secure address.", "openHttps": "Open HTTPS management site", "httpsRequired": "Configure and activate your public HTTPS site and certificate first.", "gatewayPending": "The identity provider is saved but Nginx does not yet route /id/; sync the gateway.", "addresses": "Addresses and client credentials", "issuer": "OIDC issuer", "feishuCallback": "Callback registered in Feishu", "vpnCallback": "VPN client callback", "clientId": "VPN Client ID", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index ce6709a..ffaf31b 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -575,6 +575,7 @@ }, "identity": { "tab": "飞书身份", "title": "内置飞书身份服务", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。", + "guideTitle": "飞书开放平台配置步骤", "guideApp": "在飞书开放平台 → 企业自建应用中打开你的应用;在「凭证与基础信息」复制 App ID 和 App Secret,填入下方。不要使用商店应用或智能体的其他凭据。", "guidePermission": "在「权限管理」申请企业信息读取权限(搜索 tenant:tenant:readonly 或“获取企业信息”,以飞书控制台实际名称为准),供“自动获取企业标识”使用;网页授权还需允许 user_profile 范围以读取登录用户信息。", "guidePublish": "按企业要求发布/启用新版本,等待管理员审核,并在「应用可用范围」加入需要登录 VPN 的用户;只创建应用或添加权限不代表已生效。", "guideCallback": "在飞书应用的「安全设置 → 重定向 URL」添加飞书登录回调(完整地址):", "guideCallbackPending": "先在「公网入口」完成 HTTPS 域名配置,页面才会显示回调地址。", "guideVerify": "保存并启用本页身份服务,再同步身份公网路由;用应用可用范围内的账号试登录。飞书回调地址与下方 VPN 客户端回调地址不同。", "guideManual": "自动获取失败时可手填 tenant_key;请从同一企业的飞书用户信息核对。错误码会显示在输入框下方,凭据或权限变更后可重试。", "appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "留空保持当前密钥;新配置请输入轮换后的密钥", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "可自动获取,也可手动填写", "fetchTenant": "自动获取企业标识", "tenantFetched": "已读取飞书企业标识", "tenantFailed": "读取飞书企业信息失败", "enabled": "启用内置发行者", "httpsOnly": "应用密钥只能通过 HTTPS 管理入口填写;请切换到安全地址。", "openHttps": "打开 HTTPS 管理入口", "httpsRequired": "请先完成公网域名 HTTPS 证书和站点配置,再启用身份服务。", "gatewayPending": "身份服务已保存,但 Nginx 尚未转发 /id/;请同步公网入口。", "addresses": "接入地址与客户端凭据", "issuer": "OIDC 发行者", "feishuCallback": "登记到飞书应用的回调", "vpnCallback": "VPN 客户端回调", "clientId": "VPN Client ID", diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index e7140cb..ff0474b 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -42,6 +42,17 @@ + + +
    +
  1. {{ $t('identity.guideApp') }}
  2. +
  3. {{ $t('identity.guidePermission') }}
  4. +
  5. {{ $t('identity.guidePublish') }}
  6. +
  7. {{ $t('identity.guideCallback') }} {{ identityStatus.feishu_callback }}{{ $t('identity.guideCallbackPending') }}
  8. +
  9. {{ $t('identity.guideVerify') }}
  10. +
+

{{ $t('identity.guideManual') }}

+
{{ $t('identity.openHttps') }} @@ -52,6 +63,7 @@ {{ $t('identity.fetchTenant') }} {{ tenantName }} + {{ $t('common.save') }} {{ $t('tls.refresh') }} @@ -212,7 +224,9 @@ const identitySaving = ref(false) const identityGatewayLoading = ref(false) const tenantLoading = ref(false) const tenantName = ref('') +const tenantError = ref('') function onIdentityAppChange() { + tenantError.value = '' if (identityForm.value.app_id !== identityStatus.value?.app_id) { identityForm.value.tenant_key = '' tenantName.value = '' @@ -220,6 +234,7 @@ function onIdentityAppChange() { } async function fetchIdentityTenant() { if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly')) + tenantError.value = '' tenantLoading.value = true try { const appId = identityForm.value.app_id @@ -229,7 +244,7 @@ async function fetchIdentityTenant() { tenantName.value = data.name || '' ElMessage.success(t('identity.tenantFetched')) } - } catch (e) { ElMessage.error(e.response?.data?.error || t('identity.tenantFailed')) } + } catch (e) { tenantError.value = e.response?.data?.error || t('identity.tenantFailed') } finally { tenantLoading.value = false } } async function loadIdentity() { @@ -239,6 +254,7 @@ async function loadIdentity() { identityStatus.value = data identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' } tenantName.value = '' + tenantError.value = '' } catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) } finally { identityLoading.value = false } } @@ -523,5 +539,9 @@ function copyBackup() { background: var(--bg-card); border-radius: 4px; } .tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; } +.identity-guide { margin-bottom: 16px; } +.identity-guide ol { margin: 0; padding-left: 22px; line-height: 1.8; color: var(--text-secondary); font-size: 13px; } +.identity-guide li + li { margin-top: 6px; } +.identity-guide code { display: inline-block; padding: 1px 5px; overflow-wrap: anywhere; color: var(--el-color-primary); background: var(--el-fill-color-light); border-radius: 4px; } .nginx-preview { max-height: 300px; overflow: auto; background: #172239; color: #e7eefc; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.5; }