diff --git a/backend/internal/identityprovider/handlers.go b/backend/internal/identityprovider/handlers.go index 4141543..e719a0e 100644 --- a/backend/internal/identityprovider/handlers.go +++ b/backend/internal/identityprovider/handlers.go @@ -106,13 +106,18 @@ func (p *Provider) Authorize(c *gin.Context) { oidcError(c, 503) return } - upstream := feishuAuthorize + "?" + url.Values{ - "client_id": {cfg.AppID}, "redirect_uri": {base + "/feishu/callback"}, "response_type": {"code"}, "scope": {"user_profile"}, "state": {state}, - }.Encode() + upstream := feishuAuthorizeURL(cfg.AppID, base+"/feishu/callback", state) noStore(c) c.Redirect(http.StatusFound, upstream) } +func feishuAuthorizeURL(appID, callback, state string) string { + return feishuAuthorize + "?" + url.Values{ + "client_id": {appID}, "redirect_uri": {callback}, "response_type": {"code"}, + "scope": {"contact:user.base:readonly"}, "state": {state}, + }.Encode() +} + var upstreamClient = &http.Client{Timeout: 8 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} func postFeishuToken(ctx context.Context, appID, appSecret, code, callback string) (string, error) { diff --git a/backend/internal/identityprovider/provider_test.go b/backend/internal/identityprovider/provider_test.go index ff9241e..4cb2a07 100644 --- a/backend/internal/identityprovider/provider_test.go +++ b/backend/internal/identityprovider/provider_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "io" "net/http" + "net/url" "path/filepath" "strings" "testing" @@ -51,6 +52,16 @@ func TestFeishuExchangeUsesJSONAndRejectsUnexpectedIdentity(t *testing.T) { } } +func TestFeishuAuthorizationUsesValidUserInfoScope(t *testing.T) { + u, err := url.Parse(feishuAuthorizeURL("cli_test", "https://vpn.example.com/id/feishu/callback", "state_test")) + if err != nil || u.Scheme != "https" || u.Host != "accounts.feishu.cn" || + u.Query().Get("scope") != "contact:user.base:readonly" || + u.Query().Get("redirect_uri") != "https://vpn.example.com/id/feishu/callback" || + u.Query().Get("state") != "state_test" { + t.Fatalf("invalid Feishu authorization request: %v, %v", u, err) + } +} + func TestFeishuExchangeRejectsMalformedSuccess(t *testing.T) { previous := upstreamClient defer func() { upstreamClient = previous }() diff --git a/docs/specs/SDD-007-built-in-feishu-issuer.md b/docs/specs/SDD-007-built-in-feishu-issuer.md index 9ac7583..823d9fe 100644 --- a/docs/specs/SDD-007-built-in-feishu-issuer.md +++ b/docs/specs/SDD-007-built-in-feishu-issuer.md @@ -70,7 +70,7 @@ ## 8. 飞书应用引导与查询错误诊断(S3) -当前 `tenant/v2/tenant/query` 失败时统一显示“请确认应用已开通企业信息读取权限”,掩盖接口路径、调用方式、应用发布状态、权限和网关拒绝等不同故障。页面需要针对企业自建应用给出最小配置步骤:凭据页 App ID/Secret;权限管理查找企业信息读取(`tenant:tenant:readonly`,以控制台实际名称为准)并发布版本;安全设置登记系统显示的精确 HTTPS 飞书回调;网页授权准备 `user_profile` 范围和应用可用范围。自动读取企业标识失败允许手填,但不能把未核实的企业标识视为认证成功。 +当前 `tenant/v2/tenant/query` 失败时统一显示“请确认应用已开通企业信息读取权限”,掩盖接口路径、调用方式、应用发布状态、权限和网关拒绝等不同故障。页面需要针对企业自建应用给出最小配置步骤:凭据页 App ID/Secret;权限管理查找企业信息读取(`tenant:tenant:readonly`,以控制台实际名称为准)并发布版本;安全设置登记系统显示的精确 HTTPS 飞书回调;网页授权所需权限和应用可用范围。最初页面错误地将 `user_profile` 称为飞书范围,第 10 节修正为飞书用户基础信息权限。自动读取企业标识失败允许手填,但不能把未核实的企业标识视为认证成功。 后端只回显飞书返回的数字错误码与 HTTP 状态,不回显第三方原始 `msg`、访问令牌或凭据;页面保留错误提示以便管理员根据飞书错误码排查,不再把所有错误归因于权限。验收:模拟 `code != 0`、非 2xx、无响应码,区分令牌与企业查询阶段且不泄露上游原始信息;前端引导能看到精确回调、自动查询权限与发布要求;构建和身份模块测试通过。发布先构建和测试,再按现有 `/tmp` 双备份流程部署并检查 HTTPS、401 和 Nginx;恢复为上一版二进制,保留数据库与身份配置。 @@ -81,3 +81,9 @@ 专线启动、停止、自动启动、运行中更新、删除及批量状态变更后,依据现有唯一运行中且公网域名匹配的 OIDC 专线刷新受管站点;串行执行刷新,避免并发 Nginx 写入。仅允许现有严格的回环端口代理,不改变其他路径的 444。刷新失败时专线实际状态仍以数据库为准,向操作方明确报告“专线状态已改变、路由同步失败”,不要谎称 VPN 启动失败或放行宽泛路径。 验收:启动后自动出现 `/oauth2/` 转发,停止或删除最后一条专线后移除,重复操作不改变配置;公网无 state 的 `/oauth2/start` 由空响应变为认证组件的 400,Nginx -t 成功;模拟冲突/同步失败时有可见警告。发布先备份二进制、构建和测试,再部署并检查运行中专线不被管理进程重启干扰;恢复为先前二进制并通过设置页手动同步路由,不修改数据库或证书。 + +## 10. 飞书授权范围修复与连接超时排查(S3) + +生产中 OpenVPN 客户端已接入 TCP 1194,认证组件发送 WebAuth 并等待 60 秒;此前 `/oauth2/` 入口的 444 已修复。隔离浏览器对内置发行者的合成请求跳到飞书错误页 `20043`:`user_profile 有误`,实际返回 `invalid_scope`。代码在 `handlers.go` 将 `user_profile` 写入飞书授权链接,设置页也错误地指导管理员启用它。参考项目 `/Users/logan/devops/demo/id/Feishu-OIDC-Workers` 使用飞书权限 `contact:user.base:readonly` 获取用户基础资料,替换无效范围,并在页面清楚区分企业信息查询权限与浏览器登录授权权限。OpenVPN OIDC 模式通过飞书 `tenant_key` 和 `open_id` 识别用户,不自动读取 VPN 拨号用户目录;如需逐用户允许名单须另行设计,不在本次导入飞书用户。 + +验收:单元测试验证生成的飞书授权链接包含新的权限范围、不再包含 `user_profile`;现有令牌/JWKS/用户信息测试、前端构建通过;上线后授权链接跳转路径和无 state 入口验证通过。真实飞书授权仍需应用批准该权限、发布并由用户本人完成登录;未经本人授权不得宣称已跑通完整 VPN 连接。发布前备份旧二进制、发布后检查健康与 Nginx;可回退到旧二进制但将恢复无效范围,须停止测试专线或等待修复。 diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 7d90588..c9207c8 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -575,7 +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": "For automatic tenant lookup, request enterprise information read access (search for tenant:tenant:readonly or 'get enterprise information'; use the actual name shown by Feishu). For browser login, the app must support the user_profile scope to read the signed-in user's information.", "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, then sync its public routes. Configure and start an OIDC OpenVPN tunnel, verify openvpn-auth-oauth2 is installed, sync the routes again, and test with a user in the app's availability scope.", "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.", "guideScope": "Finding tenant_key only proves the enterprise information API works; it does not confirm Feishu browser sign-in or VPN access. Browser login also needs user_profile, the callback URL, app publication, and user availability. VPN also needs a tunnel and auth component. The Feishu callback differs from the VPN callback.", + "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": "For automatic tenant lookup, request enterprise information read access (search for tenant:tenant:readonly or 'get enterprise information'; use the actual name shown by Feishu). For browser login, request the basic user information permission contact:user.base:readonly (use Feishu's actual console label).", "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, then sync its public routes. Configure and start an OIDC OpenVPN tunnel, verify openvpn-auth-oauth2 is installed, sync the routes again, and test with a user in the app's availability scope.", "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.", "guideScope": "Finding tenant_key only proves the enterprise information API works; it does not confirm Feishu browser sign-in or VPN access. Browser login also needs the basic user information permission contact:user.base:readonly, the callback URL, app publication, and user availability. VPN also needs a tunnel and auth component. The Feishu callback differs from the VPN callback.", "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", "fetchingTenant": "Requesting Feishu ({seconds}s)…", "tenantFetched": "Feishu tenant ID retrieved", "tenantFailed": "Could not fetch Feishu tenant", "tenantNotEnabled": "Tenant ID is set; enable the identity service and finish the callback, app publication, and tunnel configuration.", "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 b8da854..6cd45a7 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -575,7 +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": "保存并启用本页身份服务,再同步身份公网路由;配置并启动支持 OIDC 的 OpenVPN 专线,确认 openvpn-auth-oauth2 已安装后再次同步公网路由,最后用应用可用范围内的账号试登录。", "guideManual": "自动获取失败时可手填 tenant_key;请从同一企业的飞书用户信息核对。错误码会显示在输入框下方,凭据或权限变更后可重试。", "guideScope": "查到 tenant_key 只说明企业信息接口可用,不代表飞书网页登录或 VPN 已可连接。网页登录还需要上面的 user_profile、回调、发布与用户可用范围;VPN 还需配置专线和认证组件。飞书回调与 VPN 客户端回调是两个地址。", + "guideTitle": "飞书开放平台配置步骤", "guideApp": "在飞书开放平台 → 企业自建应用中打开你的应用;在「凭证与基础信息」复制 App ID 和 App Secret,填入下方。不要使用商店应用或智能体的其他凭据。", "guidePermission": "自动获取企业标识:在「权限管理」申请企业信息读取权限(搜索 tenant:tenant:readonly 或“获取企业信息”,以飞书控制台实际名称为准);浏览器登录:申请用户基础信息权限 contact:user.base:readonly(以飞书后台实际名称为准),本系统通过该授权读取登录用户。", "guidePublish": "按企业要求发布/启用新版本,等待管理员审核,并在「应用可用范围」加入需要登录 VPN 的用户;只创建应用或添加权限不代表已生效。", "guideCallback": "在飞书应用的「安全设置 → 重定向 URL」添加飞书登录回调(完整地址):", "guideCallbackPending": "先在「公网入口」完成 HTTPS 域名配置,页面才会显示回调地址。", "guideVerify": "保存并启用本页身份服务,再同步身份公网路由;配置并启动支持 OIDC 的 OpenVPN 专线,确认 openvpn-auth-oauth2 已安装后再次同步公网路由,最后用应用可用范围内的账号试登录。", "guideManual": "自动获取失败时可手填 tenant_key;请从同一企业的飞书用户信息核对。错误码会显示在输入框下方,凭据或权限变更后可重试。", "guideScope": "查到 tenant_key 只说明企业信息接口可用,不代表飞书网页登录或 VPN 已可连接。网页登录还需要用户基础信息权限 contact:user.base:readonly、回调、发布与用户可用范围;VPN 还需配置专线和认证组件。飞书回调与 VPN 客户端回调是两个地址。", "appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "留空保持当前密钥;新配置请输入轮换后的密钥", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "可自动获取,也可手动填写", "fetchTenant": "自动获取企业标识", "fetchingTenant": "正在请求飞书({seconds} 秒)…", "tenantFetched": "已读取飞书企业标识", "tenantFailed": "读取飞书企业信息失败", "tenantNotEnabled": "企业标识已填写;仍需启用身份服务并完成回调、应用发布及专线配置。", "enabled": "启用内置发行者", "httpsOnly": "应用密钥只能通过 HTTPS 管理入口填写;请切换到安全地址。", "openHttps": "打开 HTTPS 管理入口", "httpsRequired": "请先完成公网域名 HTTPS 证书和站点配置,再启用身份服务。", "gatewayPending": "身份服务已保存,但 Nginx 尚未转发 /id/;请同步公网入口。", "addresses": "接入地址与客户端凭据", "issuer": "OIDC 发行者", "feishuCallback": "登记到飞书应用的回调", "vpnCallback": "VPN 客户端回调", "clientId": "VPN Client ID",