本文引用的文件
本文面向 Sa-Token 会话管理、Gateway 用户路由一致性、离线授权流程以及 Electron 本地安全边界,给出可操作的实现说明和排查方法。重点覆盖:
username 解析、登录响应的 token/用户名绑定、普通请求的 x-token 解析一致性。regInfo.exe(由后端通过 LicenseUtil 调用)的机器码、过期时间、单位信息校验流程。本仓库包含网关、后端服务、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"]
图示来源
章节来源
本节从安全视角梳理关键类及其职责。
| 组件 | 类型 | 安全职责 |
|---|---|---|
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 恢复被删除的水印元素,防止控制台直接隐藏 |
章节来源
下图展示一次带授权的登录与后续请求的关键路径:
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: 返回响应
图示来源
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
图示来源
章节来源
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
图示来源
章节来源
后端通过 LicenseUtil 调用平台授权工具,Windows 上走 cmd.exe /c,Linux 上走 sh -c。授权校验流程如下:
-f <license.xlts> -verify。0:授权验证成功。10001:授权文件无效。10002:系统授权已过期。10003:机器码不匹配。离线授权上传由 SystemService.noNet 处理:
.xlts。PathConst.LICENSE_PATH_FILE。LicenseUtil.checkAuthStatus() 校验授权。read <license.xlts> 解析授权信息。将客户编号、机器码、单位、手机号、激活时间、授权时间、过期时间写入 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 表"]
图示来源
章节来源
前端授权入口页负责:
/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"]
图示来源
章节来源
授权文件 .xlts 由后端写入服务器文件系统,而不是由浏览器直接写入本机路径。Web 化后,前端通过 <input type="file"> 选择本机文件,再由后端接收并落盘,避免前端直接操作服务端路径。
Electron 应用中,主进程拥有更高权限,渲染进程应仅持有最小必要信息。水印、用户信息、授权摘要等属于渲染层展示数据,不应成为持久化敏感凭证的唯一来源。敏感配置和环境变量应优先由部署侧注入,而不是硬编码在前端包或源码中。
水印本身不是强安全控制措施,而是可视化审计手段。useWatermark 使用 MutationObserver 监听 DOM 变更,防止控制台直接删除水印元素,但这不能替代后端鉴权。
章节来源
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
图示来源
章节来源
LicenseUtil.checkAuthStatus 使用 60 秒 TTL,避免频繁拉起外部进程。LoginActivityService.touch 每个用户每分钟最多写一次 Redis,降低 Redis 压力。UserRouteService 使用 Redis 热缓存,读失败回源数据库,写失败不阻断正确性。TokenResolver 使用 24 小时 TTL,避免每个请求都查 Sa-Token。这些设计使系统在授权校验、会话活跃判断、用户路由和 token 解析上具备一定容错能力,但也意味着异常路径需要结合日志定位。
章节来源
现象
排查步骤
.xlts 后缀。regInfo.exe 是否在 Windows 或 Linux 上可用,路径是否正确。SystemInfo 中的机器码、过期时间与授权单位是否与预期一致。/sys/checkAuth 和 /sys/info,确认返回状态。相关实现
现象
排查步骤
token、userId、username。gateway:token:{serviceId}:{token}。TokenResolver 是否能回查 Sa-Token。StickyRouteFilter 是否因 token 为空而按匿名轮询。相关实现
现象
排查步骤
zsjz:auth:last-active:{userId} 是否存在。LoginActivityService.isInUse。相关实现
现象
排查步骤
相关实现
本项目在安全与授权方面形成多层防护:后端通过 Sa-Token 控制会话,通过 Redis 记录活跃会话,通过 LicenseUtil 调用授权工具校验机器码与过期时间;Gateway 通过 LoginRequestFilter、LoginResponseFilter、TokenResolver 和 StickyRouteFilter 保证用户名、userId 与节点绑定的稳定性;前端通过授权入口页、授权信息弹窗和水印提供可观测性。
实际运行中,最关键的保障点是:
x-token。当出现授权失效、token 不一致或账号被顶下线问题时,应优先检查 Redis 状态、Gateway 绑定日志、后端授权工具返回值,以及前端授权缓存是否刷新。