Files
opsx-vpn/docs/specs/SDD-007-built-in-feishu-issuer.md
T

124 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SDD-007:内置飞书 OIDC 发行者
| 字段 | 内容 |
|---|---|
| 级别 | S3(公网身份入口、签名密钥、生产网关变更) |
| 状态 | 实施中,尚未验证真实飞书登录 |
| 日期 | 2026-10-22 |
## 1. 现状与目标
当前 OpenVPN OIDC 模式调用 `openvpn-auth-oauth2`,要求外部标准 issuer;`/api/tunnels/openvpn/oidc-readiness` 仅检测已安装插件与 HTTPS 管理入口。Nginx 只允许管理页面/API,其他路径返回 444;现有 LDAP 与密码专线均应保留。
增加单租户、单机部署的 Go `/id` 身份模块:飞书网页 OAuth 授权后,向插件提供受限的 OIDC authorization code 流程。仅已启用的管理员配置可提供发现文档、JWKS 与发码;不把登录成功视为自动获得 VPN 权限。沿用现有 HTTPS 域名及数据库,不引入外部身份服务器。
## 2. 功能与约束
- FR-01:管理员配置飞书 App ID/Secret(密钥不回显)、允许的 tenant key;系统生成并仅在服务端使用 VPN OIDC Client Secret,管理页面只显示 Client ID;复用现有 TLS 域名。
- FR-02:提供 discovery、JWKS、authorize、飞书回调、token、userinfo;只接收登记的一个 HTTPS 回调地址,发放一次性短时 code;签发 RS256 ID Token。飞书返回的 open_id 与 tenant_key 组合成稳定 subject。
- FR-03:OpenVPN 插件通过机密客户端交换授权码;客户端凭据、回调、nonce、state 绑定,存在 code_challenge 时校验 S256;不接受外部指定的上游 URL。
- FR-04:前端展示 issuer、飞书回调及 VPN 回调、准备状态,允许选择内置 issuer 填写专线配置,但不覆盖既有配置。
- FR-05:Nginx 仅允许同域 `/id/` 的固定身份端点转发到管理进程;`/oauth2/` 转发到单条已启动的认证组件(仅就绪后启用);其他未知路径保持 444。
- NFR-01:签名密钥 0600、密钥材料不可回显,令牌与随机状态只保存哈希或受限数据;严格 TLS、过期时间、状态原子消费、请求限流及日志脱敏。
- NFR-02:未启用或配置不完整时不发布可用 issuer,不破坏现有 OpenVPN/LDAP/管理入口。
## 3. 方案与契约
发行者建议 `https://<现有域名>/id`;只按 issuer 文档发布实际 URL,不假设根目录 discovery。飞书上游回调 `https://<现有域名>/id/feishu/callback`;插件回调 `https://<现有域名>/oauth2/callback`。飞书 App ID/Secret 只用于上游;插件使用系统生成的独立 VPN 凭据。数据加密利用现有 `secret.key`;RSA 签名私钥独立文件,备份必须同时包含数据库和密钥。
独立 `internal/identityprovider` 包承载协议逻辑和存储;`api` 管理路由只对管理员开放;Nginx 模板明确路由优先级。JWT 仅签发 `openid` 授权范围,不在令牌里包含飞书访问令牌。用户授权初期先采用飞书应用可用范围 + 明确租户约束;后续如需逐用户允许名单再增加独立配置。
接口参考 `/Users/logan/devops/demo/id/Feishu-OIDC-Workers` 的飞书官方端点定义与 JSON 换码格式;授权状态和客户端凭据隔离参考 `/Users/logan/devops/demo/id/dex` 的协议边界。没有复制其实现;Workers 示例把原始飞书 code 转交客户端的做法不用于本项目。
## 4. 风险与验收
| 风险 | 防护 / 恢复 |
|---|---|
| 伪造授权回调 / 重放 code | 随机 state 与 code、短有效期、数据库原子消费、精确 redirect URI 和客户端绑定 |
| 泄露私钥或飞书密钥 | 0600 与 AES-GCM、API 不回显、部署时不传入对话中曾公开的旧密钥 |
| 多专线共用 `/oauth2/` | 网关只选择唯一运行中的同域 OIDC 专线,冲突时不启用转发 |
| 网关修改影响管理入口 | 先预览、备份现有站点、`nginx -t` 后 reload;失败恢复旧站点 |
验收:禁用与错误配置拒绝授权;模拟飞书服务测试完整码流、重放、错误租户、错误回调/nonce/PKCE、JWT 验签;真实飞书应用授权和 OpenVPN WebAuth 需单独联调。发布顺序:完成本地测试 → 部署但默认禁用 → 管理员轮换并保存飞书凭据 → 预览并启用网关 → 测试专线正/负向登录 → 再决定生产切换。恢复:禁用 issuer、恢复 Nginx 旧站点和旧二进制;不删除现有专线或密钥。
## 5. 专线与身份配置分工(编辑页迭代)
现有专线编辑页仍把外部 OIDC 的 issuer 与 VPN Client Secret 输入框作为主流程,容易让管理员误把飞书 App ID/Secret 填入专线。管理员仅在「设置 → 飞书身份」维护全局飞书 App ID、App Secret 和 tenant_key;专线的内置模式自动引用当前 issuer、独立 VPN Client ID 和公网入口。编辑既有外部 OIDC 专线时保持原始设置,只有明确切换来源才更改字段。外部 OIDC 仍允许手工配置,不把飞书上游凭据放入专线。
验收:专线编辑和新建都能直达身份设置;内置模式不展示飞书密钥或可编辑的 VPN 密钥,提示飞书回调与 VPN 回调的区别;外部模式保留原先的输入能力;服务端对内置标记校验已启用的发行者、Client ID 和当前域名,不因提交旧密钥而回退至外部模式。该迭代不自动切换、启用或重启已有专线;上线前构建前端并跑相关 Go 测试,上线后检查管理入口与禁用的身份入口,失败恢复上一版二进制。
## 6. 公网白名单与页面故障修复
线上新增 `/api/system/identity` 后托管 Nginx 仍使用旧白名单,浏览器看到 `ERR_EMPTY_RESPONSE`(Nginx 444);本机管理进程可正常返回 401。已有 `/api/system/tls/domain` 路由在无凭据时返回 401,不能把用户同时报告的域名保存失败归结于同一缺失路由。Vue 侧栏在 `route` 创建前注册 watch,导致初始化时引用尚未声明的变量;WireGuard SVG 含截断的路径数据。
每次启动且确认当前是有效 Nginx HTTPS 托管站点时,按已注册 Gin 路由重建站点白名单;文件内容相同则不重载,冲突或 `nginx -t` 失败时保留旧站点并记录日志,不阻止本机服务启动。公网只同步已注册 API、固定身份端点与当前单条运行的同域认证组件,未知路径仍 444。验收:新身份管理 API 不再返回空连接,未经认证返回 401;既有域名 PUT 也仍返回 401,授权后的保存需单独实测;设置和编辑页无布局初始化异常;SVG 路径有效;Nginx 测试通过。恢复时使用 `/tmp` 的上一版二进制和原 Nginx 站点快照重新加载,不修改专线或飞书凭据。
## 7. 自动读取飞书企业标识(2026-10-22,S3)
当前管理员必须手填 `tenant_key` 才能启用身份服务;用户提供的飞书 SDK `tenant.v2.tenant.query` 可通过应用凭据获取企业信息。新增仅管理员可调用的 `POST /api/system/identity/tenant`:沿用 HTTPS 管理入口限制,使用表单中 App ID/Secret 或同一 App ID 已保存的加密 Secret,向飞书换取 tenant access token 后查询企业名称和 tenant_key。仅返回这两个字段,不保存临时输入的凭据、令牌或查询结果;响应禁止缓存。页面提供「自动获取」并回填可编辑字段;保存启用配置且企业标识为空时也尝试自动获取。更换 App ID 时绝不复用原 App Secret 或旧企业标识;查询失败时不启用或改变已保存的配置。
接口限定固定飞书域名、短超时、受限响应体和无重定向;错误不回显密钥或 token。飞书授权需要应用具备企业信息读取权限(按飞书后台实际授权提示配置)。管理员仍可手填企业标识以兼容既有配置;真实飞书 API 联调在获得轮换后的凭据时进行。
| 验收 | 操作与预期 | 验证 |
|---|---|---|
| AC-07-01 | HTTPS 管理员查询正确凭据,返回企业标识与名称;未保存凭据不落库 | 模拟上游请求/响应 |
| AC-07-02 | 同一 App ID 可复用加密 Secret;换 App ID 不复用旧密钥或旧租户 | 管理接口测试 |
| AC-07-03 | 飞书拒绝授权、响应异常、无标识或 HTTP 管理请求时,安全失败且不泄露凭据 | 负向测试 |
| AC-07-04 | 启用时企业标识留空可自动获取;查询失败不保存;手工输入兼容 | 管理接口测试与前端构建 |
发布前完成 Go 测试与前端构建;部署后检查新路由被托管 Nginx 白名单接受、未授权返回 401,并在轮换飞书 Secret 后进行真实查询。恢复可回退二进制与前端资源;不需要数据迁移,也不修改已运行专线。
## 8. 飞书应用引导与查询错误诊断(S3)
当前 `tenant/v2/tenant/query` 失败时统一显示“请确认应用已开通企业信息读取权限”,掩盖接口路径、调用方式、应用发布状态、权限和网关拒绝等不同故障。页面需要针对企业自建应用给出最小配置步骤:凭据页 App ID/Secret;权限管理查找企业信息读取(`tenant:tenant:readonly`,以控制台实际名称为准)并发布版本;安全设置登记系统显示的精确 HTTPS 飞书回调;网页授权所需权限和应用可用范围。最初页面错误地将 `user_profile` 称为飞书范围,第 10 节修正为飞书用户基础信息权限。自动读取企业标识失败允许手填,但不能把未核实的企业标识视为认证成功。
后端只回显飞书返回的数字错误码与 HTTP 状态,不回显第三方原始 `msg`、访问令牌或凭据;页面保留错误提示以便管理员根据飞书错误码排查,不再把所有错误归因于权限。验收:模拟 `code != 0`、非 2xx、无响应码,区分令牌与企业查询阶段且不泄露上游原始信息;前端引导能看到精确回调、自动查询权限与发布要求;构建和身份模块测试通过。发布先构建和测试,再按现有 `/tmp` 双备份流程部署并检查 HTTPS、401 和 Nginx;恢复为上一版二进制,保留数据库与身份配置。
## 9. OpenVPN 浏览器入口随专线状态同步(S3)
线上 OpenVPN OIDC 专线运行且认证组件监听 `127.0.0.1:19090`,但当专线在管理服务启动后才启动时,受管 Nginx 未同步 `/oauth2/`,浏览器访问 `/oauth2/start` 被兜底 `444` 关闭。现有 `RefreshManagedGatewayOnStart` 仅启动时运行;设置页允许手动同步,容易遗漏。
专线启动、停止、自动启动、运行中更新、删除及批量状态变更后,依据现有唯一运行中且公网域名匹配的 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;可回退到旧二进制但将恢复无效范围,须停止测试专线或等待修复。
## 11. 权限检测与浏览器结果页(S3)
真实飞书授权及 OpenVPN 建链现已在实例日志中验证。配置页输入 App ID/Secret 后延迟调用已有 HTTPS 管理员查询接口:成功读取企业信息才显示 `tenant:tenant:readonly` 已验证,并自动填入 tenant_key;失败仅展示脱敏错误并允许重试。`contact:user.base:readonly` 是用户授权范围,不能仅靠应用凭据证明;首次成功换取用户资料并校验租户后记录无用户标识的验证时间,换应用凭据或租户时清除,设置页只展示是否曾通过。服务端启动或页面刷新不重复发起 OAuth,也不把手填 tenant_key 当作验证通过。密钥只经 HTTPS 管理接口传输,页面异步请求需忽略被新输入覆盖的旧响应。
认证组件通过原生 `http.template` 加载每实例的 0600 HTML 页面;页面只提示返回客户端查看连接状态,不在可能复用的终态模板上错误宣称 VPN 已连接。不额外放宽 Nginx 路径。验收:凭据变更后状态清零,查询正确/错误与旧响应均不误报;首次真实登录后状态可见;插件能正常加载模板并保持浏览器认证、OpenVPN 建链。发布时先构建测试,再替换二进制并重启受影响的 OIDC 实例以应用新模板;若插件不兼容模板,回退之前的配置与二进制、重启实例,旧认证仍可使用。数据库新列由 AutoMigrate 添加,回退版本忽略该列。
## 12. 在线会话的未知身份(S1)
OpenVPN `status-version 3` 的第 1 列 Common Name 与第 9 列 Username,在无客户端证书的 WebAuth 首次连接时都可能为 `UNDEF`。目前会话同步把占位字符串当作拨号用户名,误写入在线会话及审计。优先使用有效 Username,其次使用有效证书 CN;两列均为 `UNDEF` 时留空,并在在线会话及连接审计页面明确显示“身份未识别”。保持原流量快照键以避免部署后的首次轮询重复计数;不根据公网 IP、姓名或认证时间猜测身份,更不把“LG”这样的显示名自动绑定 VPNUser。已有历史日志保留原值,仅页面标记其身份未识别。
验收:密码与证书连接仍展示其用户名/CN;无身份的 WebAuth 状态行不会记录 `UNDEF` 为 VPNUser,也不会误关联飞书人员;在线会话保留连接地址与流量。若同一连接随后获得可信 Username,沿用此前 `UNDEF` 流量快照计算增量,不把旧流量重复累计;单元测试覆盖未知身份、重复轮询和后续获得稳定 subject 的过程。真正按人展示还需验证 `openvpn-auth-oauth2` 是否能向 OpenVPN 提供带稳定 subject 的 Username;如插件不传递,须在认证阶段增加可信映射,不能用会话 IP 推断。此修复不改变认证权限或 Nginx;发布前运行 OpenVPN 单元测试及前端构建,发布后检查真实状态文件第 1/9 列与在线会话,失败则恢复旧管理二进制,保留数据库和专线进程。
## 13. 授权身份传递与回调结果页(S2)
现有 `fetchFeishuUser` 从飞书 `/authen/v1/user_info` 读取 `open_id`、`tenant_key`、`name`,`/id/userinfo` 输出稳定 `sub` 与 `name`;但 ID Token 仅包含 `sub`。浏览器 `/oauth2/callback` 是认证组件消费授权码的端点,地址栏的 code 不能代表用户资料。将已获取的非空姓名作为标准 OIDC `name` claim 加入 ID Token;`sub` 继续由 `tenant_key:open_id` 组成,不能以可变姓名替代。结果模板改为简洁蓝白灰页面,继续保持结果中性,不能输出令牌、姓名或根据地址栏宣称 VPN 已连通。
FR-13-01:模拟飞书换码与用户信息返回时,服务端传递稳定标识与姓名,签发的 ID Token 在非空时包含 `name`,`/id/userinfo` 保留原字段;空姓名不写入空 claim。FR-13-02:同一 0600 模板通过 `http.template` 用于回调终态,无论认证结果如何都只提示切回客户端检查状态。
AC-13-01:模拟上游含 `open_id`、`tenant_key`、`name` 的响应,验签并检查 ID Token 的 `sub`/`name`,验证名称为空时不增加 claim;运行身份模块测试。AC-13-02:读取生成的模板,核对页面标题、移动端视口及不出现回调参数/用户资料;运行 OpenVPN 模块测试。真实账号授权和 OpenVPN 状态第 1/9 列另需在线联调,单元模拟不等同于线上已显示真人姓名。
风险:姓名属于个人信息,只放入面向已认证机密客户端的短时 ID Token 与现有 UserInfo,不写浏览器结果页或日志;上游未提供姓名时维持稳定 `sub`。兼容性:已有依赖 `sub` 的客户端行为不变;不增公网路由或数据库列。发布先构建并验证,再部署管理二进制、重启受影响的 OIDC 专线以生成新模板,检查 HTTPS 认证流程;失败回退二进制并重启该专线,不删除身份数据或签名密钥。
## 14. WebAuth 身份进入 OpenVPN 会话(S3,待组件核实)
现状证据:飞书回调读取 `tenant_key`、`open_id`、`name`,受限 OIDC Token 返回稳定 `sub`;实例配置 `openvpn.auth-token-user: true`,但服务端 `status-version 3` 的 Username 和 CN 仍可能都是 `UNDEF`。`auth-token-user` 是推送给客户端以后重新认证使用的用户名,不能证明服务端首次连接状态已绑定 OIDC subject。会话同步只信任 OpenVPN 状态文件,不应按公网 IP、浏览器回调时间或显示名匹配人。旧版代码把 `UNDEF` 直接写入审计,本地 S1 修正正在避免新增这类伪用户;历史审计保持原样。
目标链路:飞书应用确认用户和租户 → OIDC 使用 `tenant_key:open_id` 作为稳定 subject、姓名只用于显示 → 认证组件验证令牌与 pending CID/KID 的绑定 → 在同一条管理接口 `client-auth` 的客户端配置中设置 `override-username <稳定 subject>` → OpenVPN 状态文件 Username 出现该 subject → 平台在线会话与审计使用这个 subject。OpenVPN 本地源码文档 `doc/man-sections/server-options.rst` 明确 `override-username` 会被状态输出采用;但须先核实当前 `openvpn-auth-oauth2` 版本是否支持下发此配置,若不支持,应修改/封装认证组件并在集成测试验证,不能在 `server.conf` 中静态设置一个共用用户名。将来展示飞书姓名需按 subject 保存受控身份资料或提供受限查询,不得将姓名当成授权键或自动关联同名 VPNUser。
FR-14-01:每个成功认证的 OpenVPN CID/KID 必须绑定经验证的非空稳定 subject;缺失身份则拒绝授权,不得以 `UNDEF`、IP 或可变姓名补全。FR-14-02:状态文件的 Username 必须与该 subject 一致,平台在线列表和连接/断开审计使用该值;密码和证书专线保留原行为。FR-14-03:部署不得扩大 Nginx、管理 socket 或飞书资料接口的权限;失败时能回退认证组件与配置。
AC-14-01:模拟两个并发 WebAuth 请求及其不同 CID/KID,证明不能交叉赋值,令牌验证失败、租户不符和空 subject 均拒绝;检查管理命令仅含安全编码的 subject。AC-14-02:测试专线真实授权后读取 `openvpn-status.log` 的 CN/Username、在线会话和连接/断开审计,三处能追溯同一稳定 subject;重复认证和重连无误绑定。AC-14-03:测试旧密码/证书连接、认证失败、组件崩溃恢复,并检查失败时无匿名会话被错误归到已认证用户。
待决:当前运行的 `openvpn-auth-oauth2` 对 `client-auth` 配置和令牌 `sub` 的实际处理,以及线上状态文件原始列值;在没有组件源码/运行输出验证前,不声明此节已实现。发布顺序为先验证并编译组件、测试专线联调,再更新管理服务及组件,最后观察线上状态与审计;回退为恢复先前组件及配置并重启测试专线,保留密钥、身份数据和历史审计。