Files
wxmp_backend/docs/wechat-api-signature-guide.md
T
broccoliandClaude Fable 5 1d946046d2 feat: 微信登录与认证完善 + 一键 docker compose 启动
登录流程(以 openid 为唯一标识):
- auth.service.login 改为 upsert:openid 在库续登,不在库自动建用户签 token
- 个人主体无 getPhoneNumber 权限,故不强制手机号注册;register 接口保留备未来用
- wechat.service 新增 getAccessToken(Redis 缓存)、getPluginOpenPid、getUserPhoneNumber
- 新增 /api/wechat/plugin-openpid、/api/wechat/phone 接口

schema 与迁移:
- User 增加 openpid(可空唯一) 兼容插件场景;openid 保持必填主键
- 新增 add_openpid_optional_openid、openid_required_primary 迁移

部署:
- docker-compose.yml 改为 docker compose up -d 一键启动 postgres+redis+app
- app 容器内用服务名连 db/redis,启动自动跑 prisma migrate deploy
- 端口统一 3090(Dockerfile EXPOSE、compose 映射同步)
- 新增 docs/wechat-api-signature-guide.md API 签名手册

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:21:09 +08:00

196 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 微信小程序「API 接口安全」(数字签名)指导手册
> 对应获证地址:`developers.weixin.qq.com/miniprogram/dev/server/getting_started/api_signature.html`
> 配套文件:本目录 `key/` 下的三个密钥文件(**切勿提交到 git,已忽略**)。
---
## 一、这份文档究竟是干嘛的?
微信小程序服务端在调用微信的**敏感开放接口**(如获取 `access_token`、订阅消息、手机号快速验证、
用户信息等)时,传统做法是「AppSecret + IP 白名单」。这套 **API 数字签名**是微信新推出的
**更强的接口调用安全机制**,做到两层防护:
1. **内容加密** —— 请求/响应体用对称密钥加密,网络上不再是明文 JSON,防泄漏。
2. **签名验签** —— 双方互相信任消息「确实来自对方、且未被篡改」,具备**不可否认性**。
一句话:**它让你的服务器调用微信接口时,不依赖 IP 白名单也能安全、防篡改。**
适用场景:服务器 IP 不固定(云函数/CDN/多地域)时,比维护 IP 白名单更省心;以及需要更高安全等级的敏感接口。
---
## 二、你拿到的三个密钥分别是什么、有什么用
你的三件套(`key/` 目录),经核对是 **AES-256-GCM 对称加密 + RSA 签名**的组合:
| 文件 | 实际类型 | 作用 |
|---|---|---|
| **对称密钥.txt** | base64 编码的 **256-bit AES key**`AES-256-GCM` | **加密请求/响应正文** |
| **非对称密钥.txt** | **RSA 私钥**`BEGIN RSA PRIVATE KEY` | **对请求签名**`RSAwithSHA256` PSS),证明请求来自你 |
| **开放平台证书.cer** | 微信**平台公钥证书**`BEGIN CERTIFICATE` | **验签微信的回包**,确认回包来自微信且未被改 |
补充:
- **验证签名用的「应用公钥」**:在 MP 后台生成密钥对时,花生处会显示公钥字段,**需要把应用公钥填回配置框上传**,微信用它验证你发的请求签名。你的 `非对称密钥.txt` 是私钥(自留),公钥不在此文件中。
- **三个「编号(Sn)」**:对称密钥编号、非对称密钥编号、证书编号都在 MP 后台「API 安全」页面查看/记录,**不在密钥文件里**,请单独保存,后续算法和请求头需要用到。
---
## 三、算法与签名步骤
### 3.1 对称加密(加密请求正文)
- 算法:`AES-256-GCM`(你的 key 取 `对称密钥.txt` 的 base64 解码)。
- 明文构成:把原始业务字段合并上三个安全字段后 JSON 序列化:
- `_n`:随机串(随机数)
- `_appid`:你的小程序 AppID
- `_timestamp`:与请求头 `Wechatmp-TimeStamp` 一致的统一时间戳(毫秒)
- GCM 附加认证数据(AAD):`urlpath|appid|timestamp|sn`(竖线分隔)。
- 输出三个 base64 字段:`iv`12 字节随机)、`data`(密文)、`authtag`(认证标签)。
- 加密后的请求体 JSON(即下文签名用的 `postdata`)形如:
```json
{"_version": 1, "_appid": "...", "_timestamp": 1760000000000, "_sn": "密钥编号", "iv": "...", "data": "...", "authtag": "..."}
```
### 3.2 RSA 签名(对加密后的请求签名)
- 算法:`RSAwithSHA256`**PSS 填充**salt 长度 **32**。
- 拼接待签名串,字段之间用 `\n` 连接,**末尾无多余换行**
```
urlpath\nappid\ntimestamp\npostdata
```
其中:
- `urlpath` = **带 `https://` 的完整接口路径,不含 Query**,例如
`https://api.weixin.qq.com/wxa/business/getuserphonenumber`
- `appid` = 小程序 AppID
- `timestamp` = 同上传到密文 `_timestamp` 的值(毫秒);
- `postdata` = 第一步加密后的请求体 JSON 字符串。
- 用你的 `非对称密钥.txt`(RSA 私钥)对上述字符串签名,结果 base64。
### 3.3 请求头
把下面的值放到 HTTP 请求头:
```
Wechatmp-AppId: <小程序 appid>
Wechatmp-TimeStamp: <timestamp>
Wechatmp-Signature: <base64 的 RSA 签名>
```
请求体 `Content-Type: application/json`body 就是加密后的 JSON。
### 3.4 验签微信回包
- 回包头带 `Wechatmp-Serial`(新证书编号)与 `Wechatmp-Signature`。
- 用 `开放平台证书.cer` 里的平台公钥验签(同样 `RSAwithSHA256` PSS),确认无误后再用对称密钥解密响应正文(算法与加密一致)。
- 若响应头出现 `Wechatmp-Serial-Deprecated` 且与你证书号匹配,说明平台证书即将过期,需及时在 MP 后台更新。
---
## 四、在 MP 后台如何配置(拿这三件套)
路径:`mp.weixin.qq.com` → 「开发」→「开发管理」→「开发设置」→「**API 安全**」→ 管理员微信扫码验证。
1. **对称密钥**:点「随机生成密钥」→「下载密钥」→「确认」。得到 key(`对称密钥.txt`)与编号。
2. **非对称密钥**:点「随机生成密钥对」→「下载私钥」(`非对称密钥.txt`)自留 → 把**公钥**填入输入框上传 →「确认」。
3. **平台证书**:配置好应用公钥后,页面下载「开放平台证书」(`开放平台证书.cer`)。
4. 记录三个**编号(Sn)**,妥善保管(尤其私钥与大对称密钥)。
---
## 五、在项目里如何开发(集成参考)
本仓库当前 `wechat.service.ts` 用的是传统 AppSecret 直连(`code2Session`)。调用**敏感接口**时可按本机制
新增一个「签名 + 加密」请求封装。以下为可参考的 Node.js/TS 实现蓝图(基于 `node:crypto`,无需新依赖):
```ts
import { createCipheriv, createSign, createVerify, constants } from 'node:crypto';
import axios from 'axios';
// 配置:从 ConfigService / env 读取,切勿硬编码
const CFG = {
appid: process.env.WX_APPID,
// 对称密钥(base64
symmetricKey: process.env.WX_API_SYMMETRIC_KEY, // 对应 对称密钥.txt
symmetricSn: process.env.WX_API_SYMMETRIC_SN, // 编号 Sn
// RSA 私钥(PEM 字符串)
privateKey: process.env.WX_API_PRIVATE_KEY, // 对应 非对称密钥.txt
privateSn: process.env.WX_API_SN, // 非对称密钥编号
// 平台证书(PEM),用于回包验签
platformCert: process.env.WX_PLATFORM_CERT, // 对应 开放平台证书.cer
};
function sha256PssSign(privateKeyPem: string, message: string): string {
const sign = createSign('RSA-SHA256');
sign.update(message, 'utf8');
// PSS + saltLength 32
const sig = sign.sign({ key: privateKeyPem, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 });
return sig.toString('base64');
}
/** 加密请求体 -> 返回 { body, signature, timestamp } */
function encryptAndSign(data: object, urlpath: string, now: number) {
const key = Buffer.from(CFG.symmetricKey, 'base64');
const iv = require('crypto').randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
// 明文:业务字段 + _n/_appid/_timestamp
const plain = JSON.stringify({ ...data, _n: Math.random().toString(36).slice(2), _appid: CFG.appid, _timestamp: now });
// AAD = urlpath|appid|timestamp|sn
cipher.setAAD(Buffer.from(`${urlpath}|${CFG.appid}|${now}|${CFG.symmetricSn}`));
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const body = JSON.stringify({
_version: 1, _appid: CFG.appid, _timestamp: now, _sn: CFG.symmetricSn,
iv: iv.toString('base64'), data: enc.toString('base64'), authtag: cipher.getAuthTag().toString('base64'),
});
// 待签名串 urlpath\nappid\ntimestamp\npostdata
const toSign = `${urlpath}\n${CFG.appid}\n${now}\n${body}`;
const signature = sha256PssSign(CFG.privateKey, toSign);
return { body, signature, timestamp: now };
}
/** 调用示例:微信「获取用户手机号」类敏感接口 */
async function callWechatApi(urlpath: string, payload: object) {
const now = Date.now();
const { body, signature, timestamp } = encryptAndSign(payload, urlpath, now);
const { data } = await axios.post(urlpath, body, {
headers: {
'Content-Type': 'application/json',
'Wechatmp-AppId': CFG.appid,
'Wechatmp-TimeStamp': String(timestamp),
'Wechatmp-Signature': signature,
},
});
return data; // 注意:还需按第四节用平台证书验签 + 对称解密后再取用
}
```
> 说明:`axios` 已在依赖中;`node:crypto` 为 Node 内置,无需安装。正式落地时把这个封装挪进
> `src/wechat/`(如 `src/wechat/api-signature.ts`),配置项接入 `.env`。
---
## 六、注意事项(务必读)
1. **私钥/对称密钥绝不硬编码、绝不进 git**。本手册代码中的 `process.env.*` 即为此;`key/` 已被 `.gitignore` 忽略。
2. **编号(Sn)要单独记录**:文件里只有密钥本身,三个编号在 MP 后台「API 安全」页查看,算法和后续轮换都要用。
3. **部分接口不支持加密**:文档明确「资源上传类 API 暂不支持加密」,且**并非所有接口都要求**这套签名。调用前先看目标接口文档,确认它走哪套鉴权(AppSecret 直连 or 数字签名)。
4. **登录接口 `jscode2session` 当前不走这套签名**,保持现有 AppSecret 直连即可(已实现)。
5. 签名结果每次不同(PSS 含随机因子),属正常。
6. **证书过期**:关注 `Wechatmp-Serial-Deprecated` 响应头,过期前到 MP 后台更新平台证书。
7. 文档示例代码里的密钥是演示用的,不能用。
---
## 七、接入本仓库的待办清单(可按需推进)
- [ ] 在 `.env.example` 增加 `WX_API_SYMMETRIC_KEY / WX_API_SYMMETRIC_SN / WX_API_PRIVATE_KEY / WX_API_SN / WX_PLATFORM_CERT` 占位
- [ ] `src/wechat/` 新增 `api-signature.ts` 封装(按第五节蓝图,含回包验签 + 解密)
- [ ] 用真实小程序 AppID 调通一个敏感接口的加解密全链路
- [ ] 若长期大量调用,考虑把对称密钥/私钥换成环境变量注入(而非文件),并做密钥轮换流程