教学演示 Mock(三仓 + Python 模拟工具)
同一极简业务的改造前 / 改造后对照教学:before 演示传统账密 + 对称 JWT 的问题, after 演示用 QXJ SDK 接入国密认证体系(NSP-SM token + SM4-GCM 信封)。 另有纯静态 GitHub Pages 演示仓,以及在 PC 上完整模拟鸿蒙平板的 Python 测试工具。
总览:before / after 对比
| 对比维度 | before(传统方案) | after(QXJ SDK 国密方案) |
|---|---|---|
| token 签发 | 业务后端自己用 SECRET_KEY 签 HS256 JWT(PyJWT),对称密钥 | 业务后端不签 token,由独立认证服务器签 NSP-SM JWT,后端只配 SM2 公钥验签 |
| token 结构 | 单层 JWT | 双层:外层 SM2withSM3 签名(认证服务器私钥签);内层 SM3-HMAC(设备会话密钥, device_id + user_id + exp) |
| token 存储 | access/refresh 双 token 直接存网页 localStorage,页面底部实时可见,控制台一句 localStorage.getItem 即可偷走 | 网页不保存任何 token;access/refresh/K_client 只存 Pad 原生安全区,网页发请求时经 JSBridge 临时取 access 到内存 |
| 业务报文 | 明文 JSON | 64 字节帧头 + SM4-GCM 密文 + 16 字节 GCM tag,网络上只见密文信封 hex |
| 登录方式 | 业务页内嵌登录表单,POST 账密换 token | 业务页无登录表单:未登录整页跳鉴权登录页,登录后跳回,URL 不携带 token |
| 会话密钥 | 无此概念 | SM2 四步握手协商,写入 Redis 按 device_id 共享给后端 |
| 暴露的问题 | localStorage 被 XSS 偷 token、对称密钥无法跨系统分发信任、样板代码、明文报文等 5 个问题点 | 针对以上逐项给出国密体系解法 |
qxj-backend-mock-demo(Django,before/after)
同一个极简 Django 业务(登录 + 需认证接口)的两份可直接运行代码。
| 项 | before | after |
|---|---|---|
| 建议端口 | 8100 | 8101 |
| Token | 自己用 SECRET_KEY 签 HS256 JWT(PyJWT) | 不签 token,用 qxj-backend-sdk 验证 NSP-SM |
| 依赖 | Django/DRF/cors/PyJWT,无 Redis | 本地 wheel vendor/qxj_backend_sdk-0.1.0-py3-none-any.whl + Redis + dotenv |
| 测试账号 | admin/admin123、guest/guest123 | 由 mock_pad / 真机带入 |
接口清单
before(端口 8100):
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/login/ | 账密登录,返回 access/refresh 双 token |
| POST | /api/refresh/ | 用 refresh 换新 access |
| GET | /api/profile/ | 需认证,返回当前用户信息 |
| GET | /api/resources/ | 需认证,返回演示资源列表 |
after(端口 8101):
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/profile/ | 需认证(NSP-SM Bearer),返回当前用户信息 |
| POST | /api/echo/ | 需认证 + SM4-GCM 信封加解密:收到 64B 密文信封 hex,解密后把明文再加密回传(回声) |
after 架构与快速启动
鸿蒙 Pad 客户端(原生安全区) 业务后端(本 demo after)
保存 access/refresh/K_client ──Bearer NSP-SM JWT──▶ verify_access_token()
完成 SM2 握手 / SM4-GCM 加解密 ──64B 密文信封 hex──▶ sm4_gcm_decrypt()
◀─64B 密文信封 hex── sm4_gcm_encrypt()
▲ ▲
│ 会话密钥/K_client 握手后写入 │ 按 device_id 从 Redis 读同一把密钥
└────────────── Redis ◀─────────────────┘2
3
4
5
6
7
环境准备:Python 3.8+(WSL qxj conda 环境验证过)、Redis(默认连接 redis://:Nsp123456!@127.0.0.1:6379/1,可用 after/.env 覆盖;默认 db=1、 key 前缀 qxj:mock:,不污染正式数据)、SDK wheel 随 demo 交付。
# before(传统方案,端口 8100)
cd before
pip install -r requirements.txt
python manage.py runserver 0.0.0.0:8100
# after(QXJ SDK 方案,端口 8101)
cd after
pip install -r requirements.txt # 会自动安装 vendor/ 下的 SDK wheel
cp .env.example .env # 按需修改 Redis 地址/前缀(默认值可直接用)
python tools/init_demo.py # 生成 SM2 密钥对 + 预置演示设备 + 自检
python manage.py runserver 0.0.0.0:81012
3
4
5
6
7
8
9
10
11
init_demo.py 会完成并自检:SM2 密钥对生成、设备会话密钥写入 Redis、 token verify_access_token 返回 code=0、SM4-GCM 加解密往返一致。
mock_pad app(仅浏览器演示,生产移除)
没有真机 Pad 时,用 HTTP 端点模拟真机 window.jsbridgeHandle 的 6 个原生方法, 同时模拟认证服务器签发 token:
| 原生方法 | mock 端点 |
|---|---|
| login | POST /mock/pad/login |
| getAccess | GET /mock/pad/token/access |
| refresh | POST /mock/pad/token/refresh |
| logout | POST /mock/pad/logout |
| encrypt / decrypt | POST /mock/pad/crypto/encrypt、POST /mock/pad/crypto/decrypt |
tokens.py 模拟认证服务器签发 NSP-SM JWT(外层 SM2 + custom.hmac); store.py 三类 key:SDK 约定会话密钥、mock:device、mock:pad_session (模拟 Pad 安全区存登录态,网页 localStorage 无 token)。 真实业务后端不应包含该 app——真机上这些动作发生在 Pad 原生侧与独立认证服务器。 config/urls.py 同时托管 frontend_dist/ SPA(排除 api/mock/pad/assets 的兜底)。
两种运行模式(after/.env)
| 模式 | A:浏览器 mock(默认) | B:真机联调 |
|---|---|---|
| QXJ_SERVER_PUBLIC_KEY | 不配 | 配真实公钥(128 hex) |
| 初始化 | 先 python tools/init_demo.py(演示密钥对 + 预置设备与会话密钥) | init_demo 检测到公钥自行 exit,避免误写演示数据 |
| Redis | db=1,前缀 qxj:mock: | db=0,前缀 qxj:1:(对齐正式后端 django-redis KEY_PREFIX=qxj + VERSION=1) |
| 闭环 | mock 登录 → 验签 → SM4-GCM 完整 | B1 局域网完整;B2 外网预期 -7/-8 |
真机联调时 QXJ_SERVER_PUBLIC_KEY 的获取:在运行 qxj-backend-admin 的服务器上执行 python -m tools.setup qrcode,取 server_id#timestamp#public_key 的第 3 段; 或管理员调 GET /api/v3/admin/server_keys/active_qrcode/。.env.example 给了两套模板:
- B1 局域网台式机 192.168.105.116(真实后端跑在本机 WSL,Redis db0 可达):
QXJ_REDIS_URL=...@127.0.0.1:6379/0、QXJ_REDIS_KEY_PREFIX=qxj:1:+ 真实公钥, 真机 token 验签、内层 HMAC、SM4-GCM 解密可完整闭环; - B2 外网 117.72.72.201(Redis 6379 外部不可达):同样配 db0 /
qxj:1:/ 真实公钥, 但只能验证真机登录页跳转、回跳、getAccess把 NSP-SM token 带到本后端的链路; 业务接口因读不到设备会话密钥返回认证失败(code -7/-8),属预期现象。
模式 B 下 /mock/pad/* 仍挂在 URL 上但无演示密钥可用(登录返回 JSON 错误), 真机网页不会调用它们;生产化时直接移除 mock_pad app。
接入自己的后端:只需两处
1) 认证(约 40 行,见 after/auth_app/qxj_auth.py) —— 认证类 QxjAccessTokenAuthentication,code=0 才通过,get_user_id 映射本地用户:
from qxj_backend_sdk import verify_access_token
device_id, expire_time, code = verify_access_token(
token,
settings.QXJ_SERVER_PUBLIC_KEY, # 认证服务器 SM2 公钥
redis_client,
key_prefix=settings.QXJ_REDIS_KEY_PREFIX,
)
# code == 0 完全通过;1 仅外层通过(设备未握手);负值为各类失败2
3
4
5
6
7
8
9
2) 加解密(见 after/auth_app/views.py):
from qxj_backend_sdk import sm4_gcm_encrypt, sm4_gcm_decrypt
plain, code = sm4_gcm_decrypt(frame_hex, redis_client, key_prefix=prefix)
frame_hex, code = sm4_gcm_encrypt(device_id, plain_text, redis_client, key_prefix=prefix)2
3
4
会话密钥 Redis key 约定:{key_prefix}sm2:device_session_key:{device_id}, 值为 16/32 字节会话密钥的 hex 字符串,由 SM2 握手流程(认证服务器侧)写入。
结果码速查
- token 验证:
0通过;1仅外层通过;-1空 /-2解析失败 /-3SM2 失败 /-4过期 /-5custom 缺失 /-6内层 HMAC 失败 /-7无会话密钥 /-8Redis 异常 - 加解密:
0成功;-3无会话密钥;-4格式错;-5GCM tag 校验失败;-9hex 格式错
qxj-frontend-mock-demo(Vue 3,before/after)
同一个极简 Vue3 页面(登录 + 认证接口 + 加密接口)的两份对照。
| 项 | before | after |
|---|---|---|
| 依赖 | Vue + axios | Vue + vue-router + axios + qxj-frontend-sdk(vendor tgz,file:../vendor/... 引用,sm-crypto 作为其依赖自动安装) |
| 凭据 | 双 token 存 localStorage(页面底部实时展示) | 网页不存 token,全走 SDK / JSBridge |
| 页面 | 单页:登录 → 资源面板 | HomeView + /mock-login(内置模拟登录页) |
| Vite base | 无 | base: '/qxj-frontend-mock/';proxy /api、/mock/(带尾斜杠)→ 后端 |
快速启动
先启动对应后端(before 对 8100 / after 对 8101),然后:
# before(对接后端 8100)
cd before
npm install
npm run dev # http://localhost:5200
# after(对接后端 8101)
cd after
npm install
npm run dev # http://localhost:52012
3
4
5
6
7
8
9
测试账号:admin/admin123、guest/guest123。
页面对比要点
- before:登录后页面底部「localStorage 实时内容」卡片能直接看到完整 access/refresh;控制台
localStorage.getItem('demo_before_access')即可取走; - after:业务页本身没有登录表单——未登录整页跳转到鉴权前端登录页 (真机是
qxj-frontend-admin的/m/login,浏览器演示是工程内置的/mock-login模拟页);登录完成后跳回本页,localStorage 始终为空; access 只在发请求时由jsBridgeGetAccess()临时取到内存;刷新页面后登录态 由客户端安全区恢复;加密接口页可看到网络上实际传输的只有 64B 密文信封 hex。
前端 JSBridge 调用方式
业务代码只与 SDK 打交道(src/api.ts):请求拦截每次 await jsBridgeGetAccess() 注入 Bearer;401 时 jsBridgeRefresh(并发去重)后重试原请求,失败派发 qxj-session-expired——refresh 只存在客户端安全区,网页全程看不到:
import { jsBridgeGetAccess, jsBridgeRefresh } from 'qxj-frontend-sdk'
// 请求前:向客户端安全区临时取用 access(网页不存 token)
http.interceptors.request.use(async (config) => {
const r = await jsBridgeGetAccess()
if (r.code === 0) config.headers.Authorization = `Bearer ${r.accessToken}`
return config
})
// 401:由客户端用安全区里的 refresh 换新 access(网页看不到 refresh)
// 刷新成功后重试原请求,失败则派发 qxj-session-expired2
3
4
5
6
7
8
9
10
11
登录 / 加解密 / 登出(jsBridgeLogin 由鉴权登录页调用,业务页只负责跳转):
import { jsBridgeLogin, jsBridgeEncrypt, jsBridgeDecrypt, jsBridgeLogout } from 'qxj-frontend-sdk'
await jsBridgeLogin(username, password) // 加密登录全在原生侧完成
const enc = await jsBridgeEncrypt(JSON.stringify(body)) // 明文 -> 64B 信封 hex
const { data } = await http.post('/api/echo/', { frame: enc.data })
const dec = await jsBridgeDecrypt(data.frame) // 信封 hex -> 明文
await jsBridgeLogout()2
3
4
5
6
7
登录页跳转协议(src/auth-flow.ts)
业务页未登录时只做一件事——整页跳转到鉴权登录页,并把回跳地址放在 redirect query 上(绝对 URL,带一次性 login=back 标记):
{登录页URL}?redirect={encodeURIComponent(业务页URL?login=back)}登录页调 jsBridgeLogin 完成加密登录,token 只进 Pad 原生安全区,然后整页跳回 redirect——URL 上不携带任何 token。业务页加载时通过 probeAccess() (经 jsBridgeGetAccess())恢复登录态,刷新页面同理。
- 真机:
{登录页URL}=VITE_LOGIN_PAGE_URL(如https://117.72.72.201:3006/m/login),由qxj-frontend-admin提供; 该登录页已支持外域redirect:以http(s)://开头时整页跳回, 不带 redirect 时维持原行为跳/m/home; - 浏览器 mock:跳工程内置
/mock-login模拟页(src/views/MockLoginView.vue, 独立路由不套业务主界面),同样调jsBridgeLogin,且仅允许同源redirect回跳。
mock-jsbridge(src/mock-jsbridge/index.ts)
普通浏览器没有 Pad 原生注入的 window.jsbridgeHandle。VITE_ENABLE_MOCK_JSBRIDGE=true 时,工程启动时注入 HTTP 版模拟实现,把 6 个 bridge 方法转发到后端 after 工程的 /mock/pad/* 端点,并把后端响应统一转换为 4.1.42+ 结构化 JSON(永不 reject)。
# after/.env
VITE_ENABLE_MOCK_JSBRIDGE=true # 普通浏览器演示
VITE_MOCK_PAD_BASE= # 留空走 vite proxy(/mock/ -> 127.0.0.1:8101)
VITE_LOGIN_PAGE_URL=https://117.72.72.201:3006/m/login # 真机登录页(仅 false 时使用)2
3
4
注意:vite 代理前缀是 /mock/(带尾斜杠)——/mock-login 是前端 SPA 路由, 不能被代理到后端。
上 Pad 真机(重要)
打鸿蒙 Pad 包时必须设置 VITE_ENABLE_MOCK_JSBRIDGE=false: window.jsbridgeHandle 由客户端原生注入 (见 qxj_harmony_next_pad_nsp_browser/common/src/main/ets/util/JsBridgeHandle.ets), 网页代码无需任何改动——这正是接入层抽象在 JSBridge 上的目的。
真机联调步骤:
- 构建本工程(
VITE_ENABLE_MOCK_JSBRIDGE=false,VITE_LOGIN_PAGE_URL指向 Pad 配置中那台服务器的鉴权前端/m/login),部署为 Pad 可访问的地址 (局域网/外网 + HTTPS 自签证书按服务器严格/宽松模式处理); - 在 Pad 的
ServerConfig中:frontendUrl指向真实鉴权前端(承载/m/login),businessFrontendUrl指向本 demo after 页面;扫码绑定服务器 (登录要求设备已完成 SM2 握手); - Pad 启动后默认打开业务页 → 未登录点「跳转到登录页」→ 跳到
/m/login→ 原生加密登录成功 → 自动跳回业务页 →jsBridgeGetAccess拿到 NSP-SM access → 正常调/api/profile/、/api/echo/(指向 after 后端 8101 对应的部署地址, 代理/反代自行配置); - 后端 after 的
.env按联调场景切换(见上文「两种运行模式」):局域网台式机 Redis 可达可完整闭环;外网 117.72.72.201 只能验证登录页跳转/回跳/取 access 链路, 业务接口认证失败属预期。
qxj-frontend-mock(GitHub Pages 静态仓)
qxj-frontend-mock-demo(after)构建产物的静态部署仓,通过 GitHub Pages 发布。
- 远端
https://github.com/etsuyou.github/qxj-frontend-mock.git,main 分支; - 仅 3 个跟踪文件:
index.html+assets/(JS/CSS),即以base: '/qxj-frontend-mock/'的构建产物(GitHub Pages 项目站点子路径); - 该仓不存放源码,只存放构建产物;源码与改造前后对照见
qxj-frontend-mock-demo; - 线上演示:https://etsuyou.github.io/qxj-frontend-mock/ —— 页面通过
qxj-frontend-sdk+ mock-jsbridge 演示 JSBridge 登录(VITE_ENABLE_MOCK_JSBRIDGE), 登录页为/mock-login(生产配置版本则跳qxj-backend-admin.qxj.nsp.ac.cn/m/login,显示「Pad 真机」模式, 普通浏览器 JSBridge 不可用)。
更新方式:
# 在 qxj-frontend-mock-demo(after)中
pnpm build
# 把 dist 内容复制到本仓根目录并提交推送,GitHub Pages 自动更新2
3
python-mock-test-tool(PC 全流程模拟)
qxj-harmony-next-pad-browser-python-mock-test-tool:用 Python 在 PC 上 完整模拟一台鸿蒙平板,对 qxj-backend-admin 走通 「生成密钥 → 环境准备 → SM2 握手 → 加密登录 → Token 管理」全链路。 用于在没有真机 / DevEco 的环境下联调后端,也可作为协议实现的参考。
运行环境
- Python 3.10+;
- 后端
qxj-backend-admin运行中(默认http://127.0.0.1:4607, 环境变量QXJ_BACKEND_URL覆盖); - 与后端共用同一份 C 库:
libnspsmapi.so+libnspsm2handshake.so(默认取../qxj-backend-admin/apps/keymgr/utils/libs,QXJ_C_LIB_DIR覆盖), 通过 ctypes 绑定; - 服务器公钥由管理员导出(
python -m tools.setup qrcode, 默认读../qxj-backend-admin/exported_qrcode最新时间目录)。
因依赖 Linux .so,在 Windows 上请用 WSL 运行。
五个分步目录与执行顺序
| 目录 | 阶段 | 产物 |
|---|---|---|
00_generate_keypair/ | 生成模拟平板 SM2 密钥对 | state 密钥文件 |
01_setup/ | 准备后端(注册设备 / 导入公钥等) | 设备与公钥入库 |
02_handshake/ | SM2 四步握手(INIT/RESP/ACK/TOKEN),依赖与 Pad 相同的 C 库 | state/handshake.json,会话密钥 |
03_encrypted_login/ | 用会话密钥 SM4-GCM 加密登录帧 | 登录抓包样例 .bin/.json |
04_token_management/ | access/refresh 校验、刷新、登出 | — |
python 00_generate_keypair/run.py
python 01_setup/run.py
python 02_handshake/run.py
python 03_encrypted_login/run.py
python 04_token_management/run.py2
3
4
5
目录说明
lib/ # config / c_lib(C库绑定) / crypto_client / frame_parser / http_client / state
common/ # 信封加解密通用工具与测试样例(帧格式与 QxjFrameUtil 对齐)
参考/ # 真机导出的密钥信息 .dat(HUAWEI MatePad Air)
state/ # 运行中间状态(.gitignore,不入库)2
3
4
失败响应口径:HTTP 200 + 下行报警帧(帧头 main_cmd=0x01), 数据域 total_len(2) + ASCII 信息。
教学价值与使用场景
- 新人 onboarding:先跑 before 复现「token 被偷、明文传输」等 5 个问题, 再跑 after 看同一业务在国密体系下的解法,对照成本最低;
- 接入培训:后端接入只需认证类(约 40 行)+ 两个加解密调用;前端接入只需把 axios 拦截器换成
jsBridgeGetAccess/ 401 刷新,业务代码与 token 完全隔离; - 浏览器无真机演示:mock_pad + mock-jsbridge 组合让普通浏览器即可演示完整闭环 (mock 登录 → NSP-SM 验签 → SM4-GCM 回声),GitHub Pages 静态仓可在线打开;
- 联调排障对照:模式 B1/B2 提供了「完整闭环」与「Redis 不可达时预期 -7/-8」 两种基准现象;Python 模拟工具可脱离真机逐步复现握手与加密登录, 配合 Wireshark 解析器抓包逐字段核对(见辅助工具与参考工程)。
已知不一致
两 demo README 的端口(5200/5201、8100/8101)与实际配置(5173、8080)不一致; frontend README 写 vendor 0.1.0 实际为 0.2.3。以实际文件为准。