Python 后端 SDK
[qxj-backend-sdk](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-backend-sdk) 是业务后端接入 QXJ 安全体系的官方 Python SDK,把「NSP-SM Token 验签」与「SM4-GCM 信封加解密」两条主流程封装成三个高层函数。业务工程(如 [qxj-backend-mock-demo](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-backend-mock-demo) 的 after 版本)只需写少量胶水代码,即可接入认证服务器 [qxj-backend-admin](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-backend-admin) 签发的 Token,而无需持有服务器 SM2 私钥。
- 发行包名:
qxj-backend-sdk(pip / wheel) - 当前版本:0.1.0,版本号动态读取自
qxj_backend_sdk_auth._version - Python:≥ 3.8
- 运行期依赖:gmssl
>=3.2(国密算法纯 Python 实现)、redis>=4.5 - 授权协议:MIT
- 命令行入口:
qxj-backend-sdk-auth(verify / parse / version)
SDK 在整体架构中的位置
认证服务器持有 SM2 私钥并负责签发 Token;业务后端只持有 SM2 公钥,通过 SDK 完成验签,并与认证服务器共享同一个 Redis(SM2 握手协商出的会话密钥存放在其中)。会话密钥永不出现在服务端之外,业务后端也接触不到私钥——这是整套体系「最小信任面」设计的关键。
设计目标与安全原则
在阅读 API 之前,先理解 SDK 遵循的几条设计原则——它们也是面试中解释方案时的核心论据:
- Fail-closed(失败即拒绝):任何不确定的状态都按失败处理。例如 Token 中
exp缺失或无法解析,一律归入-4 TOKEN_EXPIRED,而不是放行。 - 双层校验:外层 SM2-with-SM3 数字签名保证 Token 由认证服务器签发且未被篡改;内层 SM3-HMAC 证明该 Token 与一台完成过 SM2 握手的真实设备绑定。只有两层都通过才返回
0。 - 不碰私钥、只读 Redis:SDK 只读取会话密钥,不写入、不续期(TTL 由写入端认证服务器维护,7 天);也不需要 SM2 私钥。
- 拒绝反序列化:Redis 中的值只做 JSON 解析,绝不
pickle.loads,防止被攻陷的 Redis 通过恶意字节流触发反序列化 RCE。 - 密文自包含发送方:64B 信封固定位置携带发送方设备 ID,解密端无需额外参数即可查到对应会话密钥。
安装
SDK 随工程内网 Gitea(http://192.168.168.51:3000,组织 qxj-project)分发,提供三种安装方式:
# mock-demo 的做法:把构建产物放入工程 vendor/ 目录后安装
pip install ./vendor/qxj_backend_sdk-0.1.0-py3-none-any.whl2
# 内网 Gitea 克隆后可编辑安装(-e),便于调试
git clone http://192.168.168.51:3000/qxj-project/qxj-backend-sdk.git
cd qxj-backend-sdk
pip install -e .2
3
4
# 在 SDK 工程内构建分发包
python -m pip install build
python -m build
# 产物位于 dist/qxj_backend_sdk-0.1.0-py3-none-any.whl
pip install dist/qxj_backend_sdk-0.1.0-py3-none-any.whl2
3
4
5
典型 requirements.txt(取自 mock-demo after 工程):
./vendor/qxj_backend_sdk-0.1.0-py3-none-any.whl
Django==4.2.25
djangorestframework==3.15.2
django-cors-headers==4.4.0
python-dotenv==1.0.1
redis>=4.5
gunicorn==22.0.02
3
4
5
6
7
模块结构
一个发行包内含三个子包,按「高层接口 / 鉴权 / 密码学」分层:
src/
├── qxj_backend_sdk/ # ① 顶层包:三个高层函数(业务侧只用到它)
│ ├── __init__.py # 重导出 verify_access_token / sm4_gcm_*
│ ├── highlevel.py # 高层函数实现(自动组装 Store/Verifier)
│ └── _version.py
├── qxj_backend_sdk_auth/ # ② 鉴权子包:NSP-SM Token 解析与验证
│ ├── crypto/ # sm2_verify / sm3 / sm3_hmac
│ ├── token/ # parser / verifier / errors / VerifyResult
│ ├── redis/ # SessionKeyStore(会话密钥查询)
│ ├── cli/ # qxj-backend-sdk-auth 命令行工具
│ └── _version.py
└── qxj_backend_sdk_cryptography/ # ③ 密码学子包:国密算法 + 标准信封
├── sm2.py / sm3.py / sm3_hmac.py
├── sm4_cbc.py / sm4_gcm.py # SM4 各模式(GCM 为 ECB 自实现 CTR+GHASH)
├── envelope.py # 64B 统一通信头:构建/解析/校验
├── session_crypto.py # Sm4GcmCrypto:密钥查询 + 信封加解密
└── crypto_errors.py # CryptoErrorCode2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| 子包 | 职责 | 业务侧是否常用 |
|---|---|---|
qxj_backend_sdk | 三个高层函数,屏蔽内部类构造 | ★ 只导入它即可 |
qxj_backend_sdk_auth | Token 解析/验证、会话密钥查询、CLI | ○ 需要复用、批处理时用 |
qxj_backend_sdk_cryptography | SM2/SM3/SM4 原语、信封、错误码 | ○ 需要自定义协议帧时用 |
高层 API 总览
三个函数名与老项目 SDK 文档(111.txt)对齐,均支持「传 redis_client」或「传 redis_url + key_prefix 自动构建」两种接入方式:
| 函数 | 作用 | 返回值 |
|---|---|---|
| verify_access_token | 双层校验 access token | (device_id, expire_time, code) |
| sm4_gcm_encrypt | 按设备 ID 查密钥,明文 → 64B 信封 | (cipher_hex, code) |
| sm4_gcm_decrypt | 64B 信封 → 明文(自动提取发送方) | (plain, code) |
verify_access_token
验证 access token:外层 SM2 验签 + 内层 SM3-HMAC,设备 ID 自动从 token 的 custom.device_id 提取。
from qxj_backend_sdk import verify_access_token
device_id, expire_time, code = verify_access_token(
access, # JWT access token 字符串
public_key, # 服务器 SM2 公钥(128 hex,x‖y,不带 04 前缀)
redis_client, # 已连接的 redis.Redis;与 redis_url 二选一
skip_hmac=False, # True 时仅做外层 SM2 验签 + 过期校验
redis_url=None, # 未传 redis_client 时使用的连接 URL
key_prefix="", # Redis key 前缀,须与认证服务器口径一致
leeway=0, # 过期校验时钟宽容(秒)
)2
3
4
5
6
7
8
9
10
11
参数说明
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
access | str | 是 | NSP-SM 格式 access token(header.payload.signature) |
public_key | str | 是 | 服务器 SM2 公钥,128 个 hex 字符(x‖y,不带 04 前缀);可从管理端 GET /api/v3/admin/keymgr/qrcode_string/ 获取 |
redis_client | redis.Redis | 二选一 | 已连接的客户端,建议全局单例复用;skip_hmac=True 时可不传 |
redis_url | str | 二选一 | 形如 redis://:password@host:6379/1,未传 redis_client 时由 SDK 懒加载 |
key_prefix | str | 否 | Redis 物理键前缀,纯字符串拼接,须与认证服务器一致(见 SessionKeyStore) |
skip_hmac | bool | 否 | True 跳过内层 HMAC,仅验证外层签名与有效期 |
leeway | int | 否 | 过期判断的时钟偏移容忍量,单位秒 |
返回值:(device_id, expire_time, result_code)
| 字段 | 类型 | 说明 |
|---|---|---|
device_id | str | 设备 ID(UUID);解析失败为空串 "" |
expire_time | str | 过期时间戳(exp)的十进制字符串;解析失败为空串 |
result_code | int | 结果码,完整含义见 VerifyCode 码表。只有 0 代表完全通过 |
code = 1 也必须拒绝
1 OUTER_PASSED 表示外层 SM2 签名通过、但因 device_id 为空而跳过了内层 HMAC——它证明「Token 是服务器签的」,但不能证明「来自完成握手的设备」。对外提供服务的业务接口必须坚持 code != 0 一律拒绝,mock-demo 的认证类正是这样处理的。
验证流程
sm4_gcm_encrypt
按 dev_id 从 Redis 查出会话密钥,把明文加密为 64B 信封 hex(大写):帧头 + 密文 + 16B GCM tag。
from qxj_backend_sdk import sm4_gcm_encrypt
cipher_hex, code = sm4_gcm_encrypt(
dev_id, # 设备 ID(IDA),用于查会话密钥
plain, # 待加密明文
redis_client, # redis.Redis;与 redis_url 二选一
is_hex=False, # True 时 plain 按 hex 字符串解析为字节
redis_url=None,
key_prefix="",
)2
3
4
5
6
7
8
9
10
| 参数 | 类型 | 说明 |
|---|---|---|
dev_id | str | 设备 ID,支持 36 字符 UUID / 32 hex / 72 hex 三种形态(自动规范化) |
plain | str | 明文内容;is_hex=False 时按 UTF-8 编码 |
is_hex | bool | True 表示 plain 是 hex 字符串,先解码再加密 |
redis_client / redis_url / key_prefix | — | 同 verify_access_token |
返回值:(ciphertext, code)
ciphertext:64B 标准信封的大写 hex 字符串(整体长度 = 64 + 密文长度 + 16 tag);失败时为空串。code:0成功;其余见 CryptoErrorCode 码表。- 若
redis_client与redis_url都未传,直接返回-3 SESSION_KEY_NOT_FOUND。
sm4_gcm_decrypt
从 64B 信封帧头固定偏移的 sender 字段自动提取设备 ID,查出会话密钥后解密并校验 GCM tag。
from qxj_backend_sdk import sm4_gcm_decrypt
plain, code = sm4_gcm_decrypt(
cipher, # 64B 信封 hex(大写/小写均可)
redis_client, # redis.Redis;与 redis_url 二选一
as_hex=False, # True 时返回明文的 hex,False 时按 UTF-8 解码
dev_id=None, # 预留参数,当前版本不会透传,可省略
redis_url=None,
key_prefix="",
)2
3
4
5
6
7
8
9
10
| 参数 | 类型 | 说明 |
|---|---|---|
cipher | str | 完整信封 hex |
as_hex | bool | True 返回明文 hex;False(默认)返回 UTF-8 字符串 |
dev_id | str? | 预留参数:当前高层实现并不把它透传给内部类,设备 ID 一律从信封 sender 提取,一般无需传 |
redis_client / redis_url / key_prefix | — | 同上 |
返回值:(plaintext, code)
plaintext:解密明文;失败为空串。code:0成功;帧头非法(版本/主命令/UUID/保留字段/总长不一致)通常为-4 CIPHER_FORMAT_INVALID;GCM tag 校验失败为-5 AUTH_FAILED。
VerifyCode 码表
Token 验证结果码定义于 [qxj_backend_sdk_auth/token/errors.py](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-backend-sdk/src/qxj_backend_sdk_auth/token/errors.py) 的 VerifyCode(IntEnum)。设计约定:0 完全成功,1 仅外层通过,负值表示失败;可用 describe(code) 获取中文说明。
| 码 | 枚举名 | 中文含义 | 典型触发场景 |
|---|---|---|---|
0 | OK | 验证通过(外层 SM2 + 内层 HMAC) | Token 合法且设备已握手 |
1 | OUTER_PASSED | 外层通过,内层跳过 | device_id 为空,或调用方显式 skip_hmac |
-1 | TOKEN_EMPTY | Token 为空 | 未取到 Authorization 头 / token 空串 |
-2 | TOKEN_PARSE_FAILED | Token 格式解析失败 | 不是三段式、base64url 解码失败、payload 非 JSON |
-3 | SIGN_VERIFY_FAILED | 外层 SM2 验签失败 | 签名被篡改、公钥不匹配、alg 不是 NSP-SM |
-4 | TOKEN_EXPIRED | Token 已过期 | exp 已过;exp 缺失或无法解析也归此码(fail-closed) |
-5 | CUSTOM_FIELD_MISSING | custom 字段缺失 | custom 不是对象、缺少必要字段,或 exp_s 与 exp 不一致 |
-6 | HMAC_VERIFY_FAILED | 内层 HMAC 校验失败 | 重算的 HMAC 与 custom.hmac 不符(密钥不对/字段被改) |
-7 | SESSION_KEY_NOT_FOUND | 未找到会话密钥 | 设备未握手或密钥已过期;custom.hmac 为空也归此码 |
-8 | REDIS_ERROR | Redis 查询异常 | Redis 不可达、连接超时等服务端故障 |
-99 | UNKNOWN_ERROR | 未知错误 | 未预期的异常,属于需要排查的兜底码 |
from qxj_backend_sdk_auth import VerifyCode, describe
assert VerifyCode.OK == 0
print(describe(-7)) # 未找到会话密钥2
3
4
为什么同时校验 exp_s 与 exp?
NSP-SM Token 的 custom.exp_s 是 exp 的冗余副本(字符串形式)。攻击者若只改 payload 中的 exp 而无法同步修改受 HMAC 保护的 custom 区域,两处不一致会在第 5 步直接暴露为 -5;即使两处一起改,HMAC 也会失配返回 -6。多层冗余让「篡改延期」没有可乘之机。
CryptoErrorCode 码表
加解密结果码定义于 [crypto_errors.py](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-backend-sdk/src/qxj_backend_sdk_cryptography/crypto_errors.py) 的 CryptoErrorCode(IntEnum),与老项目 qxj-backend-toolkit 的编码保持一致:
| 码 | 枚举名 | 中文含义 | 典型触发场景 |
|---|---|---|---|
0 | OK | 成功 | 加解密正常 |
-1 | EMPTY_INPUT | 传入的明文/密文为空 | plain 为空串、cipher 为空 |
-2 | LIB_CALL_FAILED | 底层加密调用失败 | gmssl 调用抛异常 |
-3 | SESSION_KEY_NOT_FOUND | 未找到对应的会话密钥 | 未传 Redis、设备无密钥;密钥长度非 16/32 字节 |
-4 | CIPHER_FORMAT_INVALID | 密文格式错误/非本算法产生/被篡改 | 帧头版本、主命令、sender UUID、reserved、total_len 任一校验不过 |
-5 | AUTH_FAILED | 认证失败(MAC 不匹配) | GCM tag 校验失败,密文或 tag 被篡改 |
-6 | LIB_NOT_LOADED | 底层加密库未加载 | 保留兼容位;纯 Python 实现始终可用,正常不会出现 |
-7 | REDIS_ERROR | Redis 查询异常 | Redis 连接/命令执行失败 |
-8 | DEVID_INVALID | devID/IDA 格式不合法 | 设备 ID 长度不是 36/32/72,或含非法字符 |
-9 | HEX_PARSE_FAILED | hex 字符串解析失败 | 密文或明文 hex 含非十六进制字符、长度为奇数 |
-99 | UNKNOWN_ERROR | 未知错误 | 兜底错误码 |
SessionKeyStore:会话密钥查询
会话密钥由认证服务器在 SM2 密钥协商(握手) 成功后写入 Redis,业务后端通过 [session_store.py](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-backend-sdk/src/qxj_backend_sdk_auth/redis/session_store.py) 中的 SessionKeyStore 只读查询。
Redis 物理键规则(纯字符串拼接,不另加分隔符):
{key_prefix}sm2:device_session_key:{device_id}因此 key_prefix 必须与认证服务器缓存配置逐字符对齐,末尾的冒号要包含在前缀里:
| 接入环境 | key_prefix | 实际物理键 |
|---|---|---|
| 正式认证后端(backend-admin) | "qxj:1:" | qxj:1:sm2:device_session_key:{uuid} |
| mock-demo(after) | "qxj:mock:" | qxj:mock:sm2:device_session_key:{uuid} |
| 无前缀的裸 Redis | "" | sm2:device_session_key:{uuid} |
取值与解码策略:
GET物理键,未命中返回None(上层映射为-7/-3)。- 命中后先按 JSON 解析:认证服务器的 django-redis 统一使用
JSONSerializer,字符串写入后在 Redis 里是带引号的形式(如"ABCD1234…"),解析后还原为字符串。 - JSON 解析失败时,退回按纯 hex 原文兼容(如手工
SET的测试数据)。 - 全过程 绝不 pickle,避免恶意 Redis 返回的字节流触发反序列化 RCE;最终统一返回大写 hex。
- 只读、不刷新 TTL——TTL(7 天)完全由写入端控制。
客户端懒加载:构造时可不传客户端,首次查询时若提供了 redis_url 则 redis.from_url(...),否则尝试连接本地默认 Redis。生产环境建议显式传入全局单例:
import redis
from qxj_backend_sdk_auth import SessionKeyStore
# decode_responses=False:保留 bytes,由 SDK 自行解码
client = redis.Redis.from_url("redis://:Nsp123456!@127.0.0.1:6379/1")
store = SessionKeyStore(client=client, key_prefix="qxj:mock:")
key_hex = store.get_session_key("b7a1c9d4-1234-4abc-8def-000000000001")2
3
4
5
6
7
64B 标准信封
所有加密业务帧共用同一个 64 字节统一通信头(全部大端无符号),定义见 [envelope.py](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-backend-sdk/src/qxj_backend_sdk_cryptography/envelope.py)。 它与 SM2 握手帧、C 库 64B 帧头布局完全一致,区别只在加密模式字节(握手帧为 0x00,业务帧为 0x40)。
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 1 | 版本号 version | 固定为 1 |
| 1 | 1 | 主命令码 main_cmd | 0 密钥协商 / 1 报警 / 2 加密完保 |
| 2 | 2 | 子命令码 sub_cmd | 最高位表示方向:0 上行、1 下行;低 15 位为功能码 |
| 4 | 2 | 总长度 total_len | 整个报文长度(头 + 密文 + tag) |
| 6 | 2 | 帧序号 frame_seq | 抗重放,协商阶段可为 0 |
| 8 | 36 | 发送方 ID sender | ASCII UUID 原始字节,右补 0x00;上行=设备 IDA,下行=服务器 IDB |
| 44 | 1 | 加密认证模式 crypto_mode | 高 4 位加密算法 / 低 4 位认证算法;SM4-GCM = 0x40 |
| 45 | 16 | IV | SM4-GCM 初始向量,随机 16 字节 |
| 61 | 3 | 保留字段 reserved | 必须全 0 |
| 64 | N | 密文 cipher | SM4-GCM 密文,长度与明文相同 |
| 64+N | 16 | 认证码 mac | GCM 消息认证码(完整 16B tag) |
默认常量:DEFAULT_MAIN_CMD=2、上行子命令 DEFAULT_SUB_CMD_UP=0x0001、下行子命令 DEFAULT_SUB_CMD_DOWN=0x8002、DEFAULT_CRYPTO_MODE=0x40。
设备 ID 规范化 normalize_ida 接受三种形态,其它长度一律报错(-8 DEVID_INVALID):
| 输入形态 | 长度 | 处理方式 |
|---|---|---|
| 带连字符 UUID | 36 | 形如 b7a1c9d4-1234-4abc-8def-000000000001,直接采用 |
| 无连字符 hex | 32 | 按 8-4-4-4-12 重排为标准 UUID |
| ASCII UUID 的 hex 化 | 72 | 即管理端 keymgr 的 ida_hex 形态,hex 解码后使用 |
为什么把发送方 ID 塞进帧头?
这样密文是自描述的:解密端无需调用方再带设备 ID,也无需解析 token,只按固定偏移切出 sender 就能查到会话密钥。上行帧 sender 是设备 IDA、下行帧 sender 是服务器 ID_B,同一布局覆盖双向通信。
SM4-GCM 加解密
高层函数内部由 Sm4GcmCrypto([session_crypto.py](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-backend-sdk/src/qxj_backend_sdk_cryptography/session_crypto.py))完成「查密钥 → 组装帧 → 算 tag」的全过程,对外提供 encrypt_str / encrypt_hex / decrypt_str / decrypt_hex 四个方法。
加密过程:
normalize_ida规范化设备 ID;- 从 Redis 取会话密钥——密钥只接受 16 或 32 字节,SM4-128 实际使用其前 16 字节;
IV = os.urandom(16)生成每次一密的随机向量;- SM4-GCM 加密(gmssl 未提供 GCM,SDK 基于 SM4-ECB 自实现 CTR + GHASH);
- 输出大写 hex:64B 帧头(
total_len自动填实际总长)+ 密文 + 16B tag。
解密过程(严格校验,任一不过即失败):
- hex 解码(非法字符 →
-9),长度不足 64B →-4; - 帧头逐字段校验:
version==1、main_cmd∈{0,1,2}、sender 必须匹配 UUID 正则、reserved全0、total_len与实际帧长一致——不符均为-4 CIPHER_FORMAT_INVALID; - 从 sender 取设备 ID → 查会话密钥;
- 校验 GCM tag,失败即
-5 AUTH_FAILED(密文或 IV 被篡改、密钥不对都会落此码); - 通过后按调用要求返回 UTF-8 字符串或 hex。
DRF 接入实战
以下代码全部来自教学工程 [qxj-backend-mock-demo](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-backend-mock-demo) 的 after(SDK 方案),可直接作为业务后端的接入模板。框架为 Django 4.2 + Django REST Framework 3.15。
1. 配置与初始化
在 Django settings 中声明 Redis 连接与前缀(after 工程默认值):
# 与认证服务器共享同一 Redis 实例(这里是 mock 使用的 1 号库)
QXJ_REDIS_URL = "redis://:Nsp123456!@127.0.0.1:6379/1"
# mock 环境前缀;对接正式 backend-admin 时改为 "qxj:1:"
QXJ_REDIS_KEY_PREFIX = "qxj:mock:"
# 真机联调时可在 .env 直接配置服务器公钥;留空则读本地演示密钥文件
QXJ_SERVER_PUBLIC_KEY = ""
QXJ_KEYPAIR_FILE = BASE_DIR / "demo_keys" / "server_sm2.json"2
3
4
5
6
7
8
9
首次跑 mock 时执行 python tools/init_demo.py,脚本会:生成 SM2 演示密钥对写入 demo_keys/、注册固定设备 b7a1c9d4-1234-4abc-8def-000000000001、写入随机会话密钥,并做一次「验签 + 加解密往返」自检。
2. DRF 认证类
业务后端唯一需要的胶水代码——[after/auth_app/qxj_auth.py](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-backend-mock-demo/after/auth_app/qxj_auth.py):
from types import SimpleNamespace
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
from .users import DEMO_USERS_BY_ID
_redis_client = None
def get_redis_client():
"""全局复用一个 Redis 连接(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):
"""Bearer NSP-SM access token 认证。"""
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
token = parts[1].strip()
# 高层接口:设备 ID 自动从 token 的 custom.device_id 提取,
# 会话密钥自动按设备 ID 从 Redis 查询。
public_key = _load_server_public_key()
device_id, expire_time, code = verify_access_token(
token,
public_key,
get_redis_client(),
key_prefix=settings.QXJ_REDIS_KEY_PREFIX,
)
if code != 0:
# 0=完全通过; 1=仅外层通过(设备未握手); 其余均为失败
raise AuthenticationFailed(
"access token 验证失败,result_code={}(0 通过 / 1 仅外层 / 负值失败)".format(code)
)
# user_id 从 token payload 解析(验签已通过,可信)
user_id = get_user_id(token)
user_info = DEMO_USERS_BY_ID.get(user_id)
if user_info is None:
raise AuthenticationFailed("token 中的用户不存在: user_id={}".format(user_id))
user = SimpleNamespace(
id=user_info["id"],
username=user_info["username"],
is_authenticated=True,
is_active=True,
)
auth_detail = {
"device_id": device_id,
"expire_time": expire_time,
"user_id": user_id,
"role": user_info["role"],
"token": token,
}
return (user, auth_detail)
def authenticate_header(self, request):
return self.keyword
def _load_server_public_key():
"""优先读 .env 的 QXJ_SERVER_PUBLIC_KEY;未配置则读演示密钥文件。"""
if getattr(settings, "QXJ_SERVER_PUBLIC_KEY", ""):
return settings.QXJ_SERVER_PUBLIC_KEY
import json
try:
with open(settings.QXJ_KEYPAIR_FILE, "r", encoding="utf-8") as f:
return json.load(f)["public_key"]
except FileNotFoundError:
raise AuthenticationFailed(
"未配置认证服务器公钥:请在 .env 设置 QXJ_SERVER_PUBLIC_KEY,"
"或(浏览器 mock 演示)执行 python tools/init_demo.py"
)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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
要点:
Authorization头缺失或格式不符时返回None(交给 DRF 的WWW-Authenticate流程);code != 0一律AuthenticationFailed,包括code=1;- 验签通过后,token 中的
user_id已可信,再映射为本地用户对象; request.auth即返回的认证详情字典,视图中可直接取device_id。
3. 明文业务接口:ProfileView
只认证、不加密的普通接口——[after/auth_app/views.py](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-backend-mock-demo/after/auth_app/views.py):
class ProfileView(APIView):
authentication_classes = [QxjAccessTokenAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request):
user_info = DEMO_USERS_BY_ID[request.user.id]
return JsonResponse(
{
"success": True,
"user": {
"id": user_info["id"],
"username": user_info["username"],
"name": user_info["name"],
"role": user_info["role"],
},
# request.auth 是认证类返回的认证详情
"device_id": request.auth["device_id"],
"expire_time": request.auth["expire_time"],
"resources": ADMIN_RESOURCES
if user_info["role"] == "admin"
else ADMIN_RESOURCES[:1],
}
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
4. 加密业务接口:EchoView
请求与响应都走 SM4-GCM 信封:前端把密文放在 {"frame": "<hex>"} 中,后端解密 → 处理 → 重新加密返回。
class EchoView(APIView):
"""加密回声接口:演示 sm4_gcm_decrypt / sm4_gcm_encrypt。"""
authentication_classes = [QxjAccessTokenAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
frame_in = request.data.get("frame")
if not frame_in:
return JsonResponse(
{"success": False, "message": "缺少 frame 字段(SM4-GCM 信封 hex)"},
status=400,
)
redis_client = get_redis_client()
# 1) 解密请求信封。设备 ID 由 SDK 自动从帧头 sender 提取。
plain_in, code = sm4_gcm_decrypt(
frame_in, redis_client, key_prefix=settings.QXJ_REDIS_KEY_PREFIX
)
if code != 0:
return JsonResponse(
{"success": False, "message": "请求信封解密失败,code={}".format(code)},
status=400,
)
try:
payload = json.loads(plain_in)
message = str(payload.get("message", ""))
except (ValueError, TypeError):
return JsonResponse(
{"success": False, "message": "解密后的请求不是合法 JSON"},
status=400,
)
# 2) 业务处理(这里简单回声)
device_id = request.auth["device_id"]
response_obj = {
"success": True,
"reply": "后端已收到:{}".format(message),
"device_id": device_id,
}
plain_out = json.dumps(response_obj, ensure_ascii=False)
# 3) 用同一设备的会话密钥把响应加密成信封返回。
frame_out, code = sm4_gcm_encrypt(
device_id,
plain_out,
redis_client,
key_prefix=settings.QXJ_REDIS_KEY_PREFIX,
)
if code != 0:
return JsonResponse(
{"success": False, "message": "响应信封加密失败,code={}".format(code)},
status=500,
)
return JsonResponse({"success": True, "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
47
48
49
50
51
52
53
54
55
56
57
58
前端配套调用方式见 前端 SDK · 真实接入示例,两侧使用同一个会话密钥与同一种信封,端到端全密文。
子包直调(高级用法)
需要批量验证、复用连接或自定义日志时,可绕过高层函数直接使用子包组件。
复用 TokenVerifier 与 SessionKeyStore(适合网关、中间件中验证大量 token):
from qxj_backend_sdk_auth import SessionKeyStore, TokenVerifier, describe
store = SessionKeyStore(client=redis_client, key_prefix="qxj:1:")
verifier = TokenVerifier(
public_key,
session_store=store,
leeway=0,
verify_hmac=True, # 对应高层的 skip_hmac=False
log_fn=logger.info, # 可选:接收各阶段日志
)
for token in token_list:
result = verifier.verify(token)
# result 是 VerifyResult:
# result.code / result.message / result.ok / result.outer_ok
# result.user_id / result.device_id / result.exp / result.jti
# result.hmac_expected / result.hmac_received / result.stages
if not result.ok:
logger.warning("拒绝 token: %s", describe(result.code))2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
只解析、不验签:parser 模块提供一组零信任成本的取值函数(仅在验签通过后的 token 上使用才安全):
from qxj_backend_sdk_auth import (
parse_token, # -> (header, payload, signature)
get_header, # header dict
get_payload, # payload dict
get_custom, # payload.custom dict(device_id/user_id/exp_s/hmac)
get_jti, # jti 字符串
get_user_id, # custom.user_id(int)
get_device_id, # custom.device_id(UUID 字符串)
get_exp, # exp(int)
get_token_type, # "access" / "refresh"
)2
3
4
5
6
7
8
9
10
11
直接调用密码学原语(自定义协议、单元测试):
from qxj_backend_sdk_cryptography import sm2, sm3, sm3_hmac, sm4_gcm, Sm4GcmCrypto
from qxj_backend_sdk_cryptography.envelope import (
build_header, parse_header, assemble, split_cipher,
normalize_ida, describe_envelope,
)
# describe_envelope 可逐字段渲染一个完整帧,排查线上密文问题时非常实用2
3
4
5
6
7
CLI 命令行工具
安装后自动注册 qxj-backend-sdk-auth 命令,适合在服务器上快速排查 token,无需写脚本:
# 完整验证(外层 + 内层 HMAC);--no-hmac 只验外层,--leeway 给时钟宽容
qxj-backend-sdk-auth verify \
-t "eyJhbGciOi..." \
-k "<128 hex 服务器公钥>" \
--redis-url "redis://:Nsp123456!@127.0.0.1:6379/1" \
--key-prefix "qxj:mock:" \
-v
# 解析 token 而不验签(-f 支持点号路径取单个字段)
qxj-backend-sdk-auth parse -t "eyJhbGciOi..."
qxj-backend-sdk-auth parse -t "eyJhbGciOi..." -f payload.custom.device_id
# 查看版本
qxj-backend-sdk-auth version2
3
4
5
6
7
8
9
10
11
12
13
14
verify 命令的退出码:外层通过(code 为 0 或 1)返回 0,否则返回 1,可直接用于 shell 健康检查。简洁模式输出 <code>\t<中文说明>,-v 输出 user_id、device_id、期望/实际 HMAC 及各阶段状态。
改造前后对照
mock-demo 的 before / after 两个版本展示了同一条业务链路在接入前后的差异:
| 维度 | before(传统方案) | after(QXJ SDK 方案) |
|---|---|---|
| Token 算法 | PyJWT,HS256 对称密钥 | NSP-SM:外层 SM2 非对称签名 |
| 密钥分布 | 验签密钥 = 签名密钥,所有后端都持有共享密钥 | 业务后端只有公钥,私钥仅认证服务器持有 |
| 设备绑定 | 无,token 可在任意环境使用 | 内层 SM3-HMAC 绑定完成握手的具体设备 |
| Token 存储 | 双 token 存浏览器 localStorage | 由平板原生侧安全存储,网页通过 JSBridge 取短期 token |
| Bearer 解析 | 视图里手写 parse_bearer_token | DRF BaseAuthentication 认证类统一接管 |
| 接口形态 | login / refresh / profile / resources 全明文 | profile 明文 + echo 端到端 SM4-GCM 密文 |
| 业务侧代码量 | 密钥散落在配置与视图中 | 一个认证类 + 三个函数调用 |
| 密码合规 | 国际算法 | SM2 / SM3 / SM4 全国密体系 |
排障建议
认证类问题(VerifyCode)
| 现象 | 可能原因与排查 |
|---|---|
频繁 -7 | 设备未完成 SM2 握手;会话密钥过期(TTL 7 天);或 key_prefix 与认证服务器不一致,用 redis-cli KEYS '*sm2:device_session_key*' 核对物理键 |
-3 | 公钥配错(环境不一致)、token 不是本环境签发;确认公钥为 128 hex、不带 04;alg 非 NSP-SM 也归此码 |
-4 | 服务器间时钟不同步,先对时(NTP);必要时设 leeway;token 本身过期属正常拒绝 |
-5 | token 结构不完整或 exp_s/exp 不一致,多半是混用了不同版本签发端 |
-6 | HMAC 重算不一致——会话密钥与签发时不是同一份(错连 Redis 库/前缀),或 token 被篡改 |
-8 | Redis 故障或网络不通,检查连接 URL、密码与连通性;此时应返回 5xx 而不是静默放行 |
加解密问题(CryptoErrorCode)
| 现象 | 可能原因与排查 |
|---|---|
解密 -4 | 帧被截断、字段被改动或不是本 SDK 产生;可用 describe_envelope(frame_bytes) 或 CLI 查看逐字段诊断 |
解密 -5 | GCM tag 失配:密文/IV 被篡改,或 sender 对应的密钥与加密时不同;注意不要 hex 转码过程中改写大小写之外的内容 |
加密/解密 -3 | Redis 中无该设备密钥,先确认握手成功且两端前缀、库号一致 |
-8 / -9 | 设备 ID 或 hex 字符串格式问题;确认 UUID 为 36/32/72 字符、密文 hex 长度为偶数 |
不要为了“让接口通”而放宽校验
把 skip_hmac=True 用于生产、或把 code=1 当作通过,都会让「设备绑定」这层防护失效。排障时应定位密钥与前缀的真实口径,而不是关闭校验。
延伸阅读
- REST API 参考:登录、刷新、SM2 握手与全部管理端接口
- JavaScript 前端 SDK:浏览器侧
verifyJwt、SM4 加密封装与 JSBridge - 国密算法与加密特性:SM2/SM3/SM4 在体系中的分工
- 权限与访问控制:认证之后的授权模型