谈谈开放平台扫码登录的接入设计

2026年05月18日13 次阅读0 人喜欢
API微信小程序NestJSOAuth架构设计
所属合集

最近把 api.nnnnzs.cn 的后端从 Express 迁移到 NestJS,顺便把扫码登录这套功能整理了一下,做成一个对外开放的平台。

之前这套扫码登录就自己用,现在想开放给第三方接入。所以涉及两个问题:怎么让第三方安全地用,同时不影响老逻辑。

整体流程

先说下扫码登录的流程,其实挺简单的:

sequenceDiagram participant 第三方网站 participant 开放平台 participant 小程序 participant 微信 第三方网站->>开放平台: POST /open-platform/qr/getToken 开放平台-->>第三方网站: 返回 token 第三方网站->>开放平台: GET /open-platform/qr/getImg?token=xxx 开放平台-->>第三方网站: 返回二维码图片 第三方网站->>用户: 展示二维码 用户->>微信: 扫描二维码 微信->>小程序: 打开小程序(携带 token) 小程序->>开放平台: GET /auth/info?token=xxx 小程序->>开放平台: POST /auth/confirm(用户确认授权) 开放平台->>第三方网站: POST 回调通知(异步) 第三方网站-->>用户: 登录成功

核心就是:第三方创建 token → 生成二维码 → 用户扫码 → 小程序确认 → 平台回调通知第三方。

实际对接中的坑

在将博客系统改造为第三方应用接入开放平台的过程中,遇到了不少坑。这里记录一下,避免大家踩同样的坑。

坑1:签名验证失败 401

现象:

复制代码
开放平台 API 调用失败: Unauthorized - {"message":"签名验证失败","error":"Unauthorized","statusCode":401}

原因:
前端签名时路径包含了 query 参数,但后端验证时用的是 request.path(不包含 query 参数),导致签名不匹配。

错误做法:

javascript 复制代码
// 签名时使用包含 query 参数的路径
const path = '/open-platform/qr/status?token=xxx';
const signature = generateSignature('GET', path, timestamp, null, appSecret);

正确做法:

javascript 复制代码
// 签名时去掉 query 参数
const fullPath = '/open-platform/qr/status?token=xxx';
const pathForSignature = fullPath.split('?')[0]; // 去掉 query 参数
const signature = generateSignature('GET', pathForSignature, timestamp, null, appSecret);

// 请求时仍然使用完整路径
fetch(`${apiUrl}${fullPath}`, {
  headers: {
    'X-Signature': signature,
    // ...
  }
});

总结: 签名字符串中的路径不包含 query 参数,但实际请求 URL 包含。

坑2:配置键名长度限制

现象:
配置 OPEN_PLATFORM_APP_SECRET 时无法保存,表单提示长度超限。

原因:
前端配置管理页面的 key 字段设置了 maxLength={20},而 OPEN_PLATFORM_APP_SECRET 有 23 个字符。

解决方案:
将前端表单的 key 字段 maxLength 改为 100。

坑3:Token 传递变成 [object Object]

现象:

复制代码
GET /api/wechat/status?token=[object%20Object]

原因:
前端返回整个对象而不是 token 字符串:

javascript 复制代码
// 错误
return response.data.data;
// 正确  
return response.data.data.token;

坑4:图片接口 401

现象:
二维码图片无法加载,返回 401。

原因:
开放平台的 getImg 接口也需要签名验证,但前端直接用 <img> 标签请求图片无法添加签名头。

解决方案:
在后端代理图片请求,添加签名后返回图片流:

javascript 复制代码
// 后端代理
const { appKey, appSecret, apiUrl } = await getOpenPlatformConfig();
const signature = generateSignature('GET', '/open-platform/qr/getImg', timestamp, null, appSecret);

const response = await fetch(`${apiUrl}/open-platform/qr/getImg?token=${token}&env_version=${env}`, {
  headers: {
    'X-App-Key': appKey,
    'X-Timestamp': timestamp,
    'X-Signature': signature,
  }
});

// 返回图片流
return new NextResponse(new Uint8Array(await response.arrayBuffer()), {
  headers: { 'Content-Type': 'image/png' }
});

坑5:扫码状态的理解

现象:
扫码后没有自动登录,需要再次确认。

理解误区:
最初以为扫码就会触发登录,实际上扫码有两次状态:

  1. 扫码成功scanStatus: 0(已扫码,等待确认)
  2. 确认成功scanStatus: 1(扫码完成)

回调时机:

  • 回调只在用户确认授权后触发一次
  • 扫码时不回调

最终方案:
通过轮询 status 接口获取状态,在 scanStatus === 1 时执行登录逻辑。

接入步骤

1. 注册应用

调用注册接口拿到 appKeyappSecret

bash 复制代码
POST https://api.nnnnzs.cn/open-platform/register
Content-Type: application/json

{
  "appName": "我的应用",
  "callbackUrl": "https://example.com/api/wechat/callback",
  "description": "用于网站扫码登录",
  "contactEmail": "dev@example.com"
}

返回:

json 复制代码
{
  "status": true,
  "data": {
    "appKey": "opk_xxxxxxxxxxxx",
    "appSecret": "ops_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  }
}

appSecret 只在注册时返回一次,请妥善保管。如果丢了可以调用重新生成接口。

2. 签名机制

大部分接口都需要签名鉴权,签名规则如下:

请求头:

Header 说明
X-App-Key 你的 appKey
X-Timestamp 当前时间戳(秒)
X-Signature 签名值

签名算法:

复制代码
签名字符串 = HTTP方法 + "\n" + 请求路径 + "\n" + 时间戳 + "\n" + 请求体JSON
签名值 = HMAC-SHA256(签名字符串, appSecret)

**注意:请求路径不包含 query 参数!**这是最容易出错的地方。

注意:请求体为空时,签名字符串最后一行为空字符串。时间戳有效期 5 分钟。

示例代码(Node.js):

javascript 复制代码
const crypto = require('crypto');

function generateSignature(method, path, timestamp, body, appSecret) {
  const bodyStr = body ? JSON.stringify(body) : '';
  const signString = `${method}\n${path}\n${timestamp}\n${bodyStr}`;
  return crypto
    .createHmac('sha256', appSecret)
    .update(signString)
    .digest('hex');
}

// 用法
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = generateSignature('POST', '/open-platform/qr/getToken', timestamp, null, 'ops_your_app_secret');

fetch('https://api.nnnnzs.cn/open-platform/qr/getToken', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-App-Key': 'opk_your_app_key',
    'X-Timestamp': timestamp,
    'X-Signature': signature,
  },
  body: JSON.stringify({}),
});

3. 扫码登录接口

创建扫码会话

bash 复制代码
POST /open-platform/qr/getToken

请求体可选,支持传入自定义参数:

json 复制代码
{
  "params": { "action": "login", "redirectUrl": "/dashboard" }
}

返回 token 和有效期:

json 复制代码
{
  "status": true,
  "data": {
    "token": "A1B2C3D4E5F6...",
    "expiresIn": 3600
  }
}

获取二维码图片

bash 复制代码
GET /open-platform/qr/getImg?token=A1B2C3D4E5F6...&env_version=release

env_version 参数说明:

说明
release 正式版
trial 体验版(默认)
develop 开发版

返回的是 PNG 图片,直接用 <img> 标签展示就行。注意需要签名验证

查询扫码状态

bash 复制代码
GET /open-platform/qr/status?token=A1B2C3D4E5F6...

返回:

json 复制代码
{
  "status": true,
  "data": {
    "token": "A1B2C3D4E5F6...",
    "scanStatus": 0,
    "openId": "oXXXXXXXX",
    "scanData": {
      "nickName": "用户昵称",
      "avatarUrl": "https://..."
    },
    "params": { "redirectUrl": "/dashboard" },
    "message": "已扫码,等待确认"
  }
}

scanStatus 状态码:

说明
-1 等待扫码
0 已扫码,等待确认
1 扫码完成

4. 接收回调(可选)

用户在小程序上确认授权后,平台会向注册时填写的 callbackUrl 发送 POST 请求。

回调请求头:

Header 说明
X-App-Key 你的 appKey
X-Signature 回调签名(用 appSecret 对请求体做 HMAC-SHA256)

回调请求体:

json 复制代码
{
  "token": "A1B2C3D4E5F6...",
  "appKey": "opk_xxxxxxxxxxxx",
  "status": 1,
  "openId": "oXXXXXXXX",
  "scanData": {
    "nickName": "用户昵称",
    "avatarUrl": "https://thirdwx.qlogo.cn/..."
  },
  "params": { "redirectUrl": "/dashboard" },
  "scannedAt": "2025-05-18T10:30:00.000Z",
  "timestamp": 1734525000
}

回调会重试 3 次(间隔 1s、2s、4s),你的接口返回 2xx 就算成功。

验证回调签名:

javascript 复制代码
const crypto = require('crypto');

function verifyCallback(bodyStr, appSecret, signature) {
  const expected = crypto
    .createHmac('sha256', appSecret)
    .update(bodyStr)
    .digest('hex');
  return expected === signature;
}

前端集成示例

一个简单的前端页面,展示二维码并轮询状态:

javascript 复制代码
async function startQrLogin() {
  // 1. 创建会话(签名逻辑见上方)
  const { data: { token } } = await createSession();
  
  // 2. 展示二维码
  const img = document.getElementById('qrcode');
  img.src = `https://api.nnnnzs.cn/open-platform/qr/getImg?token=${token}&env_version=release`;
  
  // 3. 轮询状态
  const timer = setInterval(async () => {
    const { data } = await checkStatus(token);
    if (data.scanStatus === 1) {
      clearInterval(timer);
      // 登录成功,拿到 openId
      console.log('登录成功:', data.openId, data.scanData);
    }
  }, 2000);
}

推荐的集成方案

基于踩坑经验,推荐以下集成方案:

方案一:纯轮询(简单)

流程:

  1. 前端轮询第三方后端的 /api/wechat/status
  2. 第三方后端调用开放平台 /open-platform/qr/status
  3. 扫码确认后,第三方后端执行登录逻辑,生成 loginToken
  4. 前端拿到 loginToken 自动登录

优点: 实现简单,不需要回调接口
缺点: 请求量较大

方案二:回调 + 轮询(推荐)

流程:

  1. 前端轮询第三方后端
  2. 用户确认后,开放平台回调第三方后端
  3. 第三方后端处理登录逻辑,写入 Redis
  4. 前端轮询时从 Redis 读取结果

优点: 减少对开放平台的请求
缺点: 需要维护回调接口

注意事项

  • token 有效期 1 小时,过期需要重新创建
  • 签名时间戳有效期 5 分钟
  • 签名时路径不包含 query 参数(重要!)
  • 回调只会发送一次成功通知,重试最多 3 次
  • appSecret 务必保密,不要暴露在前端代码里
  • 如果 appSecret 泄露,调用重新生成接口即可

应用管理接口

注册之后还有一些管理接口可以查信息、更新配置:

接口 方法 说明
/open-platform/app/info POST 查看应用信息
/open-platform/app/update POST 更新应用信息(名称、回调地址等)
/open-platform/app/regenerate-secret POST 重新生成 appSecret

这些接口同样需要签名鉴权。

Demo 页面

如果想快速体验,可以访问 Demo 页面(无需签名):

https://api.nnnnzs.cn/open-platform/demo/

这里有一个完整的扫码登录演示流程。


就这样,接口设计得比较简单,签名用标准的 HMAC-SHA256,回调带重试。实际对接中遇到几个坑,主要是签名路径的问题。有啥问题可以联系我。

加载评论中...