背景与目标

项目原本使用短信验证码登录,但短信通道出现故障后,继续依赖单一供应商并不是最稳妥的选择。考虑到产品已经有认证过的微信小程序,并且小程序具备手机号快速验证能力,我们决定增加小程序码登录。

这里使用的不是微信开放平台的网站应用二维码,而是微信小程序码。用户在电脑打开登录页后,用微信扫码进入小程序,授权手机号并确认,网页随后自动完成登录。

最终目标包括:

  • 网页不再依赖短信验证码;
  • 继续保留密码登录;
  • 小程序负责验证微信身份和手机号;
  • 网页沿用原有 JWT 登录态;
  • 二维码过期、重复扫码和并发换票都必须安全失败。

网页端小程序码登录效果

图中的二维码属于短时登录会话,发布文章时已经失效。

整体方案

整个流程由网页、小程序、业务后端、Redis 和微信接口共同完成:

  1. 网页向后端创建短时登录会话。
  2. 后端返回公开的 sessionId 和仅当前浏览器持有的 pollToken
  3. 后端把 sessionId 写入小程序码的 scene
  4. 用户扫码进入小程序确认页。
  5. 小程序等待用户主动授权手机号。
  6. 小程序提交 sessionIdwx.login code 和手机号动态 code。
  7. 后端向微信校验凭证并关联业务用户。
  8. 网页轮询到确认状态,用 pollToken 一次性换取 JWT。
  9. 登录会话立即失效,不能再次换票。

这套设计的核心是:小程序只确认用户身份,网页 JWT 只交给最初创建会话的浏览器。

双票据安全模型

二维码是一种公开信息,可能被拍照、截图或转发,因此不能在二维码中放 JWT、手机号、用户 ID 或权限信息。

最终使用两份随机票据:

1
2
3
二维码中    sessionId
浏览器中 sessionId 和 pollToken
Redis 中 sessionId 和 SHA-256 pollToken

sessionId 只负责定位会话。pollToken 不进入二维码,小程序也不会收到它。即使其他人拿到了二维码,也无法查询完整状态或领取 JWT。

服务端只保存 pollToken 的哈希,避免 Redis 数据意外暴露时直接泄漏换票凭据。

Redis 状态机

扫码登录是一段跨设备的异步流程,不能用一次普通请求完成。登录会话保存在 Redis 中,并设置 3 分钟 TTL。

状态 含义
WAITING 网页等待扫码
SCANNED 小程序已打开并等待确认
CONFIRMED 身份确认成功并等待网页换票
CONSUMED 网页已经领取 JWT
EXPIRED 会话已经过期

Redis 数据大致如下:

1
2
3
4
5
6
7
8
9
key: auth:miniapp-code:{sessionId}
ttl: 180 seconds

status
pollHash
userId
maskedPhone
createdAt
confirmedAt

确认和换票使用 Lua 脚本完成原子状态转换。只有 CONFIRMED 状态可以进入 CONSUMED,所以并发请求中最多只有一个能领取登录结果。

使用 Redis 而不是 Java 进程内 Map,还能避免应用重启丢失会话,并为以后扩展多实例部署保留空间。

接口设计

后端提供六类接口:

调用方 方法 路径 作用
网页 POST /api/auth/miniapp-code/sessions 创建会话
网页 GET /api/auth/miniapp-code/sessions/{id}/image 获取小程序码
小程序 POST /api/auth/miniapp-code/sessions/{id}/scanned 标记已扫码
小程序 POST /api/auth/miniapp-code/sessions/{id}/confirm 确认身份
网页 GET /api/auth/miniapp-code/sessions/{id}/status 查询状态
网页 POST /api/auth/miniapp-code/sessions/{id}/exchange 换取 JWT

创建会话返回的数据结构如下:

1
2
3
4
5
6
7
{
"sessionId": "opaque-short-id",
"pollToken": "browser-only-secret",
"qrImagePath": "/api/auth/miniapp-code/sessions/opaque-short-id/image",
"expiresAt": 1788330000000,
"pollIntervalMs": 2000
}

网页查询状态和换票时必须通过 X-Login-Poll-Token 请求头提交私密票据。小程序端的扫码和确认接口不使用这个值。

后端实现

后端主要负责四件事:

创建登录会话

sessionIdpollToken 都使用安全随机数生成。后端把会话写入 Redis,并设置独立的接口限流,避免恶意请求持续消耗小程序码接口额度。

生成小程序码

通过 getwxacodeunlimit 生成不限量小程序码:

1
2
3
4
5
6
7
{
"scene": "l=opaque-short-id",
"page": "pages/scan-login/index",
"check_path": false,
"env_version": "trial",
"width": 360
}

登录码不能像普通分享码一样长期保存,图片接口设置 Cache-Control: no-store。微信 access_token 则可以缓存在 Redis 中,避免每次生成二维码都重新获取。

校验微信身份

小程序确认时,后端分别校验 wx.login code 和手机号动态 code,得到 openid 与手机号,再查找或创建已有业务用户。

这里不能为了兼容旧接口,在微信换号失败时相信客户端自己提交的手机号。否则客户端可以直接伪造身份。

一次性换票

小程序确认后只把 userId 写入会话,不向小程序返回网页 JWT。网页带着正确的 pollToken 换票时,后端才签发 JWT,并立即把状态改为 CONSUMED

小程序端实现

扫码登录使用独立主包页面:

1
pages/scan-login/index

不要直接复用业务首页。原有首页可能已经处理职位分享等其他 scene 参数,登录票据容易被旧逻辑误判。

扫码页面从 scene 中解析会话 ID:

1
2
const pair = scene.split('&').find(item => item.startsWith('l='))
const sessionId = pair ? pair.slice(2).trim() : ''

手机号不能静默获取,必须由用户主动点击授权按钮:

1
2
3
4
5
6
<button
open-type="getPhoneNumber"
@getphonenumber="handlePhoneNumber"
>
授权手机号并确认登录
</button>

成功回调中的手机号动态 code 与 wx.login code 是两种不同凭证,不能混用。本项目同时提交两者,因为现有用户体系既需要手机号,也需要维持 openid 关联。

小程序只展示确认结果,不接收、不保存网页 JWT。

网页端实现

网页封装一个可复用的扫码登录组件,统一处理:

  • 会话创建和二维码加载;
  • 每 2 秒轮询状态;
  • 二维码倒计时;
  • 扫码后的确认状态;
  • JWT 换票和登录跳转;
  • 过期或失败后的刷新操作。

页面切到后台时暂停轮询,重新可见后立即同步状态。这样可以减少多标签页和长时间后台页面产生的无效请求。

扫码登录和密码登录共用同一套用户信息及 token 存储逻辑,退出登录也同时清理两份状态,避免请求头残留旧 JWT。

项目原来存在主登录页、动态弹窗和历史内嵌登录框。实现时必须梳理所有入口并复用同一个组件,否则只替换主页面后,用户仍可能从其他入口进入旧短信登录流程。

环境切换

小程序码的 env_version 必须和当前联调阶段一致:

阶段
开发版 develop
体验版 trial
正式版 release

体验版联调时,先上传小程序体验版,再部署使用 trial 的网页构建。小程序正式发布后,网页也要重新构建为 release,否则普通用户无法打开对应页面。

两个实际问题

图片类型与响应头不一致

最初图片接口固定声明:

1
Content-Type: image/png

真实调用微信接口后发现,小程序码字节以 FF D8 FF 开头,实际是 JPEG。浏览器虽然可能正常显示,但错误媒体类型经过 CDN 或严格客户端时可能产生问题。

最终根据文件签名返回类型:

1
2
3
if (isPng(payload)) return MediaType.IMAGE_PNG;
if (isJpeg(payload)) return MediaType.IMAGE_JPEG;
return MediaType.APPLICATION_OCTET_STREAM;

部署脚本误判进程

旧管理脚本在 PID 文件缺失时通过命令行搜索 JAR 名称。如果远程部署命令本身也包含 JAR 文件名,脚本可能把部署 shell 误认为 Java 进程。

部署动作因此拆成独立步骤:

  1. 上传 incoming 文件;
  2. 校验两端 SHA-256;
  3. 备份当前版本;
  4. 单独停止服务;
  5. 切换 JAR;
  6. 单独启动服务;
  7. 检查端口和业务接口;
  8. 失败时恢复备份。

网页静态文件也先上传到临时目录,校验后再原子切换 dist,并保留带时间戳的回滚目录。

安全检查

  • 二维码不包含手机号、用户 ID、JWT 或权限;
  • pollToken 不进入二维码,服务端只保存哈希;
  • 会话短时有效且只能消费一次;
  • 确认与换票操作具备原子性;
  • 创建、轮询、确认和换票接口分别限流;
  • 二维码图片禁止缓存;
  • 拒绝手机号授权时网页不会登录;
  • 登录响应不返回密码、sessionKey 等敏感字段;
  • 日志不记录微信动态 code、access_token、完整手机号和 JWT。

验收清单

  1. 网页可以正常显示小程序码。
  2. 扫码后进入正确的小程序确认页。
  3. 仅扫码但未授权时网页不会登录。
  4. 拒绝手机号授权后网页保持未登录。
  5. 同意授权后网页自动完成登录。
  6. 刷新网页后登录态仍然有效。
  7. 同一个二维码不能重复换取 JWT。
  8. 过期二维码不能继续确认或换票。
  9. 没有 pollToken 时不能领取登录结果。
  10. 全流程不调用短信发送接口。
  11. 生产日志中没有敏感凭证。
  12. 正式发布时网页使用 release 环境。

总结

小程序码登录的重点并不是生成二维码,而是建立一套安全的跨设备确认协议。

sessionId 相当于公开的会话地址,pollToken 相当于浏览器的取件凭证。小程序确认用户身份,Redis 管理状态与过期时间,Lua 保证一次性消费,最终只有最初的浏览器能够取得 JWT。

如果项目已经具备认证小程序、手机号授权、统一用户表和 Redis,这套方案可以在较小改造范围内替代网页短信验证码登录,同时保留原有密码登录作为回退方式。

官方资料