谈谈微信小程序扫码开放平台的设计与实现
最近在折腾一个需求:把微信小程序扫码登录的功能抽出来,做成类似开放平台的东西,让别人也能接入使用。
背景
我之前在 auth 模块里写了一堆小程序扫码相关的接口——生成二维码、查状态、拿用户信息之类的。但这些都是自己内部用的,代码也耦合在一起,谈不上什么复用性。
这次想做的事情其实很简单:
别人在我这里注册一个应用,拿到 appKey 和 appSecret,然后调我的接口生成二维码,用户扫码之后我通知他们结果。
说白了就是一个扫码登录的 SaaS 服务。
整体架构
我把这个功能拆成了一个独立的 open-platform 模块,和原有的 auth 模块完全解耦。
外部应用 → 签名调用API → 生成二维码 → 用户扫码
↓
小程序上报用户信息
↓
异步回调 + 轮询查询
外部应用注册之后会拿到 appKey 和 appSecret,之后所有请求都通过 HMAC-SHA256 签名验证,不用再搞什么账号密码登录那一套。
认证方案
这个讨论了很久,最后选了 HMAC-SHA256 签名认证。为什么不用 API Key 直传或者 OAuth?
- API Key 太简单了,被抓包直接就泄露了
- OAuth 对接成本太高,我这就是个扫码服务,不值得搞那么重
签名算法是这样的:
签名字符串 = HTTP方法 + "\n" + 请求路径 + "\n" + 时间戳 + "\n" + 请求体
签名 = HMAC-SHA256(签名字符串, appSecret)
请求带上三个 Header 就行:
X-App-Key: opk_xxx
X-Timestamp: 1715932800
X-Signature: <签名值>
时间戳超过 5 分钟就拒绝,防止重放攻击。
关于 appSecret 的存储,HMAC 验证需要用到原文,不能单向哈希。所以最后用 AES-256-GCM 加密存储,密钥放环境变量里。
数据库设计
就两张表。
tb_open_app 是应用注册表:
| 字段 | 说明 |
|---|---|
| appName | 应用名称 |
| appKey | 分配的 appKey |
| appSecretEncrypted | AES 加密后的 secret |
| callbackUrl | 回调地址 |
| status | 0待审核 1启用 2禁用 |
tb_qr_session_log 是扫码会话日志:
| 字段 | 说明 |
|---|---|
| token | 会话 token |
| appKey | 所属应用 |
| status | -1未扫码 0已扫码 1已确认 |
| scanData | 用户信息 JSON |
| callbackStatus | 回调结果 |
没用复杂的关联关系,就简单的两张表加上 Redis 缓存。
Redis 的 key 设计
op:session:{token} # 会话数据,1小时过期
op:session:{token}:scan # 扫码用户信息
op:rate:{appKey}:{日期} # 每日请求计数
用 op: 前缀和原有的 screen_key: 区分开,之前代码里还有个坑——存储 key 用了两种分隔符:screen_key:${token} 和 screen_key/${token},排查的时候差点没找到。
回调通知
用户扫码确认之后,我这边会做两件事:
- 异步 POST 到注册时填的 callbackUrl
- 外部应用也可以轮询
/qr/status接口
回调也是带签名的,外部应用可以用 appSecret 验证请求确实来自我的平台,防止有人伪造回调。
失败的话会重试 3 次,间隔分别是 1s、2s、4s。
踩坑记录
小程序 code2Session 返回值取错了一层
服务端返回的是:
json
{ "status": true, "data": { "openid": "xxx" } }
小程序那边直接 response.data.openid 去取,当然拿到的是 undefined。应该是 response.data.data.openid。
这问题排查了挺久,因为小程序端没法方便地打断点。
handleLogin 和 handleAuth 并行执行
小程序的 onLoad 里同时调了 handleLogin(获取 openid)和 handleAuth(展示授权弹窗),两个都是异步的。问题是用户点确认的时候,code2Session 请求还没回来,openid 还是空的。
改成 handleLogin().then(() => handleAuth()) 串行执行就好了。
userInfo 不应该缓存
之前的代码把 wx.getUserProfile 的结果缓存到了 Storage 里。用户换了头像昵称之后,缓存里还是旧的,拿到的就是过期信息。
干掉了缓存逻辑,每次扫码都重新弹窗获取。
环境变量多了一个逗号
env
miniAppId="wxf6fafa413396f3bd",
末尾多了个逗号,导致 appid 变成了 wxf6fafa413396f3bd,,微信接口返回 invalid appid。这种问题肉眼根本看不出来。
接口列表
公开接口(无需签名):
POST /open-platform/register— 注册应用
小程序端接口(无需签名):
GET /open-platform/mini/code2Session— code 换 openidGET /open-platform/mini/info— 获取会话信息POST /open-platform/mini/confirm— 确认授权PUT /open-platform/mini/status— 更新扫码状态
应用接口(需要签名):
POST /open-platform/app/info— 查看应用信息POST /open-platform/app/update— 更新应用POST /open-platform/app/regenerate-secret— 重置密钥POST /open-platform/qr/getToken— 创建扫码会话GET /open-platform/qr/getImg— 获取二维码图片GET /open-platform/qr/status— 查询扫码状态
管理员接口:
GET /open-platform/admin/apps— 查看所有应用POST /open-platform/admin/approve/:id— 审核通过POST /open-platform/admin/reject/:id— 拒绝
后续规划
目前还是个基础版本,后续可以考虑加上:
- 速率限制(现在虽然数据库里有
dailyRateLimit字段,但还没实现拦截逻辑) - 回调重试的定时任务
- 应用数据统计面板
- Webhook 管理页面
先把核心流程跑通再说吧。