安全与许可证.md 32 KB

安全与许可证

本文引用的文件

  • SaTokenWebConfig.java
  • LoginActivityService.java
  • LicenseUtil.java
  • LicenseErrorEnum.java
  • SystemController.java
  • SystemService.java
  • SystemInfo.java
  • StickyRouteFilter.java
  • LoginRequestFilter.java
  • LoginResponseFilter.java
  • TokenResolver.java
  • UserRouteService.java
  • useWatermark.ts
  • authorization/index.vue
  • AuthorizationInfoModal.vue
  • license.ts

目录

  1. 引言
  2. 项目结构与安全边界
  3. 核心安全组件
  4. 架构总览
  5. 详细组件分析
  6. 依赖关系分析
  7. 性能与可用性特性
  8. 故障排查指南
  9. 结论

引言

本文面向 Sa-Token 会话管理、Gateway 用户路由一致性、离线授权流程以及 Electron 本地安全边界,给出可操作的实现说明和排查方法。重点覆盖:

  • Sa-Token 登录拦截、活跃度登记、账号“正在使用”判定与顶下线语义。
  • Gateway 中登录请求的 username 解析、登录响应的 token/用户名绑定、普通请求的 x-token 解析一致性。
  • regInfo.exe(由后端通过 LicenseUtil 调用)的机器码、过期时间、单位信息校验流程。
  • 前端如何读取授权单元并写入水印,以及 Electron 主进程与渲染进程隔离带来的安全影响。
  • 本地文件保护、敏感配置与环境变量覆盖的安全注意事项。
  • 许可证失效、token 不一致、账号被顶下线等问题的排查步骤。

项目结构与安全边界

本仓库包含网关、后端服务、Electron 桌面前端等多个子系统,安全相关能力分布在以下区域:

区域 主要职责 安全关注点
后端服务 Sa-Token 认证、活跃会话标记、离线授权命令执行、系统信息存储 外部进程调用、授权文件落盘、Redis 状态、接口白名单
网关 登录请求预处理、粘性路由、token 缓存、用户节点绑定 x-token 解析、登录体大小限制、响应后处理不阻塞业务
Electron 前端 授权入口页、水印、本地缓存、环境变量 渲染进程权限、DOM 防篡改、敏感值不落库
graph TB
    Client["客户端浏览器或 Electron 渲染进程"] --> Gateway["Spring Cloud Gateway"]
    Gateway --> Backend["后端 Spring Boot 服务"]
    Backend --> Redis["Redis"]
    Backend --> DB["数据库 system_info 表"]
    Backend --> LicenseTool["regInfo.exe / 授权工具"]
    Client --> Watermark["前端水印 DOM"]

图示来源

  • StickyRouteFilter.java:1-180
  • LoginRequestFilter.java:1-120
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • SaTokenWebConfig.java:1-65
  • LicenseUtil.java:1-163
  • useWatermark.ts:1-205

章节来源

  • StickyRouteFilter.java:1-180
  • LoginRequestFilter.java:1-120
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • SaTokenWebConfig.java:1-65
  • LicenseUtil.java:1-163
  • useWatermark.ts:1-205

核心安全组件

本节从安全视角梳理关键类及其职责。

组件 类型 安全职责
SaTokenWebConfig 后端配置 注册 Sa-Token 拦截器;仅做登录检查,不做权限注解扫描;维护登录白名单
LoginActivityService 后端服务 记录最后登录活跃时间;判断账号是否“正在使用”,用于登录前提示顶号风险
LicenseUtil 后端工具 调用 regInfo.exe;校验授权文件是否存在、机器码是否匹配、是否过期
SystemController / SystemService 后端控制器与服务 暴露 /sys/info、/sys/code、/sys/noNet、/sys/checkAuth 等授权相关接口
SystemInfo 数据模型 保存机器码、客户编号、单位、手机号、授权时间、过期时间、激活时间
LoginRequestFilter 网关过滤器 解析登录请求中的 username,改写 Accept-Encoding,避免后端 gzip 导致响应解析失败
LoginResponseFilter 网关过滤器 解析成功登录响应,把用户名和用户 ID 绑定到粘性节点,回填 x-token → userId 缓存
TokenResolver 网关组件 优先从网关 Redis 缓存解析 x-token,未命中时回查 Sa-Token 共享 Redis
StickyRouteFilter 网关过滤器 按用户名或 userId 选择节点;token 无效时降级为匿名轮询;绑定节点不可用时返回 503
UserRouteService 网关服务 用户路由的 Redis + 数据库两级缓存;并发首次绑定由数据库唯一索引裁决
useWatermark 前端 Hook 在页面注入水印 DOM,监听 MutationObserver 恢复被删除的水印元素,防止控制台直接隐藏

章节来源

  • SaTokenWebConfig.java:1-65
  • LoginActivityService.java:1-106
  • LicenseUtil.java:1-163
  • SystemController.java:1-200
  • SystemService.java:1-200
  • SystemInfo.java:1-71
  • LoginRequestFilter.java:1-120
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • StickyRouteFilter.java:1-180
  • UserRouteService.java:1-117
  • useWatermark.ts:1-205

架构总览

下图展示一次带授权的登录与后续请求的关键路径:

sequenceDiagram
    participant FE as "前端"
    participant GW as "Gateway"
    participant BE as "后端服务"
    participant RT as "StickyRouteFilter"
    participant LR as "LoginResponseFilter"
    participant TR as "TokenResolver"
    participant UR as "UserRouteService"
    participant SA as "Sa-Token"
    participant RS as "Redis"

    FE->>GW: POST 登录请求<br/>含 username
    GW->>GW: LoginRequestFilter 解析 username
    GW->>RT: StickyRouteFilter 按用户名选节点
    RT-->>GW: 候选节点 ID
    GW->>BE: 转发登录请求
    BE->>SA: 创建会话,生成 token
    BE-->>GW: Result{code=200, data:{token, userId, username}}
    GW->>LR: LoginResponseFilter 解析成功登录响应
    LR->>UR: bindIfAbsent(serviceId, userId, username, nodeId)
    LR->>TR: cacheToken(serviceId, token, userId)
    LR-->>FE: 原样返回登录响应

    FE->>GW: 后续请求<br/>携带 x-token
    GW->>TR: resolveUserId(serviceId, token)
    TR->>RS: 查询 gateway:token:{serviceId}:{token}
    TR-->>GW: userId 或空
    GW->>RT: 按 userId 查找绑定节点
    RT-->>GW: 目标节点
    GW->>BE: 转发业务请求
    BE->>SA: StpUtil.checkLogin()
    BE-->>GW: 业务结果
    GW-->>FE: 返回响应

图示来源

  • LoginRequestFilter.java:1-120
  • StickyRouteFilter.java:1-180
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • UserRouteService.java:1-117
  • SaTokenWebConfig.java:1-65

详细组件分析

Sa-Token 会话与会话释放机制

Sa-Token 在后端只做登录检查,不做角色权限注解扫描。所有已登录请求都会进入拦截器,调用 StpUtil.checkLogin(),同时记录一次用户活跃。白名单包括 /auth/login、/sys/health、/sys/checkAuth、/sys/info、/sys/code、/sys/noNet、地图资源等,避免启动健康检查和授权检查被拦截。

活跃度由 LoginActivityService 用 Redis 维护,key 形如 zsjz:auth:last-active:{userId},TTL 等于“半小时内是否在使用”窗口。每次写 Redis 会按用户节流,默认每分钟最多一次。判断“正在使用”的条件是:Sa-Token 中仍存在该用户的 token,且 Redis 活跃 key 存在。

单端登录场景下,后登录会顶掉前一个会话。LoginActivityService.isInUse 会在登录前检查:若同一账号已有有效 token 且在窗口内活跃过,则提示用户确认继续登录。旧会话被顶下线后,后端需要释放其全部案件数据源,避免残留上下文污染新会话。

flowchart TD
    Start["登录请求到达后端"] --> CheckActive["检查账号是否正在使用"]
    CheckActive --> HasToken{"Sa-Token 中仍有该用户 token?"}
    HasToken -->|否| AllowLogin["允许登录,不弹顶号提示"]
    HasToken -->|是| CheckRedis{"Redis 活跃 key 是否存在?"}
    CheckRedis -->|否| AllowLogin
    CheckRedis -->|是| PromptConfirm["提示用户:账号正在使用,是否继续登录"]
    PromptConfirm --> ConfirmYes{"用户确认继续登录?"}
    ConfirmYes -->|是| ForceLogin["强制登录并顶掉旧会话"]
    ConfirmYes -->|否| CancelLogin["取消登录"]
    ForceLogin --> ReleaseOld["释放旧会话的全部案件数据源"]
    ReleaseOld --> End["完成"]
    AllowLogin --> End
    CancelLogin --> End

图示来源

  • SaTokenWebConfig.java:1-65
  • LoginActivityService.java:1-106

章节来源

  • SaTokenWebConfig.java:1-65
  • LoginActivityService.java:1-106

Gateway 中 token 与 username 解析一致性

Gateway 对登录请求和普通请求采用不同但互补的解析方式:

阶段 输入 解析内容 目的
登录请求预处理 POST 登录体 username 用于粘性路由选择候选节点
登录响应后处理 Result{data:{token, userId, username}} token、userId、username 绑定用户到节点,回填 token 缓存
普通请求 请求头 x-token userId 按 userId 查找绑定节点
粘性路由 userId 或 username 目标节点 ID 重写请求 URL,使同用户始终访问同一后端节点

LoginRequestFilter 只取 JSON 中的 username 字段,最大体大小为 64KB。解析失败不会阻断登录,而是按匿名轮询,由后端自行校验。LoginResponseFilter 只处理成功登录响应,并把 gateway:token:{serviceId}:{token} → userId 写入 Redis,TTL 为 24 小时。

TokenResolver.resolveUserId 先读网关 Redis,再回查 Sa-Token;解析不出 token 时返回空,由 StickyRouteFilter 按匿名处理,最终透传后端 code=401,引导重新登录。这样既兼容网关上线前已签发的 token,也避免因网关缓存不一致导致登录失败。

flowchart TD
    Req["请求进入 Gateway"] --> IsLogin{"是否登录路径?"}
    IsLogin -->|是| ParseUsername["解析 body.username"]
    ParseUsername --> RouteLogin["StickyRouteFilter 按用户名选节点"]
    IsLogin -->|否| ReadToken["读取 x-token"]
    ReadToken --> ResolveToken["TokenResolver 解析 userId"]
    ResolveToken --> RouteNormal["StickyRouteFilter 按 userId 选节点"]
    RouteLogin --> PostLogin["LoginResponseFilter 解析成功登录响应"]
    PostLogin --> BindNode["UserRouteService.bindIfAbsent"]
    PostLogin --> CacheToken["TokenResolver.cacheToken"]
    RouteNormal --> Forward["转发到后端节点"]
    BindNode --> Forward
    CacheToken --> Forward

图示来源

  • LoginRequestFilter.java:1-120
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • StickyRouteFilter.java:1-180
  • UserRouteService.java:1-117

章节来源

  • LoginRequestFilter.java:1-120
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • StickyRouteFilter.java:1-180
  • UserRouteService.java:1-117

regInfo.exe 许可证校验流程

后端通过 LicenseUtil 调用平台授权工具,Windows 上走 cmd.exe /c,Linux 上走 sh -c。授权校验流程如下:

  1. 检查授权文件是否存在。
  2. 调用授权工具执行 -f <license.xlts> -verify。
  3. 根据工具返回码映射状态:
    • 0:授权验证成功。
    • 10001:授权文件无效。
    • 10002:系统授权已过期。
    • 10003:机器码不匹配。
    • 其他:未授权。
  4. 校验结果带 60 秒 TTL 缓存,避免频繁拉起子进程。

离线授权上传由 SystemService.noNet 处理:

  1. 校验上传文件非空且后缀为 .xlts。
  2. 覆盖写入 PathConst.LICENSE_PATH_FILE。
  3. Linux 环境下执行授权文件权限修改。
  4. 再次调用 LicenseUtil.checkAuthStatus() 校验授权。
  5. 调用授权工具 read <license.xlts> 解析授权信息。
  6. 将客户编号、机器码、单位、手机号、激活时间、授权时间、过期时间写入 system_info 表。

    flowchart TD
    Upload["前端上传 .xlts 授权文件"] --> ValidateExt["后端校验文件后缀"]
    ValidateExt --> SaveFile["写入 license.xlts"]
    SaveFile --> Chmod["Linux 环境 chmod 授权文件"]
    Chmod --> Verify["LicenseUtil.checkAuthStatus()"]
    Verify --> Code{"返回码"}
    Code -->|0| Success["授权成功"]
    Code -->|10001| FileInvalid["授权文件无效"]
    Code -->|10002| Expired["系统授权已过期"]
    Code -->|10003| MachineMismatch["机器码不匹配"]
    Success --> ReadAuth["读取授权信息"]
    ReadAuth --> Persist["写入 system_info 表"]
    

图示来源

  • LicenseUtil.java:1-163
  • SystemService.java:146-179
  • LicenseErrorEnum.java:1-62
  • SystemInfo.java:1-71

章节来源

  • LicenseUtil.java:1-163
  • SystemService.java:146-179
  • LicenseErrorEnum.java:1-62
  • SystemInfo.java:1-71

前端授权信息与水印写入

前端授权入口页负责:

  • 获取机器码:调用后端 /sys/code,显示并支持复制。
  • 选择 .xlts 授权文件:仅接受 .xlts 后缀。
  • 离线授权:调用后端 /sys/noNet,成功后刷新授权摘要并按登录态跳转。
  • 授权状态判断:调用 /sys/checkAuth 与 /sys/info,结合 isAuthorizationExpired 判断是否过期。
  • 授权信息弹窗:展示客户编号、单位、联系电话、授权有效期、机器码等。

授权单元信息来自后端返回的系统信息。前端 extractAuthorizationCache 会提取 unit、overdatetime、授权标签等信息,并用于界面展示和过期判断。水印则由 useWatermark 注入 DOM,文本通常包含当前用户标识和单位信息,用于防泄露与审计。

flowchart TD
    OpenPage["打开授权入口页"] --> GetMachineCode["调用 /sys/code 获取机器码"]
    GetMachineCode --> SelectFile["选择 .xlts 授权文件"]
    SelectFile --> UploadLicense["调用 /sys/noNet 上传授权文件"]
    UploadLicense --> RefreshCache["刷新授权缓存"]
    RefreshCache --> CheckExpired["判断 overdatetime 是否过期"]
    CheckExpired --> ShowModal["授权信息弹窗展示 unit、过期时间等"]
    ShowModal --> WriteWatermark["使用授权单位与用户信息写入 watermark"]

图示来源

  • authorization/index.vue:489-608
  • authorization/index.vue:508-579
  • license.ts:1-124
  • AuthorizationInfoModal.vue:1-200
  • useWatermark.ts:1-205

章节来源

  • authorization/index.vue:489-608
  • authorization/index.vue:508-579
  • license.ts:1-124
  • AuthorizationInfoModal.vue:1-200
  • useWatermark.ts:1-205

本地文件保护与 Electron 安全边界

授权文件 .xlts 由后端写入服务器文件系统,而不是由浏览器直接写入本机路径。Web 化后,前端通过 <input type="file"> 选择本机文件,再由后端接收并落盘,避免前端直接操作服务端路径。

Electron 应用中,主进程拥有更高权限,渲染进程应仅持有最小必要信息。水印、用户信息、授权摘要等属于渲染层展示数据,不应成为持久化敏感凭证的唯一来源。敏感配置和环境变量应优先由部署侧注入,而不是硬编码在前端包或源码中。

水印本身不是强安全控制措施,而是可视化审计手段。useWatermark 使用 MutationObserver 监听 DOM 变更,防止控制台直接删除水印元素,但这不能替代后端鉴权。

章节来源

  • SystemService.java:146-179
  • authorization/index.vue:540-579
  • useWatermark.ts:1-205

依赖关系分析

Gateway 与后端之间的关键依赖如下:

依赖 方向 说明
Gateway → TokenResolver 内部 解析 x-token → userId
Gateway → UserRouteService 内部 查询或绑定 userId/username → nodeId
Gateway → StickyRouteFilter 内部 按用户名或 userId 选择节点并重写 URL
Gateway → LoginResponseFilter 内部 解析登录响应,回填 token 缓存
Gateway → Backend 网络 转发登录和业务请求
Backend → Sa-Token 内部 检查登录、生成 token
Backend → Redis 外部 活跃会话标记、路由缓存、token 缓存
Backend → LicenseUtil 内部 调用授权工具
Backend → SystemInfoMapper 内部 读写授权元数据
Frontend → Backend API 网络 机器码、授权文件上传、授权信息
Frontend → useWatermark 内部 渲染层水印注入
graph LR
    Frontend["前端授权入口"] --> BackendAPI["后端授权接口"]
    Frontend --> WatermarkHook["useWatermark"]
    GatewayReq["Gateway 登录请求"] --> UsernameParse["LoginRequestFilter"]
    UsernameParse --> Sticky["StickyRouteFilter"]
    Sticky --> BackendReq["后端登录接口"]
    BackendResp["后端登录响应"] --> RespFilter["LoginResponseFilter"]
    RespFilter --> TokenCache["TokenResolver.cacheToken"]
    RespFilter --> RouteBind["UserRouteService.bindIfAbsent"]
    NormalReq["Gateway 普通请求"] --> TokenResolve["TokenResolver.resolveUserId"]
    TokenResolve --> Sticky

图示来源

  • LoginRequestFilter.java:1-120
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • StickyRouteFilter.java:1-180
  • UserRouteService.java:1-117
  • authorization/index.vue:508-579
  • useWatermark.ts:1-205

章节来源

  • LoginRequestFilter.java:1-120
  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • StickyRouteFilter.java:1-180
  • UserRouteService.java:1-117
  • authorization/index.vue:508-579
  • useWatermark.ts:1-205

性能与可用性特性

  • 授权校验缓存:LicenseUtil.checkAuthStatus 使用 60 秒 TTL,避免频繁拉起外部进程。
  • 登录活跃度节流:LoginActivityService.touch 每个用户每分钟最多写一次 Redis,降低 Redis 压力。
  • 路由缓存:UserRouteService 使用 Redis 热缓存,读失败回源数据库,写失败不阻断正确性。
  • Token 缓存:TokenResolver 使用 24 小时 TTL,避免每个请求都查 Sa-Token。
  • 登录响应后处理:绑定和缓存写入为 fire-and-forget,不影响本次登录响应。
  • 命令输出截断:授权工具 stdout/stderr 最多读取 10 行,避免日志膨胀。

这些设计使系统在授权校验、会话活跃判断、用户路由和 token 解析上具备一定容错能力,但也意味着异常路径需要结合日志定位。

章节来源

  • LicenseUtil.java:1-163
  • LoginActivityService.java:1-106
  • UserRouteService.java:1-117
  • TokenResolver.java:1-100
  • LoginResponseFilter.java:1-176

故障排查指南

许可证失效

现象

  • 前端提示授权无效、系统授权已过期或机器码不匹配。
  • 离线授权后仍无法进入业务页。

排查步骤

  1. 检查授权文件是否为 .xlts 后缀。
  2. 查看后端日志中是否出现“授权文件不存在”“授权文件无效”“系统授权已过期”“机器码不匹配”。
  3. 确认 regInfo.exe 是否在 Windows 或 Linux 上可用,路径是否正确。
  4. 检查 Linux 环境授权文件权限是否可被后端读取。
  5. 对比 SystemInfo 中的机器码、过期时间与授权单位是否与预期一致。
  6. 前端重新调用 /sys/checkAuth 和 /sys/info,确认返回状态。

相关实现

  • LicenseUtil.java:1-163
  • SystemService.java:146-179
  • LicenseErrorEnum.java:1-62
  • authorization/index.vue:540-608

token 不一致或登录后仍提示未登录

现象

  • 登录成功后,部分请求返回 401。
  • 切换后端节点或重启 Gateway 后出现短暂未登录。

排查步骤

  1. 确认登录响应中是否包含 token、userId、username。
  2. 检查 Gateway 是否成功解析登录响应并完成绑定。
  3. 查看 Redis 中是否存在 gateway:token:{serviceId}:{token}。
  4. 如果缓存缺失,确认 TokenResolver 是否能回查 Sa-Token。
  5. 检查 StickyRouteFilter 是否因 token 为空而按匿名轮询。
  6. 检查后端 Sa-Token 中是否仍存在该用户的会话。

相关实现

  • LoginResponseFilter.java:1-176
  • TokenResolver.java:1-100
  • StickyRouteFilter.java:1-180
  • SaTokenWebConfig.java:1-65

账号被顶下线或踢下线

现象

  • 用户 A 正在操作时,用户 B 用同一账号登录,A 被顶下线。
  • 登录后立即看到“账号正在使用”提示。
  • 旧会话的数据源未释放,新会话受影响。

排查步骤

  1. 检查 Redis 中 zsjz:auth:last-active:{userId} 是否存在。
  2. 检查 Sa-Token 中是否仍存在该用户的 token。
  3. 确认登录流程是否调用了 LoginActivityService.isInUse。
  4. 确认顶下线后是否释放了旧会话的全部案件数据源。
  5. 检查多节点部署下活跃标记是否按节点各自节流,但语义仍正确。

相关实现

  • LoginActivityService.java:1-106
  • SaTokenWebConfig.java:1-65

前端水印未显示或被控制台隐藏

现象

  • 页面没有水印。
  • 控制台手动删除水印 DOM 后,水印没有恢复。

排查步骤

  1. 检查前端是否调用了水印 Hook。
  2. 检查页面 DOM 中是否存在水印容器。
  3. 检查 MutationObserver 是否正常监听节点变化。
  4. 确认水印文本是否包含预期的单位和用户信息。
  5. 注意水印只是展示层防护,不能作为后端鉴权依据。

相关实现

  • useWatermark.ts:1-205
  • AuthorizationInfoModal.vue:1-200

结论

本项目在安全与授权方面形成多层防护:后端通过 Sa-Token 控制会话,通过 Redis 记录活跃会话,通过 LicenseUtil 调用授权工具校验机器码与过期时间;Gateway 通过 LoginRequestFilter、LoginResponseFilter、TokenResolver 和 StickyRouteFilter 保证用户名、userId 与节点绑定的稳定性;前端通过授权入口页、授权信息弹窗和水印提供可观测性。

实际运行中,最关键的保障点是:

  • 授权文件必须存在且机器码匹配。
  • 登录响应必须能被 Gateway 解析并回填 token 缓存。
  • 普通请求必须携带有效的 x-token。
  • 账号顶下线后必须释放旧会话数据源。
  • 水印只能作为辅助审计手段,不能替代后端鉴权。

当出现授权失效、token 不一致或账号被顶下线问题时,应优先检查 Redis 状态、Gateway 绑定日志、后端授权工具返回值,以及前端授权缓存是否刷新。