spec.md 12 KB

客户端凭据与公共客户端协议加固

status: apply created: 2026-07-27 complexity: 🔴复杂

1. 背景与目标

sys_client.app_secret 当前以明文保存并直接比较,四个内置客户端的密钥还被写入初始化 SQL;管理端登录构建通过 VITE_APP_SECRET 把同一值打进浏览器产物。浏览器、H5 和分发式 App 无法安全保管客户端 Secret,因此继续把它们建模为机密客户端没有安全收益。

本变更完成以下结果:

  1. 客户端显式区分 none 公共客户端与 client_secret 机密客户端。
  2. 内置 PC、报表、App、H5 客户端迁移为公共客户端,数据库和前端构建不再保存或发送 Secret。
  3. 机密客户端仅保存带算法标记的 BCrypt 摘要,接口永不返回摘要或原文。
  4. 历史自定义客户端支持明文双读;首次成功验证后以条件更新方式升级为 BCrypt,兼容期可盘点且不得吞掉格式错误。
  5. 客户端查询下沉 Mapper XML,避免本变更继续扩大 Controller/Service Wrapper 技术债。

2. 代码现状(Research Findings)

2.1 相关入口与链路

  • 登录入口:SystemAuthServiceImpl#validateAndLoadClient 读取 userClient/appId/appSecret,当前通过 SysClient#getAppSecret().equals 比较。
  • 通用校验:ClientServiceImpl#validateAppSecret 同样直接明文比较。
  • 管理入口:SysClientController#create/update/getAppSecret 直接保存 DTO 中的 Secret,并对存储值做前四后四脱敏。
  • 浏览器入口:login/index.vuelogin/callback.vueVITE_APP_SECRET 读取并随登录请求发送;.env.development.env.production 均配置了非空值。
  • 数据入口:两份初始化 SQL 的 sys_client.app_secretNOT NULL,四个内置客户端均保存明文。

2.2 现有实现

  • forge-starter-auth 已提供 PasswordUtil.encrypt/matches BCrypt 封装,System 插件已依赖该 Starter。
  • sys_client.app_secretvarchar(128),可容纳 {bcrypt} 标记和 BCrypt 摘要。
  • SysClientMapper 当前只继承 BaseMapper,没有 Mapper XML。
  • SysClientVO 不返回 appSecret,但 getMaskedAppSecret 会泄露摘要前后片段。

2.3 发现与风险

  • VITE_* 会被 Vite 静态注入浏览器产物,不能作为机密凭据来源。
  • 只把数据库值改成 BCrypt 而继续让浏览器发送固定 Secret,仍允许任何前端用户提取并复用该凭据。
  • 直接把全部存量客户端切成公共客户端会削弱自定义服务端客户端,必须只清理明确的内置公共客户端。
  • 无条件机会式升级可能覆盖管理员并发轮换的新 Secret,必须以 id + 原存储值 条件更新。

3. 功能点

  • 新增客户端认证方式字段和 sys_client_auth_method 字典。
  • 内置公共客户端清空 app_secret,初始化 SQL 不再包含客户端 Secret。
  • 新增 {bcrypt} 客户端 Secret 编解码器,使用 BCrypt 校验和常量时间遗留明文比较。
  • 创建/修改机密客户端时哈希新 Secret;修改时空值保留,切换公共客户端时清空。
  • 历史明文成功校验后条件升级摘要并清理缓存。
  • 登录认证按客户端认证方式决定是否要求 Secret;公共客户端仍必须匹配 clientCode + appId
  • 前端删除 VITE_APP_SECRET 读取和环境值,管理页按认证方式显示轮换输入。
  • 客户端分页、列表、按编码和 AppId 查询进入 Mapper XML。
  • 登录及密码类入口从通用操作日志硬排除,认证配置读取绕过一小时通用缓存,消除 Secret 日志和旧凭据回填窗口。
  • 空 Secret 元数据更新不写 app_secret;轮换、清空和认证方式切换使用旧存储值 CAS,禁止并发恢复旧摘要。
  • Bearer Token 不进入认证/在线用户日志或 WebSocket 广播载荷,单会话和批量下线路径从通用操作日志硬排除。
  • 客户端 Mapper XML 使用显式 resultMapJacksonTypeHandler 恢复 IP 白名单 JSON 映射。
  • /auth/online/** 强制登录并逐接口校验权限;移除测试广播入口,所有管理操作通过租户内会话/用户记录定位。
  • 在线用户分页、列表及 userTokens 兼容路径只返回会话 ID 和展示字段,列表 SQL 不读取 token_value

4. 业务规则

  1. none 只能表示无法安全持有 Secret 的公共客户端;仍必须校验启用状态、客户端编码和 AppId。
  2. client_secret 创建时必须提供 Secret;更新时空值表示不轮换,非空表示原子替换摘要。
  3. 新 Secret 至少 16 个字符且 UTF-8 编码不超过 72 字节,避免 BCrypt 输入截断;不记录日志,不进入 VO、缓存日志或错误信息。
  4. 摘要格式固定为 {bcrypt}<60-char-hash>;未知标记或损坏摘要失败关闭。
  5. 遗留无标记值只在兼容期开启双读;匹配使用 UTF-8 字节常量时间比较,成功后立即条件升级。
  6. 管理端只返回 hasAppSecret/固定掩码,不返回摘要片段。
  7. Bearer Token 可用于会话定位和下线执行,但不得写入应用日志、操作日志或广播消息载荷。

5. 数据变更

操作 表名 字段/索引 说明
新增 sys_client client_auth_method varchar(32) 默认 client_secret,保护未知存量自定义客户端
修改 sys_client app_secret varchar(128) NULL 公共客户端允许为空
更新 sys_client 内置客户端 pc/forge_report/app/h5 设置 none 并清空 Secret
新增 sys_dict_type/data sys_client_auth_method noneclient_secrettenant_id=1

迁移脚本使用 V1.0.53__harden_client_credentials.sql,所有结构和字典写入具备 information_schema/NOT EXISTS 防重复保护。回滚代码前必须先确认没有 {bcrypt} 摘要且公共客户端已恢复旧协议;不提供恢复历史明文的 SQL。

6. 接口变更

操作 接口 方法 变更内容
收紧 /auth/login POST 公共客户端不再接收固定 Secret;机密客户端校验 BCrypt/兼容明文
扩展 /system/client POST/PUT 新增 clientAuthMethod;Secret 采用新增必填、更新可选轮换语义
收紧 /system/client/secret/{id} GET 仅返回固定掩码或空值,不泄露摘要片段
收紧 /auth/online/page/list/userTokens GET 需要显式查询权限;不返回 Bearer Token,userTokens 兼容路径返回租户内会话记录 ID
收紧 /auth/online/kickout/batchKickout POST 需要独立操作权限;请求参数从原始 Token 改为租户内会话记录 ID
删除 /auth/online/test GET 删除匿名 WebSocket 测试广播入口

7. 影响范围

  • forge-plugin-system:实体、DTO/VO、Mapper XML、凭据编解码、Service、认证和测试。
  • forge-admin-ui:登录请求、环境模板、客户端管理页。
  • forge-server/db/migration 和两份初始化 SQL:字段、字典和内置公共客户端数据。

8. 风险与关注点

  • 认证安全变更:公共/机密客户端边界错误会导致登录拒绝或客户端认证降级,必须按内置编码白名单迁移。
  • 兼容迁移:历史自定义客户端没有可恢复原文副本;兼容读不能在未盘点前直接关闭。
  • 缓存一致性:客户端认证方式和 Secret 属于安全配置,不再从通用缓存读取;更新、删除和历史升级继续清理遗留 client:config:* 键。
  • 浏览器约束:删除 VITE_APP_SECRET 是协议修正,不能通过混淆或改名重新引入。

8.5 测试策略

  • 测试范围:Codec 格式、公共/机密创建更新、历史升级和轮换并发保护、登录分支、Mapper JSON 映射、Token 日志边界、前端 build 和敏感值扫描。
  • 覆盖率目标:Codec 和 Secret 状态分支 100%;不设置仓库总覆盖率阈值。
  • 独立 Test Spec:是,见 test-spec.md

9. 待澄清

  • 无。采用保守规则:仅四个仓库内置分发式客户端迁移为 public;未知存量客户端保持 client_secret 并走兼容升级。

10. 技术决策

  1. 使用显式 client_auth_method,不通过 Secret 是否为空猜测客户端类型。
  2. 复用 PasswordUtil BCrypt,不引入新的哈希依赖。
  3. 使用 {bcrypt} 标记区分新旧格式,避免把普通 $2... 明文误判为摘要。
  4. 使用 Mapper XML 条件更新完成机会式升级,不在 Service 使用 Wrapper 拼查询。
  5. 公共客户端删除 Secret,而不是给浏览器换一个新的固定值。
  6. 管理端新建客户端默认选择 client_secret,仅在管理员明确选择时创建公共客户端。
  7. 客户端认证配置每次从数据库读取,不使用缺少版本/CAS 合同的一小时通用缓存。
  8. /auth/login 及密码类入口由日志切面硬排除,动态日志配置不能重新开启敏感请求体记录。
  9. 元数据、认证方式和 Secret 在同一条 Mapper XML UPDATE 中写入,并同时校验旧认证方式与旧 Secret;空 Secret 元数据更新不写 app_secret,CAS 失败抛错并回滚。
  10. 在线会话管理接口仅接收会话记录 ID,Service 在租户过滤后的数据库记录中解析 Token;Token 不进入响应、日志或广播载荷。
  11. 动态 API 权限排除只影响数据库权限拦截器,不能绕过全局登录校验;在线管理方法另有不可动态移除的 @SaCheckPermission

11. 执行日志

Task 状态 实际改动文件 备注
Proposal 完成 本变更四份 SDD 文档 当前用户消息确认按审计建议继续
Task 1-4 完成 Flyway/初始化 SQL、System Entity/DTO/VO/Mapper/Service/Auth/Controller、Admin UI/环境文件 未连接真实数据库
Task 5 代码修复完成 在线鉴权/租户边界、会话 ID 协议、双字段原子 CAS、Token 日志清理、36 条目标测试 未执行真实 DB/Redis/E2E 或全量构建,保留部署门禁

12. 审查结论

  • 阶段一 Spec Compliance:PASS。
  • 阶段二首次复审:FAIL,发现 legacy-read 迁移覆盖关闭状态和 CAS 失败测试缺口;两项均已修复。
  • 阶段二终审:FAIL,1 个 Critical、1 个 Important、2 个 Minor;发现登录 Secret 可进入通用操作日志、轮换后旧认证配置可被缓存回填,以及环境模板/合同覆盖缺口。
  • 最新修复:日志切面硬排除登录和密码类入口并同步默认/初始化配置;认证配置改为数据库直读且只清理遗留缓存;删除全部浏览器 Secret 环境声明;新增日志、缓存、Mapper/响应和环境合同。
  • 第三次 Code Quality Review:FAIL,2 个 Critical、2 个 Important、2 个 Minor;发现并发元数据更新可恢复旧摘要、Bearer Token 日志/操作日志暴露、Mapper JSON TypeHandler 丢失和合同缺口。
  • 第三次修复:空 Secret 更新零写,轮换/清空改为旧值 CAS;移除 Token 日志和广播载荷,硬排除在线下线路径;Mapper 增加显式 resultMap/JacksonTypeHandler,环境合同覆盖 .env.test
  • 修复验证:System 5 个目标测试类 26/26,System 25 模块 Reactor package 通过,SysClient Mapper XML 和 Token 静态扫描通过;前端最新生产构建仍为 8727 modules。等待新的阶段二复审。
  • 本轮 Critical/Important 修复:删除 /auth/online/test,登录排除与动态权限排除解耦,在线接口增加显式权限和租户内记录校验;列表 SQL 不读取 Token;客户端元数据、认证方式、Secret 合并为同一条双旧值 CAS。
  • 本轮验证:7 个目标测试类 36/36;目标 Vue ESLint、两份 Mapper XML、Flyway 版本/placeholder、环境 Secret、Token 日志/响应和格式扫描通过。按用户约束未执行真实 DB/Redis/E2E 或全量构建。
  • 最新 Code Quality 复审:FAIL,1 个 Critical;超级管理员会跳过租户 SQL 拦截,在线会话管理仍需显式传入当前 tenantId
  • 处置状态:用户于 2026-07-27 明确要求暂不处理该项;本阶段保持未通过,不将其记为部署门禁已完成。

13. 确认记录(HARD-GATE)

  • 确认时间:2026-07-27
  • 确认人:用户
  • 确认内容:用户在收到剩余项、风险和执行顺序后明确回复“按照你的思路 继续进行”,授权按该分阶段方案继续实施。