任何人连上就能看卫星遥测?我们花一天补上了 WebSocket 鉴权与三层 RBAC

先想象一个画面:你的卫星星座平台正在对外服务,数据接入链路通过 WebSocket 实时推送。这时,任何一个能访问到服务的人——不需要账号、不需要 token——只要连上 WebSocket 地址,就能安静地订阅走这些实时数据。而系统对此毫无察觉——因为大门本来就是开着的。

这不是假设,这是我们平台此前的真实状态。

上期《86 个 Lint 错误清零 + 空库一键迁移》把卫星平台 2.0 的地基修平(M0)。本期施工周记(二)进入 M1 上半场,直面上期盘点里最扎眼的两项缺陷:

  • E4:WebSocket 无鉴权——任何能连上总线的人,都能订阅平台的实时数据推送;
  • E5:权限体系未闭环——只要登录成功,人人都是"全能选手"。

我们用一天时间完成四连击:先用 Playwright 织一张 10 条用例的 E2E 测试网(端到端测试:像真实用户一样操作浏览器)兜住回归,再给 WebSocket 补上 STOMP 协议层 JWT 鉴权,然后把用户管理从后端接口到前端按钮级 RBAC(基于角色的权限控制:谁能看到什么、能点什么)一次闭环,最后顺带修复了一个"埋了很久"的角色鉴权 Bug。全程真实数据,文末附可直接抄作业的踩坑笔记——建议先收藏,再慢慢看

一、把"能用"升级成"可信"

上期排查出的 14 项工程缺陷中,有四项直接指向"安全与回归保障",本期逐项对账:

#上期盘点缺陷本期对策结果
E4WebSocket 无鉴权(/ws/** 全放行)STOMP CONNECT 帧校验 JWT4 类非法连接全部拒绝,单测 7/7
E5前端路由仅登录态守卫,无角色权限菜单 / 路由 / 按钮三层 RBAC + 后端注解收紧权限全链路闭环,E2E 2 条专项通过
E2后端零测试(历史欠账)WS 拦截器 + 用户管理服务单测后端 mvn test 14/14 全绿
安全改造无自动化回归保障Playwright E2E 测试网10 条用例(7 条 mock + 3 条真后端)

本期只守一条原则:先设计,再改造。鉴权改造是"动身份、动路由"的高危操作,先把改动纳入自动化回归网,再动核心链路——这是改完还敢睡觉的底气。

image.png

二、先设计:Playwright E2E 测试网(10 条用例)

2.1 7 条 mock 用例:不依赖后端的"随时全绿"

前端 E2E 最大的落地障碍是"环境依赖":CI 上未必有后端和数据库。我们的解法是在浏览器网络层 mock 全部 /api 请求e2e/fixtures.ts 提供兜底 mock + 具体接口 mock + 登录态预置),playwright.config.ts 里的 webServer 自动拉起 vite dev(前端开发服务器),端口可用 E2E_PORT 覆盖避让冲突。

覆盖矩阵——5 个页面 7 条用例:

用例断言要点
登录成功跳转仪表盘 + token 写入 localStorage
登录失败错误提示可见 + 停留登录页
仪表盘加载4 张统计卡片渲染出 mock 数值
卫星列表 + 关键字筛选行数收敛 + 命中目标卫星
遥测页加载5 张实时指标卡片(%、W、dB 单位逐一校验)
用户管理(ADMIN 视角)菜单可见 → 列表渲染 → 写操作按钮可见
用户管理(OPERATOR 视角)菜单不可见 → 直连 URL 被重定向回仪表盘

结论:CI 无需后端、无需数据库,npm run test:e2e 即可全绿。接真实后端时去掉 mock 即可复用。

2.2 3 条真后端联调:零 mock,期望值实时取自 API

mock 能保证"UI 逻辑不坏",但保证不了"前后端契约不漂"。我们补了一套 real-backend.spec.ts(默认跳过,E2E_REAL_BACKEND=1 显式开启,不拖累 CI):零 mock,请求经 vite 代理直达本机 8080 后端,所有期望值实时取自 API,不写死一条数据

联调当天就抓到一个"反直觉"的契约事实:登录失败不是 HTTP 401,而是 HTTP 200 + 响应体内 code: 401——因为业务异常走统一响应包装,HTTP 状态码仍是 200,前端真正的分支判断在业务码上。

处理方式:mock 用例保留 HTTP 401 形态,反向覆盖 axios 异常分支(HTTP 层报错路径);真后端用例断言业务码分支。两套断言、两条代码路径,全部覆盖。

2.3 最值钱的坑:一个通配符让整个应用白屏

这是本批次最"贵"的一个坑,值得单独拿出来讲:

Playwright 的 route 匹配不要用 glob 通配 /api/ 前缀。用 glob 拦截 **/api/** 时,会连带命中 vite dev 服务器的源码模块请求 /src/api/*.ts——你的拦截器"好心"把这些源码请求也一并拦截、返回了 JSON,浏览器对模块做 MIME 类型校验对不上号,整个应用直接白屏

正解:用 URL 谓词精确锚定根路径——page.route((url) => url.pathname.startsWith('/api/'), ...)。另有一条路由规则:Playwright 中后注册的 route 先匹配,所以兜底 mock 必须先注册、具体接口 mock 后注册,顺序反了就全部被兜底吞掉。

2.4 结果

text
复制代码
默认(CI 路径):7 passed + 3 skipped 联调模式: 3 passed(零 mock 直连真实后端) npm run lint → 0 error vue-tsc → 0 error

image.png

三、再改造(上):WebSocket 鉴权——让数据总线"查证入场"

3.1 问题现场

SecurityConfig/ws/** 对所有请求放行:REST 接口有 JWT 过滤器把守,而 WebSocket 是一条不查证的后门——任何人拿到 SockJS 地址就能建立连接、订阅数据接入的实时推送。对卫星平台这类系统,"谁在听遥测"和"遥测本身"一样重要。

3.2 为什么鉴权不能做在 HTTP 握手层?

先回答一个高频疑问:为什么不直接在 WebSocket 握手上校验?因为浏览器原生 WebSocket / SockJS 握手无法携带自定义 Authorization——这是浏览器标准的硬限制,不是工程偷懒。

所以鉴权必须下沉到 STOMP 协议层(STOMP 是运行在 WebSocket 之上的消息协议,可以理解为"信封格式"):拦截会话建立帧(CONNECT / STOMP),读取信封上的原生头。核心实现只有三个动作:

java
复制代码
// WsAuthChannelInterceptor#preSend(节选) StompCommand command = accessor.getCommand(); if (command == StompCommand.CONNECT || command == StompCommand.STOMP) { String header = accessor.getFirstNativeHeader("Authorization"); if (!StringUtils.hasText(header) || !header.startsWith("Bearer ")) { throw new MessagingException("Unauthorized: missing bearer token"); // 连接被拒绝 } String token = header.substring("Bearer ".length()); Claims claims = jwtUtil.getClaimsFromToken(token); // 复用 REST 层同一把钥匙 // 注入 principal:ROLE_USER(兼容存量)+ ROLE_<role>(支撑细粒度校验) accessor.setUser(new UsernamePasswordAuthenticationToken(username, null, authorities)); }

三个设计要点:

  1. 非 CONNECT 帧直接透传——鉴权只做在"入场"这一刻,订阅/心跳帧零开销;
  2. 失败即抛 MessagingException——连接被拒绝并向客户端发送 ERROR 帧,不留"半开"状态;
  3. 给会话身份(principal)授予双权限ROLE_USER + ROLE_<role>,与 REST 层的权限模型完全对齐。

前端只改一行——STOMP 客户端连接头携带 token(重连时由 stompjs 自动附带):

ts
复制代码
this.client = new Client({ webSocketFactory: () => new SockJS(url), connectHeaders: { Authorization: `Bearer ${token}` }, // 新增 reconnectDelay: 3000, })

3.3 单测 7 条:把"拒绝"也当成功能来验收

#用例断言
1有效 token 携带角色principal 含双角色
2无 role claim 的有效 token仅 ROLE_USER
3缺 Authorization 头拒绝
4非 Bearer 前缀(如 Token xxx拒绝
5畸形 token(Bearer not-a-jwt拒绝
6过期 token拒绝
7非 CONNECT 帧(如 SUBSCRIBE)原样透传、零开销

工程视角:"拒绝"必须是可测试的功能,而不是靠"应该不会出事"的侥幸。4 类非法连接全部有单测背书,才算真正关上了这扇门。

四、再改造(下):用户管理全栈 + 三层 RBAC

4.1 后端:5 个接口 + 两条安全红线

新增 UserController,5 个接口覆盖用户全生命周期:

接口能力权限
GET /users分页 + 关键字/角色/状态筛选登录即可
POST /users新建用户ADMIN
PUT /users/{id}编辑资料ADMIN
PUT /users/{id}/password重置密码(BCrypt 重编码)ADMIN
PUT /users/{id}/status启用 / 停用ADMIN

两条安全红线:

  1. 写操作全部挂 @PreAuthorize("hasRole('ADMIN')")——非管理员调用直接 403(统一异常映射);
  2. 密码从不出现在响应里——BCrypt 编码入库;列表、创建、更新、启停用的返回对象统一置空 password 字段。防泄漏是"默认动作",不是"记得才做"。

4.2 意外收获:一个"埋了很久"的鉴权 Bug

为了让 hasRole('ADMIN') 真正生效,排查中发现了一个隐蔽的历史 Bug:

JWT 的负载(claims)里一直带着 role,但 JwtAuthenticationFilter 只授予了 ROLE_USER 也就是说:系统此前只认得"你登录了",不认得"你是谁"。所有细粒度权限注解写了也白写——不是"没权限被放行",而是"有权限也进不去"

这是一种典型的 fail-secure 型 Bug:它不给攻击者开口子,最隐蔽、最容易被忽略;但一旦你想加细粒度权限,它会立刻"爆炸"——明明是该放行的管理员,却全场 403。

修复:单次解析 claims,一次授予 ROLE_USER + ROLE_<role> 双权限——既兼容存量接口的 hasRole('USER'),又让 hasRole('ADMIN') 真正生效。WebSocket 拦截器采用同一套授权模型,REST 与 WS 两条链路"一把尺子"。

4.3 前端:一张管理页 + 三层防线

新增 views/system/UserList.vue(500+ 行):关键字/角色/状态三重筛选 + 分页表格 + 新建/编辑对话框(角色、部门、联系方式)+ 重置密码对话框(双输入一致性校验)+ 启停用开关(先调接口、成功才翻转,失败保持原状态,不留"界面说停了、后端还开着"的假象)。

image.png

image.png RBAC 做了三层:

mermaid
复制代码
flowchart TB A["登录 → JWT 携带 role"] --> B["第 1 层 · 菜单:MainLayout 按角色过滤渲染<br/>OPERATOR 根本看不到『用户管理』入口"] B --> C["第 2 层 · 路由:meta.roles + 守卫拦截<br/>直连 /system/users 会被弹回仪表盘"] C --> D["第 3 层 · 元素:v-permission 指令<br/>角色不匹配的写按钮直接从 DOM 移除"] D --> E["边界 · 后端:@PreAuthorize('hasRole(ADMIN)')<br/>绕过前端直接调 API?照样 403"]

配套改造:stores/user.ts 新增 hasRole(兼容单值/数组;角色经 localStorage 持久化,刷新后权限保持);路由 meta.roles 类型扩展 + 守卫;i18n 约 45 个文案键中英双语同步。

4.4 一句话记住职责边界

前端三层管"体验",后端注解管"边界"。 菜单过滤、路由拦截、按钮隐藏都只是提高体验、减少误操作;即使有人绕过前端直接调 API,@PreAuthorize 依然兜底。E2E 用两个角色视角把这条链路钉死:ADMIN 可见可用,OPERATOR 不可见、直连被弹回。

image.png

五、附赠:Flyway"既有库收编"——一次诚实的决策修订

上期我们把 baseline-on-migrate=false 称为"关键决策:不对非空库假装已迁移"。本期就撞上了它的现实代价:

本地历史库(结构由 V1 脚本手工重放建成、等价基线终态)没有 flyway_schema_history,启动直接报错:

text
复制代码
Found non-empty schema(s) "public" but no schema history table

修订方案(一次深思熟虑的"打脸"):

yaml
复制代码
spring: flyway: baseline-on-migrate: true # 从 false 改为 true baseline-version: 1

两种场景被分开处理:

场景行为
空库照常执行 V1(原有路径不变)
既有库仅建历史表并把现有结构登记为 V1 基线,不重放 V1,数据零改动

这不是摇摆,而是把"默认拒绝"演进为"核实后可通行":M0 的 false 是防止未经核对的库被静默收编;现在改为 true,是因为明确知道该库就是 V1 的重放产物、结构可核对。工程决策要跟着事实走,而不是跟着面子走

flyway验证.png

六、验收:数据说话

验收项命令 / 方法结果
后端单元测试mvn test14/14 全绿(WS 7 + 用户 7)
WS 未授权连接4 类拒绝场景单测全部拒绝
E2E(默认 mock 路径)npm run test:e2e7 passed + 3 skipped
E2E(真实后端联调)E2E_REAL_BACKEND=13 passed(零 mock 直连)
RBAC 双视角E2E(ADMIN / OPERATOR)菜单、路由、按钮三层验证通过
前端质量门禁npm run lint / vue-tsc --noEmit0 error

七、踩坑笔记(7 条,可直接抄作业,建议收藏)

  1. Playwright route 用 URL 谓词,别用 glob。glob 通配 /api/ 会误伤 vite 源码模块 /src/api/*.ts,把源码请求当接口返回 JSON → 应用白屏。
  2. route 注册顺序即优先级:后注册先匹配——兜底 mock 先注册、具体接口 mock 后注册。
  3. 统一响应包装下,"登录失败"是 HTTP 200 + code:401。mock 断言别照抄直觉;建议 mock 覆盖异常分支、联调覆盖业务码分支,两条路径都测。
  4. WS 鉴权必须下沉到 STOMP 协议层——浏览器 WebSocket/SockJS 握手无法携带自定义头,这是标准硬限制。
  5. 鉴权链路的"隐性断点":token 里带了 role ≠ 过滤器授予了 role。凡是"角色没生效",先查授权对象(principal)里的 authorities,再查注解。
  6. 前端权限只做体验,边界必须在后端注解——纵深防御的正确姿势,而不是"前端藏了按钮就等于没有权限"。
  7. baseline-on-migrate 的两种语义要分清false = 拒绝未知非空库;true + baseline-version = 核实后收编。改动前想清楚自己的库属于哪种。

八、下期预告:M1 收官之战

  • 遥测导出:流式 CSV / JSON 输出防 OOM(内存溢出),导出文件与页面数据抽样一致;
  • 审计日志@AuditLog 注解 + AOP 切面,写操作自动留痕(谁、何时、对什么、结果如何);
  • M1 DoD(完成定义)验证:全量回归 + 覆盖率门禁 + 权限与审计逐项核对;
  • 再往后是 M2 业务深化:遥测分区、告警引擎、SGP4 轨道预报……

九、结语

上期最值得截图的是三行绿色输出;本期最值得截图的是两个画面:

一个是 OPERATOR 视角的侧边栏——找不到"用户管理",直连 URL 也会被礼貌地弹回仪表盘;另一个是 未授权 WebSocket 连接被拦截器拒之门外。安全最好用的样子,就是"该来的畅通无阻,不该来的连门都摸不到"。

从"功能齐全"到"工程可靠",再到"安全可信",跨越的从来不是代码量,而是把每一条安全约束变成默认动作的纪律:拒绝要可测试、密码永不出响应、权限有三层、每一次改动都有回归网。

施工还在继续,下期 M1 收官见。

📌 觉得这篇实战记录有价值?点赞 + 在看 + 转发 + 收藏,让更多航天与软件工程师看到! 💬 留言聊聊:你们的 WebSocket 鉴权做在哪一层?有没有被"JWT 里有角色、注解却不生效"坑过?


本文关键词:卫星平台 2.0 | WebSocket 鉴权 | STOMP | JWT | RBAC | 按钮级权限 | Playwright E2E | Spring Boot | Vue 3 | 工程化实战

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP