第三方业务系统接入指南
本文档面向业务系统开发方(接入方)。在 QXJ 安全平板环境中,终端 App 与认证体系由平台方统一提供;业务方只需开发并维护自己的业务前后端,通过两个 SDK 接入,即可获得完整的统一认证与加密通信能力。文中的所有步骤均可以在教学工程 qxj-frontend-mock-demo / qxj-backend-mock-demo 中找到对应的 before / after 可运行代码。
- 项目负责人:建议阅读第 1~3 章,了解交付边界、架构与网络前置条件。
- 前端工程师:重点阅读第 4 章(Demo 用法)与第 5 章(前端接入六步)。
- 后端工程师:重点阅读第 4 章(Demo 用法)与第 6 章(后端接入六步)。
- 完成开发后,对照第 7 章端到端联调、第 10 章验收清单逐项确认。
1. 交付边界:平台方提供什么,业务方做什么
整套体系按「平台提供安全底座,业务方专注业务」划分责任:
| 组件 | 提供方 | 说明 |
|---|---|---|
| 鸿蒙安全平板 App(定制浏览器) | 平台方 | 开箱即用;内置 JSBridge 原生能力:加密登录、令牌安全存储、SM4-GCM 加解密;提供服务器扫码绑定、SM2 握手 |
认证服务器 qxj-backend-admin | 平台方 | 用户/设备/角色/围栏管理,NSP-SM Token 签发,SM2 握手服务端,安全审计 |
鉴权前端 qxj-frontend-admin | 平台方 | 管理后台,以及移动登录页 /m/login(供业务 H5 整页跳转完成登录) |
前端 SDK qxj-frontend-sdk(.tgz) | 平台方 | JS 接入层:封装 JSBridge、NSP-SM JWT 解析/校验、国密算法工具 |
后端 SDK qxj-backend-sdk(.whl) | 平台方 | Python 接入层:Token 双层校验、SM4-GCM 信封加解密 |
| 教学 Demo(before / after) | 平台方 | 本文配套的可运行示例,业务方照 after 版本改造即可 |
| 业务前端 H5 | 业务方 | 业务方自行开发,运行在平板 App 内;通过 qxj-frontend-sdk 接入 |
| 业务后端服务 | 业务方 | 业务方自行开发(Python 优先,其他语言可按协议对接);通过 qxj-backend-sdk 接入 |
接入完成后,业务系统自动获得:
- 统一身份认证:用户在平台登录页一次登录,所有业务系统共享登录态,无需各自建账号体系;
- NSP-SM 令牌双层校验:外层 SM2 数字签名 + 内层 SM3-HMAC 设备绑定,防伪、防盗用;
- 业务报文加密:敏感接口使用 SM4-GCM 64 字节信封,网络上只传密文;
- 身份上下文:业务后端可直接获取当前用户 ID、设备 ID、令牌过期时间。
2. 总体架构与信任边界
2.1 架构全景
2.2 信任边界(密钥流向)
理解密钥在哪里,是安全设计的核心:
| 凭据 | 持有方 | 业务后端 | 网页 H5 |
|---|---|---|---|
| 服务器 SM2 私钥 | 仅认证服务器 | ❌ | ❌ |
| 服务器 SM2 公钥 | 可公开 | ✅ 配置在 SDK 中 | 不需要 |
| 设备会话密钥(SM2 握手协商) | Pad 原生安全区 + Redis | ✅ 只读 | ❌ |
| access token | Pad 原生安全区 | 验签时可见 | ⚠ 仅请求时在内存临时使用,不持久化 |
| refresh token | Pad 原生安全区 | ❌ | ❌ |
业务后端不需要共享对称密钥
传统方案里,验签密钥和签名密钥是同一把对称密钥(HS256),每台服务器都要持有;QXJ 方案业务后端只配置公钥即可验签,私钥不出认证服务器。令牌与设备的绑定由内层 HMAC 完成,而 HMAC 使用的会话密钥来自 SM2 握手、只存在于 Redis 与 Pad 安全区。
2.3 一次完整业务请求的时序
3. 接入前置条件
开工前请确认已从平台方获得以下交付物、并满足网络要求。
3.1 交付物清单
| # | 交付物 | 形态 | 用途 |
|---|---|---|---|
| 1 | 鸿蒙平板 App 安装包 / 应用商店地址 | App | 真机运行环境(平台方直接提供,无需业务方开发) |
| 2 | 认证服务器地址 | URL,如 https://117.72.72.201:3006 | 登录、设备绑定 |
| 3 | 鉴权前端地址 | URL,移动登录页路径 /m/login | 业务 H5 跳转登录 |
| 4 | 管理员账号 | 用户名/密码 | 管理用户、设备、角色(仅需管理员) |
| 5 | qxj-frontend-sdk 安装包 | .tgz(当前 0.2.3) | 业务前端依赖 |
| 6 | qxj-backend-sdk 安装包 | .whl(当前 0.1.0) | 业务后端依赖 |
| 7 | 服务器 SM2 公钥 | 128 hex(x‖y,不带 04) | 真机联调时业务后端验签 |
公钥获取方式(二选一):
- 在运行
qxj-backend-admin的服务器执行python -m tools.setup qrcode,取输出server_id#timestamp#public_key的第 3 段; - 由管理员调用接口
GET /api/v3/admin/server_keys/active_qrcode/。
3.2 关键网络要求:业务后端必须能访问 Redis
业务后端校验令牌内层 HMAC、解密业务信封时,都需要按设备 ID 从 Redis 读取会话密钥。因此部署上有两种形态:
| 部署形态 | 网络条件 | 可用能力 |
|---|---|---|
| 形态一:同机房/同机(推荐) | 业务后端与认证服务器部署在同一网络,Redis(默认 6379)可达 | 完整能力:SM2 验签 + HMAC + SM4-GCM 全部闭环 |
| 形态二:跨网、Redis 不可达 | 业务后端只能访问认证服务器 HTTPS 端口 | 仅外层 SM2 验签可用;HMAC 与解密因取不到会话密钥失败(code -7/-8),不建议作为正式形态 |
若业务后端与认证服务器分属不同机房,应通过专线/VPN 打通 Redis 访问,或在业务侧部署 Redis 只读代理;并在防火墙只放开必要端口。
3.3 其他环境要求
- 业务 H5 必须通过 HTTPS 提供服务(平板 App 对混合内容与证书有要求;自签证书需在服务器「严格/宽松」模式中确认);
- 业务后端运行环境 Python ≥ 3.8(推荐 3.11);前端构建 Node.js ≥ 20、pnpm ≥ 8;
- 各系统时钟需 NTP 同步——令牌过期判断依赖时间。
4. 教学 Demo:before / after 怎么用
平台方提供两个一一配套的教学工程,建议先跑通 Demo,再动手改自己的系统:
| 工程 | 角色 | 内容 |
|---|---|---|
qxj-frontend-mock-demo | 前端教学 | before/ 与 after/ 两个可运行 Vue3 工程 |
qxj-backend-mock-demo | 后端教学 | before/ 与 after/ 两个可运行 Django 工程 |
4.1 before 与 after 的差别
| 维度 | before(传统方案) | after(接入 QXJ) |
|---|---|---|
| 令牌 | 业务后端自签 HS256 JWT | 认证服务器签发 NSP-SM(外层 SM2 + 内层 HMAC) |
| 密钥 | 验签密钥 = 签名密钥,后端都要持有 | 业务后端只持公钥 |
| 网页存储 | access/refresh 存 localStorage,XSS 可直接窃取 | 网页不存任何令牌,localStorage 始终为空 |
| 401 刷新 | 网页自己持有 refresh 并刷新 | 经 JSBridge 由原生侧刷新,网页看不到 refresh |
| 登录 | 业务页自带登录表单 | 业务页跳转到平台统一登录页 /m/login |
| 业务报文 | 全明文 JSON | 敏感接口 SM4-GCM 64B 信封密文 |
| 设备绑定 | 无 | 令牌绑定完成 SM2 握手的具体设备 |
4.2 在普通电脑上跑 Demo(无需真机)
普通浏览器没有 Pad 注入的 JSBridge,因此 Demo 内置了 mock-jsbridge:前端把桥调用转发到后端 after 工程的 /mock/pad/* 端点,由后端模拟 Pad 原生与认证服务器。协议与真机完全一致,先用它在浏览器里熟悉完整链路。
Demo 端口以工程实际配置为准
工程 README 中曾标注 5200/5201、8100/8101 端口;当前配置实际为:前端 Vite 5173(base 路径 /qxj-frontend-mock/),后端 8080。before/after 两对工程共用默认端口,同一时间只跑一对即可,也可自行修改配置区分。
第 1 步:启动后端 after
cd qxj-backend-mock-demo/after
# 安装依赖(会自动安装 vendor/ 下的 SDK wheel)
pip install -r requirements.txt
# 准备 .env(默认值可直接用:Redis db=1、前缀 qxj:mock:,不污染正式数据)
cp .env.example .env
# 生成演示 SM2 密钥对、预置演示设备与会话密钥,并完成自检
python tools/init_demo.py
# 启动(开发模式)
python manage.py runserver 0.0.0.0:80802
3
4
5
6
7
8
9
10
11
12
13
init_demo.py 自检内容:密钥对生成、会话密钥写入 Redis、verify_access_token 返回 code=0、SM4-GCM 加解密往返一致。看到自检全部通过即说明环境正常。
如需 HTTPS 启动(与真机行为更接近):
# 先把 qxj-backend-admin/certs/ 下的 server.crt、server.key 复制到 after/certs/
./run_https.sh
# 走 HTTPS 时,前端 vite proxy target 需相应改为 https,并设置 secure: false2
3
第 2 步:启动前端 after
cd qxj-frontend-mock-demo/after
# SDK 随工程交付于 vendor/,package.json 以 file: 方式引用
pnpm install
pnpm dev
# 打开 http://localhost:5173/qxj-frontend-mock/2
3
4
5
6
7
测试账号:admin/admin123、guest/guest123。
第 3 步:观察 before / after 的关键差异
- after 业务页没有登录表单,点击跳转后进入内置的
/mock-login模拟登录页;登录成功自动跳回; - 打开开发者工具 → Application → Local Storage:始终为空;
- 加密回声页面中,网络面板里请求体只有
{"frame": "…密文 hex…"},看不到业务明文。
4.3 两种运行模式的切换
| 模式 | VITE_ENABLE_MOCK_JSBRIDGE | JSBridge 来源 | 登录页 |
|---|---|---|---|
| 浏览器演示(默认) | true | 前端注入 HTTP 模拟实现 → 后端 /mock/pad/* | 工程内置 /mock-login |
| Pad 真机/模拟器 | false(必须关闭) | 鸿蒙原生注入 window.jsbridgeHandle | 平台真实登录页 VITE_LOGIN_PAGE_URL |
上真机必须关闭 mock
打真机包前必须设置 VITE_ENABLE_MOCK_JSBRIDGE=false,否则网页会尝试调用不存在的模拟后端。关闭后网页代码无需任何改动——这正是把接入层抽象在 JSBridge 上的目的。
4.4 建议的学习路径
- 先跑 before 一对:理解传统方案的令牌、存储、刷新流程;
- 再跑 after 一对:对照本文第 4.1 表格逐项观察差异;
- 阅读 after 的四个关键文件:前端
src/api.ts、src/auth-flow.ts;后端auth_app/qxj_auth.py、auth_app/views.py; - 然后按第 5、6 章把同样的模式套用到自己的系统。
5. 业务前端接入(六步)
以下代码取自 qxj-frontend-mock-demo/after,业务方照此改造自己的 H5 即可。
Step 1:安装前端 SDK
把随交付物提供的 tgz 包放入工程(如 vendor/ 目录),以本地文件方式依赖:
{
"dependencies": {
"qxj-frontend-sdk": "file:./vendor/qxj-frontend-sdk-0.2.3.tgz"
}
}2
3
4
5
pnpm installStep 2:HTTP 层接入——请求前取令牌、401 时原生刷新
这是前端接入最核心的一段(对应 Demo 的 src/api.ts):
import axios from 'axios'
import { jsBridgeGetAccess, jsBridgeRefresh } from 'qxj-frontend-sdk'
export const http = axios.create({
timeout: 15000,
// 有 baseURL 时直接打业务后端(真机/部署);留空走 vite proxy(本地演示)
baseURL: import.meta.env.VITE_API_BASE || '',
})
// 请求拦截:向客户端安全区临时取用 access,网页本身不保存、不持久化
http.interceptors.request.use(async (config) => {
const result = await jsBridgeGetAccess()
if (result.code === 0 && result.accessToken) {
config.headers.Authorization = `Bearer ${result.accessToken}`
}
return config
})
// 多个并发请求同时 401 时,只触发一次 refresh
let refreshing: Promise<boolean> | null = null
function refreshAccess(): Promise<boolean> {
if (!refreshing) {
refreshing = jsBridgeRefresh()
.then((r) => r.code === 0)
.finally(() => {
refreshing = null
})
}
return refreshing
}
http.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error || {}
if (response?.status === 401 && config && !config._retried) {
config._retried = true
const ok = await refreshAccess()
if (ok) {
const r = await jsBridgeGetAccess()
if (r.code === 0 && r.accessToken) {
config.headers.Authorization = `Bearer ${r.accessToken}`
return http(config) // 用新 access 重试原请求
}
}
// refresh 也失败:通知页面回到登录态
window.dispatchEvent(new Event('qxj-session-expired'))
}
return Promise.reject(error)
},
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
Step 3:登录态探测与登录页跳转协议
业务页与登录页完全解耦:未登录时只做一件事——整页跳转到平台登录页,并把回跳地址放在 redirect 参数上(对应 Demo 的 src/auth-flow.ts)。
{登录页URL}?redirect={encodeURIComponent(业务页绝对URL?login=back)}import { isJsBridgeAvailable, jsBridgeGetAccess } from 'qxj-frontend-sdk'
const MOCK_MODE = import.meta.env.VITE_ENABLE_MOCK_JSBRIDGE === 'true'
const REAL_LOGIN_PAGE_URL = import.meta.env.VITE_LOGIN_PAGE_URL || ''
// URL 上的一次性回跳标记:从登录页回来时带 login=back,消费后立即清掉
const RETURN_MARKER = 'login'
export function buildReturnUrl(): string {
const url = new URL(window.location.href)
url.hash = ''
url.searchParams.delete(RETURN_MARKER)
url.searchParams.set(RETURN_MARKER, 'back')
return url.href
}
export function gotoLoginPage(): void {
const redirect = encodeURIComponent(buildReturnUrl())
if (MOCK_MODE) {
window.location.href = `/mock-login?redirect=${redirect}`
return
}
if (!REAL_LOGIN_PAGE_URL) {
throw new Error('未配置 VITE_LOGIN_PAGE_URL(真机鉴权前端登录页地址)')
}
const sep = REAL_LOGIN_PAGE_URL.includes('?') ? '&' : '?'
window.location.href = `${REAL_LOGIN_PAGE_URL}${sep}redirect=${redirect}`
}
// 探测安全区当前是否持有 access
export async function probeAccess(): Promise<string> {
if (!isJsBridgeAvailable()) return ''
const result = await jsBridgeGetAccess()
return result.code === 0 && result.accessToken ? result.accessToken : ''
}
// 若带登录页回跳标记,消费它(清掉 query)
export function consumeReturnMarker(): boolean {
const url = new URL(window.location.href)
if (url.searchParams.get(RETURN_MARKER) !== 'back') return false
url.searchParams.delete(RETURN_MARKER)
const clean = url.pathname + (url.search || '') + url.hash
window.history.replaceState({}, document.title, clean || '/')
return true
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
协议要点:
redirect必须是绝对 URL;真机登录页已支持外域回跳——以http(s)://开头时登录成功整页跳回;不带redirect时登录页跳默认主页;- 令牌绝不出现在 URL 上:登录成功后令牌只进 Pad 原生安全区,页面通过
jsBridgeGetAccess()恢复登录态,刷新页面同理; - 业务页加载时调用
consumeReturnMarker()+probeAccess()判断是否刚登录返回。
Step 4:调用加密业务接口
加解密由客户端侧(真机原生 / 浏览器 mock)完成,网页代码只处理信封 hex:
import { jsBridgeEncrypt, jsBridgeDecrypt } from 'qxj-frontend-sdk'
import { http } from './api'
// 1) 明文 -> 64B 信封 hex
const enc = await jsBridgeEncrypt(JSON.stringify({ message: '你好,国密 SM4-GCM!' }))
if (enc.code !== 0 || !enc.data) {
throw new Error(`加密失败 code=${enc.code}`)
}
// 2) 线上只传输密文信封
const { data: resp } = await http.post('/api/echo/', { frame: enc.data })
// 3) 响应信封 -> 明文
const dec = await jsBridgeDecrypt(resp.frame)
const reply = JSON.parse(dec.data).reply2
3
4
5
6
7
8
9
10
11
12
13
14
15
登出同样走桥:jsBridgeLogout(),由原生侧清除安全区令牌。
Step 5:配置环境变量
# 浏览器演示 / 真机切换
VITE_ENABLE_MOCK_JSBRIDGE=true
# mock 落点前缀;留空走 vite proxy(/mock/ -> 后端)
VITE_MOCK_PAD_BASE=
# 真机登录页(VITE_ENABLE_MOCK_JSBRIDGE=false 时使用)
# 严格服务器:https://117.72.72.201:3006/m/login
# 宽松服务器:https://117.72.72.201:13006/m/login
VITE_LOGIN_PAGE_URL=https://117.72.72.201:3006/m/login
# 业务后端地址;浏览器演示留空走代理,真机填写实际部署地址
VITE_API_BASE=http://192.168.105.116:80802
3
4
5
6
7
8
9
10
11
12
13
vite proxy 参考(注意 /mock/ 必须带尾斜杠——/mock-login 是前端 SPA 路由,不能被代理):
server: {
proxy: {
'/api': { target: 'http://127.0.0.1:8080', changeOrigin: true },
'/mock/': { target: 'http://127.0.0.1:8080', changeOrigin: true },
},
}2
3
4
5
6
Step 6:真机上线前检查
-
VITE_ENABLE_MOCK_JSBRIDGE=false; -
VITE_LOGIN_PAGE_URL指向该平板绑定的那台服务器的鉴权前端/m/login; -
VITE_API_BASE指向业务后端正式地址,且为 HTTPS; - 页面部署地址已加入平板服务器配置(
businessFrontendUrl); - 验证:刷新页面登录态可恢复、401 自动刷新重试、加密接口往返正常。
6. 业务后端接入(六步)
Step 1:安装后端 SDK
将 wheel 放入工程并在 requirements.txt 中引用:
./vendor/qxj_backend_sdk-0.1.0-py3-none-any.whl
redis>=4.52
pip install -r requirements.txtStep 2:增加配置
# 认证服务器同一 Redis(演示环境用 db=1 / qxj:mock:;正式环境用 db=0 / qxj:1:)
QXJ_REDIS_URL = "redis://:password@127.0.0.1:6379/0"
QXJ_REDIS_KEY_PREFIX = "qxj:1:"
# 认证服务器 SM2 公钥(128 hex,x||y 不带 04)
QXJ_SERVER_PUBLIC_KEY = "<真实公钥>"2
3
4
5
6
Redis 物理键规则为纯字符串拼接(前缀需与认证服务器逐字符一致,含末尾冒号):
{key_prefix}sm2:device_session_key:{device_id}Step 3:实现认证类(Django REST Framework)
以下为精简但可直接使用的版本;完整注释版见 Python 后端 SDK · DRF 接入实战。
import redis
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from qxj_backend_sdk import verify_access_token
from qxj_backend_sdk_auth import get_user_id
_redis_client = None
def get_redis_client():
"""全局复用一个连接(decode_responses=False,SDK 自行解码)。"""
global _redis_client
if _redis_client is None:
_redis_client = redis.Redis.from_url(settings.QXJ_REDIS_URL)
return _redis_client
class QxjAccessTokenAuthentication(BaseAuthentication):
keyword = "Bearer"
def authenticate(self, request):
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if not auth_header:
return None
parts = auth_header.split(" ", 1)
if len(parts) != 2 or parts[0] != self.keyword:
return None
device_id, expire_time, code = verify_access_token(
parts[1].strip(),
settings.QXJ_SERVER_PUBLIC_KEY,
get_redis_client(),
key_prefix=settings.QXJ_REDIS_KEY_PREFIX,
)
if code != 0:
# 0=完全通过; 1=仅外层通过(设备未握手); 负值=失败 —— 一律拒绝
raise AuthenticationFailed(f"access token 验证失败,result_code={code}")
user_id = get_user_id(parts[1].strip())
# TODO: 按业务系统自身的用户表映射用户
user = load_your_user(user_id)
return user, {
"device_id": device_id,
"expire_time": expire_time,
"user_id": user_id,
"token": parts[1].strip(),
}
def authenticate_header(self, request):
return self.keyword2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
坚持 code != 0 一律拒绝
code=1 只能证明令牌由认证服务器签发,不能证明来自完成握手的设备。正式业务接口不得放行 code=1;也不要在生产使用 skip_hmac=True。
Step 4:编写业务视图
class ProfileView(APIView):
authentication_classes = [QxjAccessTokenAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request):
# 明文接口:只有认证,没有加密
return JsonResponse({
"user": serialize_your_user(request.user),
"device_id": request.auth["device_id"], # 认证类返回的上下文
"expire_time": request.auth["expire_time"],
})
class EchoView(APIView):
"""加密接口:请求/响应均为 SM4-GCM 信封。"""
authentication_classes = [QxjAccessTokenAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
frame_in = request.data.get("frame")
if not frame_in:
return JsonResponse({"message": "缺少 frame 字段"}, status=400)
redis_client = get_redis_client()
# 1) 解密(设备 ID 自动从信封 sender 提取)
plain_in, code = sm4_gcm_decrypt(
frame_in, redis_client, key_prefix=settings.QXJ_REDIS_KEY_PREFIX
)
if code != 0:
return JsonResponse({"message": f"解密失败 code={code}"}, status=400)
# 2) 业务处理
payload = json.loads(plain_in)
plain_out = json.dumps(handle_business(payload), ensure_ascii=False)
# 3) 用同一设备的会话密钥重新加密返回
frame_out, code = sm4_gcm_encrypt(
request.auth["device_id"], plain_out, redis_client,
key_prefix=settings.QXJ_REDIS_KEY_PREFIX,
)
if code != 0:
return JsonResponse({"message": f"加密失败 code={code}"}, status=500)
return JsonResponse({"frame": frame_out})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Step 5:非 DRF 框架对接
SDK 与框架无关,任何 Python Web 框架都可以直接调用。以下 FastAPI 依赖示例展示同样思路(示例代码,非 SDK 内置):
import redis
from fastapi import Depends, Header, HTTPException
from qxj_backend_sdk import verify_access_token
_redis = redis.Redis.from_url(settings.QXJ_REDIS_URL)
async def current_device(authorization: str = Header(default="")) -> dict:
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="缺少 Bearer Token")
device_id, expire_time, code = verify_access_token(
authorization[7:], settings.QXJ_SERVER_PUBLIC_KEY, _redis,
key_prefix=settings.QXJ_REDIS_KEY_PREFIX,
)
if code != 0:
raise HTTPException(status_code=401, detail=f"令牌校验失败 code={code}")
return {"device_id": device_id, "expire_time": expire_time}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
非 Python 技术栈(Java/Go 等)可按公开协议自行实现等价逻辑:SM2-with-SM3 验签(签名为固定长度 r‖s)、SM3-HMAC(缓冲为 device_id + 大端 8 字节 user_id + 大端 8 字节 exp)、SM4-GCM 64B 信封。协议细节见 REST API 参考。
Step 6:生产化检查
- Redis 地址、db 号、
key_prefix与认证服务器一致(正式环境qxj:1:); - 已配置真实服务器公钥,且未再执行
init_demo.py; - 移除
mock_pad这类仅用于浏览器演示的 app(真机上登录/握手由 Pad 原生与认证服务器完成); - 关闭 DEBUG、收敛 CORS 白名单、使用 HTTPS;
- Redis 故障时接口应失败返回(5xx/401),不得静默放行。
7. 端到端联调
7.1 无真机:浏览器全链路
按第 4.2 节启动后端 after 与前端 after,依次验证:
- 打开业务页 → 自动/手动跳转
/mock-login; admin/admin123登录 → 自动跳回业务页,URL 中的login=back被清除;- Local Storage 始终为空;
- 调用认证接口
/api/profile/成功; - 加密回声:请求/响应面板中只有密文信封,页面正确还原明文;
- 等待 access 过期或手动制造 401:自动刷新并重试成功;
- 退出后回到未登录态。
7.2 真机:Pad 全链路
- 部署业务 H5(HTTPS)与业务后端,确认业务后端到认证服务器 Redis 连通;
- 在 Pad 的服务器配置(
ServerConfig)中:frontendUrl指向平台鉴权前端(承载/m/login);businessFrontendUrl指向业务 H5;- 扫码绑定服务器(登录要求设备已完成 SM2 握手);
- Pad 打开业务页 → 未登录跳转
/m/login→ 原生加密登录 → 自动跳回; - 业务接口认证通过、加密接口往返成功;
- 杀进程重开/刷新页面:登录态由原生安全区恢复。
7.3 分阶段验收标准
| 阶段 | 验证点 | 预期结果 |
|---|---|---|
| 环境 | init_demo.py 自检 | 全部通过(演示模式) |
| 登录 | 跳转登录、登录跳回 | 回到业务页,URL 无令牌、网页存储为空 |
| 认证 | 访问认证接口 | 200;无令牌/伪造令牌返回 401 |
| 设备绑定 | 使用未握手设备 | code=1 被拒绝(不放行) |
| 加密 | 加密回声接口 | 网络上仅密文,页面正确加解密 |
| 续期 | access 过期后请求 | 原生自动刷新并重试,网页无感 |
| 登出 | 退出登录 | 安全区清除,业务页回到未登录态 |
8. 结果码速查
令牌验证 VerifyCode:0 完全通过;1 仅外层通过;-1 为空 / -2 解析失败 / -3 SM2 验签失败 / -4 已过期 / -5 custom 字段缺失 / -6 HMAC 失败 / -7 无会话密钥 / -8 Redis 异常 / -99 未知。
加解密 CryptoErrorCode:0 成功;-1 输入为空 / -3 无会话密钥 / -4 信封格式错 / -5 GCM tag 校验失败 / -7 Redis 异常 / -8 设备 ID 非法 / -9 hex 解析失败 / -99 未知。
完整码表(含每个码的触发场景)见 Python 后端 SDK 与 JavaScript 前端 SDK。
9. 常见问题
Q: 登录页登录成功,但业务接口返回 401(code=-7)? 业务后端没查到设备会话密钥:确认业务后端与认证服务器连接的是同一 Redis、db 号与 key_prefix(qxj:1:)一致;确认设备确实完成了 SM2 握手(会话密钥 TTL 7 天)。可用 redis-cli KEYS '*sm2:device_session_key*' 核对物理键。
Q: 真机上登录后没有跳回业务页? 检查 redirect 是否为绝对 URL 且地址可达;业务 H5 地址需在平板服务器配置中登记;浏览器演示模式下 mock 登录页仅允许同源回跳。
Q: 请求出现 401 反复刷新? 通常是系统时钟不同步导致令牌被判过期(先 NTP 对时);或刷新得到的新 access 仍不被业务后端认可(公钥配错环境)。检查拦截器只重试一次的逻辑是否被破坏。
Q: 解密返回 code=-5? GCM tag 校验失败:密文、IV 在传输/转码中被改动(hex 需完整、长度为偶数),或 sender 对应设备的会话密钥与加密时不是同一份。
Q: 浏览器调试时 bridge 方法报错? 确认 VITE_ENABLE_MOCK_JSBRIDGE=true 且后端 after(含 /mock/pad/*)已启动;vite proxy 中 /mock/ 前缀带尾斜杠。
Q: 业务系统可以不用加密、只用登录认证吗? 可以。普通接口照 ProfileView 模式只做认证、明文传输即可;仅敏感接口需要按 EchoView 模式加信封。但 code=1 放行、关闭 HMAC 等削弱校验的做法不可接受。
Q: HTTPS 页面调用 HTTP 接口失败? 平板 WebView 阻止混合内容。业务接口也必须是 HTTPS;自签证书需按服务器严格/宽松模式处理,或在设备信任证书。
10. 接入完成确认清单
交付物确认
- 已获得并安装平板 App;
- 已获得认证服务器/鉴权前端地址及管理员账号;
- 已获得两个 SDK 安装包与真实服务器 SM2 公钥。
前端
- 已通过本地 tgz 依赖 SDK;
- HTTP 拦截器完成「临时取 access + 401 原生刷新重试」;
- 登录跳转协议(redirect 绝对 URL、回跳标记)实现完毕;
- 真机配置
VITE_ENABLE_MOCK_JSBRIDGE=false且页面 HTTPS 可访问。
后端
- 已安装 SDK wheel 并配置 Redis(db/前缀正确)、真实公钥;
- 认证类对所有受保护接口生效,code != 0 一律拒绝;
- 加密接口完成解密-处理-重加密闭环;
- 已移除演示用 app(
mock_pad),DEBUG 关闭。
联调:第 7.3 节验收表全部通过。
延伸阅读
- 开发环境与规范:团队内部开发环境、代码规范与构建命令
- REST API 参考:认证、握手与管理端全部 HTTP 接口
- JavaScript 前端 SDK:桥方法、JWT 校验与国密工具完整参考
- Python 后端 SDK:高层函数、错误码、信封结构与 DRF 完整示例
- SM2 密钥协商:会话密钥如何协商产生
- 加密通信:国密算法体系与信封设计