利用微信公众号带参二维码实现微信扫码登录的通用方案

chat

入口 URL:/wechat-scan-login

⚠️ 说明:本方案 ≠ 微信官方「网站应用扫码登录」


1. 总体架构

整体方案由 3 个角色协作完成:

角色说明
浏览器(前端页面)展示二维码并轮询登录状态
业务后端生成二维码、接收微信事件、自动注册/登录用户、发放业务 token、提供轮询接口;业务自身的用户体系也由它管理
微信公众平台颁发带参二维码 ticket、推送扫码事件
┌──────────┐                      ┌─────────────────────────┐                ┌────────────────┐
│ 浏览器   │ ── 1.请求扫码页 ──▶ │ Next.js 后端            │ ── 2.申请二维码 ticket ─▶│ 微信公众平台   │
│          │ ◀ 3.返回二维码 url ─│ (含用户库 + token 颁发) │ ◀──── 返回 ticket ─────  │                │
│          │ ── 4.展示二维码 + 启动轮询 ─▶ │                         │                │                │
│          │                      │                         │                         │                │
│ 用户扫码 │ ────────────────────────────────────────────── 5.关注 / 扫码 ─▶                          │
│          │                      │                         │ ◀── 6.事件 XML 推送 ───                  │
│          │                      │ 7.标记 scanned + 生成 confirm_code,回复"点我确认登录"图文消息 ──▶ │
│          │ ── 7'.轮询命中 'scanned',UI 提示"请在手机点确认" ─▶                                       │
│ 用户点击 │ ── 8.GET /api/wechat/confirm-login?code=xxx ─▶│                                            │
│          │                      │ 9.原子消费 confirm_code → userBridge 颁发 JWT → 写库 token         │
│          │ ── 10.轮询命中 'confirmed' 拿到 token ───▶│                                                │
│          │ ── 11.写 cookie 后跳回登录前 url ──────────────────────▶                                  │
└──────────┘                      └─────────────────────────┘                └────────────────┘

 


2. 接入前置条件

在动手实施之前,请先确认以下条件全部满足——这些是这条方案能跑通的硬性门槛:

#前置条件说明
1微信公众号必须是「已认证的服务号」订阅号没有 SCAN 事件,未认证号没有「带参二维码」接口权限。开发期可用 微信公众平台测试号 零成本调试
2公众号已开通的接口①「生成带参数的二维码」(cgi-bin/qrcode/create) ②「获取 access_token」(cgi-bin/stable_token) ③「接收用户消息和事件推送」
3公众号后台「服务器配置」已对接业务后端URL 指向你的 /api/wechat/serverTokenOfficialAccount.token 一致、可选 EncodingAESKey;点击「提交」必须显示绿色「Token 验证成功」
4业务后端 IP 加入公众号「IP 白名单」否则 cgi-bin/stable_token 会返回 40164;多实例部署需把所有出口 IP 都加进去
5公网可访问的 80 / 443 域名微信回调只走 80(http)和 443(https),且建议强制 HTTPS。本地开发可用 frp / ngrok / cpolar 内网穿透
6MySQL(或可执行 SQL 的关系型数据库)用于 wx_scan_loginwx_access_token 两张表;业务自身用户体系也建议放这里
7业务用户体系实现 UserBridge 4 个钩子(详见 §5 Step 5)checkIsNewUser / getUserIdByOpenid / createNewUser / issueToken;这是业务侧需要写的全部代码
8接受「用户必须关注公众号才能登录」这是这条方案区别于「开放平台扫码登录」的本质代价——以关注换登录。对增长是好事,对体验是负担

不满足 1 / 5 时不要继续:去开通服务号或起一个内网穿透。其他条件可以边做边补。


3. 关键资源与配置

3.1 数据库

MySQL(或任何支持 SQL + 唯一索引的关系型数据库均可)。

3.2 关键表

本方案需要 2 张 自有表:

作用谁写谁读
wx_scan_login扫码会话工单(一次性消费,带二次确认)① 生成二维码接口 INSERT;② 扫码事件回调 UPDATE scanned=1, openid, confirm_code;③ 确认接口 UPDATE token, user_id 并作废 confirm_code前端轮询接口 SELECT,命中 token 后立即 UPDATE valid=0
wx_access_token公众号 access_token 缓存AccessToken 服务(upsert)AccessToken 服务(读)

业务侧另需 2 张自有表(占位命名,按业务实际命名替换):

最少字段说明
usersid (主键)、其余按业务自定业务自身的用户表
user_openid_mapopenid (唯一)、user_id (外键 → users.id)openid ↔ userId 映射,用于实现 §5 Step 5 中的 4 个钩子

建表 SQL(可直接执行)

-- 扫码会话工单
CREATE TABLE wx_scan_login (
  uuid               VARCHAR(36)  NOT NULL,
  token              VARCHAR(2048),
  user_id            VARCHAR(64),
  valid              TINYINT      NOT NULL DEFAULT 1,
  scanned            TINYINT      NOT NULL DEFAULT 0,           -- 0=等待扫码 1=已扫描待确认
  openid             VARCHAR(64),                                -- 扫码人 openid
  confirm_code       VARCHAR(64),                                -- 一次性确认码(仅出现在微信图文消息 URL 中)
  confirm_expires_at DATETIME,                                   -- 确认链接过期时间,建议 5 分钟
  created_at         DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (uuid),
  UNIQUE KEY uk_confirm_code (confirm_code),
  KEY idx_valid_created (valid, created_at)
);

-- access_token 缓存(按 appId 唯一)
CREATE TABLE wx_access_token (
  app_id     VARCHAR(64)  NOT NULL,
  token      VARCHAR(2048) NOT NULL,
  expires_at DATETIME     NOT NULL,
  updated_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (app_id)
);

-- openid 映射示例(业务自定字段,按需扩展)
CREATE TABLE user_openid_map (
  openid     VARCHAR(64)  NOT NULL,
  user_id    VARCHAR(64)  NOT NULL,
  created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (openid),
  KEY idx_user (user_id)
);

 

wx_scan_login.created_at + idx_valid_created 是为了配合定时任务清理过期会话(见 §8 安全表)。

3.3 公众号配置

只有 4 个字段,建议全部走环境变量

export const OfficialAccount = {
  appId:  process.env.WX_APP_ID!,
  secret: process.env.WX_APP_SECRET!,
  token:  process.env.WX_TOKEN!,        // 微信公众号后台「服务器配置」中的 Token
  aesKey: process.env.WX_AES_KEY ?? '', // 仅「安全模式」需要
};

 

3.4 业务自身配置

export const APP_CONFIG = {
  appName:  process.env.APP_NAME ?? 'My App',
  appDesc:  process.env.APP_DESC ?? '',
  appAvatar: process.env.APP_AVATAR ?? '',
  appCover:  process.env.APP_COVER ?? '',
  // 业务首页:用于「微信对话窗内图文消息」点击后的浏览器落地页
  // 浏览器扫码端不依赖此字段,扫码成功后是直接跳回「登录前 URL」
  homeUrl: process.env.HOME_URL!, // 必填
};

 


4. 关键文件索引

模块路径
扫码页(SSR)app/wechat/scan-login/page.tsx
扫码页数据获取app/wechat/scan-login/getData.ts
扫码页前端组件app/wechat/scan-login/scan-client.tsx
生成二维码接口app/api/wechat/generate-qr-code/route.ts
轮询登录状态接口app/api/wechat/check-scan-status/route.ts
点链接即确认接口app/api/wechat/confirm-login/route.ts(用户在微信内点击图文消息链接 → 后端原子消费 confirm_code 并颁发 token)
公众号消息回调app/api/wechat/server/route.ts
扫码事件处理app/api/wechat/server/handle-message.ts
微信回调签名校验app/api/wechat/_util/gen-signature.ts + validate-token.ts(GET 服务器配置 / POST 消息推送共用)
AccessToken 服务通用的 access_token 维护服务,对外暴露 getToken();内部用 wx_access_token 表持久化、自动判过期、按需刷新
公众号配置app/api/wechat/config.ts
业务配置app/api/wechat/app-config.ts
用户与登录函数app/api/wechat/_user/user.ts
通用 HTTP 客户端app/_class/HttpClient.ts
通用响应封装app/api/req-rep.ts

5. 链路详解

Step 1:浏览器请求扫码页(SSR)

入口:/wechat/scan-login,可选 redirect_param登录前 URL,登录成功后浏览器会跳回这里;不传则默认首页 /)。

app/wechat/scan-login/page.tsx(Server Component)做了三件事:

  1. 读取 searchParams.redirect_param
  2. 调用 getData() 通过服务端 HTTP 客户端请求自家的 /api/wechat/generate-qr-code 拿到 { qrcode, scanId, appInfo }
  3. 渲染 <ScanClient />,把这些数据透传给客户端组件
// app/wechat/scan-login/page.tsx
const WeChatLoginPage = async ({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) => {
  let redirect_param = '';
  if (
    searchParams.redirect_param &&
    typeof searchParams.redirect_param === 'string'
  ) {
    redirect_param = searchParams.redirect_param;
  }

  const data = await getData();
  const { qrcode, scanId, appInfo } = data;

  return (
    <div className="...">
      {qrcode && (
        <ScanClient
          redirectParam={redirect_param}
          scanId={scanId}
          qrcode={qrcode}
          appinfo={appInfo}
        />
      )}
    </div>
  );
};

 

getData 仅透传时间戳防缓存:

// app/wechat/scan-login/getData.ts
import { HttpClient2 } from '../../_class/HttpClient';
export async function getData() {
  const httpClient = HttpClient2.create();
  const t = Date.now();
  const resp = await httpClient.get(`/wechat/generate-qr-code?t=${t}`);
  if (resp?.data?.code !== 1) {
    throw new Error('Failed to fetch data');
  }
  return resp?.data?.data;
}

 

这里是 SSR 在服务端 HTTP 调用自身的 API Route,浏览器拿到的 HTML 里就已经包含二维码地址。


Step 2:后端生成带参二维码

接口:GET /api/wechat/generate-qr-code

实现:app/api/wechat/generate-qr-code/route.ts,主流程:

  1. 生成扫码会话 uuid:在 wx_scan_login 写入一条 valid=1 的记录,得到 scan_uuid
  2. 拿公众号 access_token:调用 AccessToken 服务(详见下文),命中表中未过期的 token 则复用,否则自动刷新并回写
  3. 请求微信 cgi-bin/qrcode/create
    • expire_seconds: 300
    • action_name: 'QR_STR_SCENE'
    • scene_str: login_{scan_uuid} ← 关键回传参数
  4. 返回前端
    • qrcode = https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=...
    • scanId = scan_uuid
    • appInfo(来自 APP_CONFIG
// app/api/wechat/generate-qr-code/route.ts
async function handler(req: NextRequest) {
  const scan_uuid = await generateScanId();
  if (scan_uuid === -1) return failureJson('扫码会话生成失败 请稍后再试');

  const at = new AccessToken(OfficialAccount.app_id, OfficialAccount.secret);
  let token = '';
  try {
    token = await at.getToken();
  } catch (e: any) {
    return failureJson('access token 获取失败,' + e.toString());
  }
  if (!token) return failureJson('未获取到 access token');

  const httpClient = HttpClient.create();
  const rsp = await httpClient.post(
    `/cgi-bin/qrcode/create?access_token=${token}`,
    {
      expire_seconds: 300,
      action_name: 'QR_STR_SCENE',
      action_info: {
        scene: {
          scene_str: `login_${scan_uuid}`, // 协议:login_{scanUuid}
        },
      },
    },
  );

  const { data } = rsp;
  const error = getWechatApiError(data);
  if (error) return failureJson('二维码票据获取失败');

  const ticket = data?.ticket;
  return successJson({
    qrcode: `https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=${encodeURIComponent(ticket)}`,
    scanId: scan_uuid,
    appInfo: APP_CONFIG,
  });
}

 

scene_strlogin_ 前缀是后端用来识别「这是一次登录扫码」的协议约定,下文事件回调依赖它。

AccessToken 服务

AccessToken 是一个通用的 access_token 维护服务,对外只暴露一个 getToken() 方法,内部用一张表 wx_access_token 来持久化 token 与过期时间。调用方完全不需要关心「是否需要刷新、如何刷新、被谁刷新过」。

表结构(占位字段名,按业务实际命名):

字段类型说明
app_idvarchar公众号 appId(主键
tokenvarchar当前有效的 access_token
expires_atdatetimetoken 过期的绝对时间,用于判断是否仍可用
updated_atdatetime最近一次写入时间(便于排查)

取号规则

  1. app_id 查表 → 命中且expires_at 还有 ≥ 60s(容错),直接返回库里的 token
  2. 否则调微信 cgi-bin/stable_token(稳定版,避免与其他服务互踢)拿到新 token,upsertwx_access_token,再返回

伪代码:

class AccessToken {
  constructor(
    private appId: string,
    private secret: string,
  ) {}

  async getToken(): Promise<string> {
    const row = await db('wx_access_token')
      .where({ app_id: this.appId })
      .first();
    if (row && row.expires_at.getTime() - Date.now() > 60_000) {
      return row.token; // 命中缓存
    }
    return this.refresh();
  }

  private async refresh(): Promise<string> {
    const { access_token, expires_in } = await callWechatStableToken(
      this.appId,
      this.secret,
    );
    await db('wx_access_token')
      .insert({
        app_id: this.appId,
        token: access_token,
        expires_at: new Date(Date.now() + expires_in * 1000),
        updated_at: new Date(),
      })
      .onConflict('app_id')
      .merge();
    return access_token;
  }
}

 

为什么用表而不是文件 / 进程内存:


Step 3:前端展示二维码并启动轮询

app/wechat/scan-login/scan-client.tsx 核心状态:

  • status0 等待扫码、1 已扫描,等待用户在手机上点击确认登录2 登录成功、3 失败/超时
  • token / userId:登录成功后由轮询接口取到
  • timeCount:轮询次数计数器,超过 180 次(约 3 分钟)视为失败——加入"用户切到微信点确认"的人肉时间,超时阈值要比之前更宽

引入"已扫描待确认"中间态的目的:用户扫码后微信里会推送一条「确认登录到 xxx」图文消息,用户点击链接才会真正下发 token。PC 端这段时间需要在 UI 上提示用户「扫描成功,请在手机上点击确认」,避免用户以为卡住了。

轮询逻辑:

// app/wechat/scan-login/scan-client.tsx
const checkStatus = useCallback(() => {
  const currentTimestamp = new Date().getTime();
  httpClient.current
    .get('wechat/check-scan-status?id=' + scanId + '&t=' + currentTimestamp)
    .then(async (res) => {
      timeCount.current++;
      const data = res?.data?.data ?? {};
      // 后端返回的 status:'pending' | 'scanned' | 'confirmed' | 'expired'
      if (data.status === 'confirmed' && data.token) {
        setStatus(2);
        setToken(data.token);
        setUserId(data.userId);
        return;
      }
      if (data.status === 'scanned') {
        setStatus(1); // 提示「扫描成功,请在手机上确认登录」
      }
      if (data.status === 'expired' || timeCount.current >= 180) {
        return setStatus(3);
      }
      await sleep(1000);
      checkStatus();
    });
}, []);
useEffect(() => {
  checkStatus();
}, []);

 

轮询命中 status === 'confirmed' 后,useEffect 触发 jump() 跳转(详见 Step 7):

// app/wechat/scan-login/scan-client.tsx
const jump = useCallback(() => {
  document.cookie = `auto-login-token=${token}; Path=/; SameSite=Lax`;
  document.cookie = `user_id=${userId}; Path=/; SameSite=Lax`;
  window.location.href = redirectParam || '/';
}, [token, userId, redirectParam]);

 


Step 4:用户扫码 / 关注,微信回调公众号服务器

入口:/api/wechat/server(POST/GET),由微信公众号后台配置为消息接收 URL。

任何进来的请求必须先做签名校验,确认它真的来自微信服务器——不论是 GET 的「服务器配置验证」还是 POST 的「事件推送」。

4.1 请求签名校验(关键安全防线)

协议:微信任何回调请求的 URL 上都会带 3 个 query 参数:signaturetimestampnonce

算法:把 [token, timestamp, nonce] 字典序排序后拼接,SHA-1 得到的字符串应当与 signature 相等。token 是公众号后台设置的、只有你和微信两边持有的密钥(见 OfficialAccount.token)。

意义:在 token 不泄露的前提下,外部攻击者无法构造合法 signature,因此所有伪造请求都会在入口被拦掉。这是这条扫码登录方案能否进入生产的硬性门槛。

// app/api/wechat/_util/gen-signature.ts
import sha1 from 'sha1';
import { OfficialAccount } from '../config';

export function genSignature(timestamp: string, nonce: string): string {
  const sortedArr = [OfficialAccount.token, timestamp, nonce].sort().join('');
  return sha1(sortedArr);
}

 

// app/api/wechat/_util/validate-token.ts
import { genSignature } from './gen-signature';

/**
 * 同时给「服务器配置验证(GET + echostr)」和「消息推送(POST)」使用
 * 校验通过返回 true,失败返回 false(不抛异常,让上层决定 403 还是 echostr 回写)
 */
export function isFromWechat(
  signature: string,
  timestamp: string,
  nonce: string,
): boolean {
  if (!signature || !timestamp || !nonce) return false;
  return genSignature(timestamp, nonce) === signature;
}

 

4.2 消息回调 Route:统一验签后再分发

app/api/wechat/server/route.ts 的入口结构:

// app/api/wechat/server/route.ts
import { isFromWechat } from '../_util/validate-token';

async function handler(req: NextRequest) {
  const url = new URL(req.url);
  const signature = url.searchParams.get('signature') ?? '';
  const timestamp = url.searchParams.get('timestamp') ?? '';
  const nonce = url.searchParams.get('nonce') ?? '';
  const echostr = url.searchParams.get('echostr');

  // ─── 1) 任何请求都先验签 ───────────────────────────────
  if (!isFromWechat(signature, timestamp, nonce)) {
    return new Response('invalid signature', { status: 403 });
  }

  // ─── 2) GET + echostr:服务器配置验证 ─────────────────
  if (echostr && req.method === 'GET') {
    return new Response(echostr, { status: 200 });
  }

  // ─── 3) POST:解析 XML 消息后按 MsgType 分发 ─────────
  const wechatData = await req.text();
  const wechatMsg = (await parseXml(wechatData)) as MessageXml;

  switch (wechatMsg?.MsgType) {
    case 'text':
      return handleTextMessage(wechatMsg);
    case 'event':
      switch (wechatMsg.Event) {
        case 'subscribe':
        case 'SCAN':
          if (wechatMsg.EventKey?.includes('login')) {
            return handleLoginEvent(wechatMsg);
          }
          return handleTextMessage(wechatMsg);
      }
      break;
  }

  return repleyMessage('');
}

export const GET = handler;
export const POST = handler;

 

关键点:把 isFromWechat 提到入口最前面,对 GET 和 POST 一视同仁。原本只在 GET + echostr 路径里调用 validateToken、POST 路径不校验的写法,是这条方案最常见、也最容易踩的陷阱。

4.3 进入扫码登录处理的两种事件

通过验签后,下面两种事件都会被路由到 handleLoginEvent

用户状态微信事件EventKey 形如
未关注,扫码后关注subscribeqrscene_login_{scan_uuid}
已关注,再次扫码SCANlogin_{scan_uuid}

只要 EventKeylogin 就会路由到 handleLoginEvent

4.4 验签能挡什么、不能挡什么

攻击场景是否能被验签拦掉
攻击者直接 POST /api/wechat/server 伪造一条 EventKey=login_xxx 想盗 token✅ 拦
攻击者重放微信发过的旧请求(同样的 signature / timestamp / nonce)⚠️ 拦不住——签名只校来源不校时序,可选加 5 分钟 timestamp 偏差 + nonce 防重表
中间人在传输过程中改 body(明文模式)❌ 拦不住——明文模式签名不含 body;走 HTTPS 已经能阻断绝大多数中间人
中间人偷看 body 内容❌ 拦不住;走 HTTPS 已加密链路;如需端到端加密改「安全模式」AES

结论:明文模式 + 严格验签 + HTTPS,对 99% 的项目已经足够。涉及金融/强隐私时再升「安全模式(msg_signature + AES)」。


Step 5:处理扫码登录事件,标记已扫描并下发"确认登录"链接

⚠️ 反 QRLJacking(扫码登录钓鱼)的关键设计点:扫码事件到达后,本步骤不立即颁发 token——只把会话标记为 scanned=1、写入 openid、生成一次性 confirm_code,并在微信对话窗回复一段「点我确认登录」的图文消息。真正的 token 颁发在 Step 5b 点击确认链接时才发生。

handleLoginEvent 把以下几件事串起来。它依赖 UserBridge 的 4 个钩子方法——这是整个方案对接入方的唯一业务侧契约:

/**
 * 微信扫码登录与业务用户体系之间的桥梁。
 * 接入方实现这 4 个方法即可,方案不关心它们的内部细节。
 */
export interface UserBridge {
  /** 该 openid 在本系统里是否首次出现 */
  checkIsNewUser(openid: string): Promise<boolean>;

  /** 用 openid 反查本系统的 user_id */
  getUserIdByOpenid(openid: string): Promise<string>;

  /** 创建新用户并把 openid 与新 user_id 绑定,返回新 user_id */
  createNewUser(openid: string): Promise<string>;

  /** 用 user_id 颁发业务 JWT(或任意业务侧自定的登录凭证字符串) */
  issueToken(userId: string): Promise<string>;
}

 

钩子入参出参职责
checkIsNewUser(openid)微信 openidboolean判断该 openid 在系统里是否首次出现user_openid_map 没有对应行)
getUserIdByOpenid(openid)微信 openidstring根据 openid 反查本系统的 user_id
createNewUser(openid)微信 openidstring(新建 user_idusers 中创建新用户,并把 openid 与新 user_id 绑定写入 user_openid_map
issueToken(userId)本系统 user_idstring(业务 JWT)user_id 颁发业务身份凭证;具体签名算法 / 加密密钥 / TTL 全部由接入方自定

4 个钩子覆盖了「微信身份 ↔ 业务身份」的全部桥接动作,主流程对它们一无所知——用什么数据库、用什么签名算法、密码策略如何、是否走 jwt-auth,全部对调用方透明。

handleLoginEvent 主流程(伪代码):

  1. 解析 EventKey:去掉 qrscene_ 前缀后按 _ 拆出 scanId
  2. 校验扫码会话:在 wx_scan_loginuuid=scanId AND valid=1 取出会话;不存在或失效则回复"二维码已失效,请刷新登录页重试"
  3. 生成一次性确认码confirm_code = crypto.randomBytes(32).toString('hex'),过期时间 5 分钟
  4. 更新会话wx_scan_login 中写入 scanned=1, openid, confirm_code, confirm_expires_at不写 token
  5. 回复图文消息:URL 指向业务后端的 /api/wechat/confirm-login?code={confirm_code};用户在微信内点击即触发 Step 5b
// app/api/wechat/server/handle-message.ts
import crypto from 'node:crypto';

export async function handleLoginEvent(msg: MessageXml): Promise<Response> {
  const fromTo = {
    FromUserName: msg.FromUserName,
    ToUserName: msg.ToUserName,
  };

  // 已关注例子:login_677878
  // 未关注扫码后例子:qrscene_login_677878
  const pureEventKey = msg.EventKey.replace('qrscene_', '');
  const [, scanId] = pureEventKey.split('_');
  const openid = msg.FromUserName;

  const session = await db('wx_scan_login')
    .where('uuid', scanId)
    .andWhere('valid', 1)
    .first();
  if (!session) {
    return repleyMessage(
      textMessage({ ...fromTo, reply: '二维码已失效,请刷新登录页重试' }),
    );
  }

  const confirmCode = crypto.randomBytes(32).toString('hex');
  const confirmExpiresAt = new Date(Date.now() + 5 * 60 * 1000);

  await db('wx_scan_login')
    .where('uuid', scanId)
    .andWhere('valid', 1)
    .update({
      scanned: 1,
      openid,
      confirm_code: confirmCode,
      confirm_expires_at: confirmExpiresAt,
    });

  // 微信对话窗里的图文消息:用户在微信内点击 → 浏览器打开 → 命中 Step 5b 接口 → 完成登录
  // URL 里只有 confirm_code(一次性),没有 token、没有 userId,攻击者拿到也无法横向利用
  const confirmUrl = `${APP_CONFIG.siteUrl}/api/wechat/confirm-login?code=${confirmCode}`;
  return repleyMessage(
    articleMessage({
      ...fromTo,
      articles: [
        {
          title: `登录到 ${APP_CONFIG.appName}`,
          description: `如果是你本人在 ${APP_CONFIG.appName} 登录,请点击确认;非本人操作请直接忽略。`,
          url: confirmUrl,
          img: APP_CONFIG.appAvatar,
        },
      ],
    }),
  );
}

 

APP_CONFIG.siteUrl 是新增的业务自身站点 URL(如 https://yourapp.com),用于拼装确认链接。原本的 homeUrl 仅用于"登录成功后微信内跳转的落地页",二者职责不同,建议都保留。


Step 5b:点链接即确认,颁发 token 并写库

接口:GET /api/wechat/confirm-login?code={confirm_code}

实现:app/api/wechat/confirm-login/route.ts

链路:用户在微信对话窗看到 Step 5 回复的图文消息 → 点击进入微信内置浏览器 → 命中本接口 → 后端原子消费 confirm_code → 调 userBridge 颁发 token → 写回 wx_scan_login.token → 302 跳到一个轻量提示页("登录成功,请回到电脑端继续操作")。

// app/api/wechat/confirm-login/route.ts
import { userBridge } from '../server/user-bridge';

async function handler(req: NextRequest) {
  const code = getUrlParam(req.url!, 'code');
  if (!code) return failureJson('缺少确认码');

  const ok = await db.transaction(async (trx) => {
    // 关键:select for update + 后续 update 同事务,保证"消费 code → 颁发 token"原子
    const session = await trx('wx_scan_login')
      .where('confirm_code', code)
      .andWhere('scanned', 1)
      .andWhere('valid', 1)
      .andWhere('confirm_expires_at', '>', new Date())
      .whereNull('token')
      .forUpdate()
      .first();
    if (!session) return false;

    const isNewUser = await userBridge.checkIsNewUser(session.openid);
    const userId = isNewUser
      ? await userBridge.createNewUser(session.openid)
      : await userBridge.getUserIdByOpenid(session.openid);
    const token = await userBridge.issueToken(userId);
    if (!token) return false;

    await trx('wx_scan_login')
      .where('uuid', session.uuid)
      .update({ token, user_id: userId, confirm_code: null });

    return true;
  });

  if (!ok) {
    // 失败页:在微信内置浏览器里给用户一个明确的反馈
    return Response.redirect(`${APP_CONFIG.siteUrl}/wechat/confirm-failed`, 302);
  }
  return Response.redirect(`${APP_CONFIG.siteUrl}/wechat/confirm-success`, 302);
}

export const GET = handler;

 

反 QRLJacking 的核心保证都集中在这一步:


Step 6:前端轮询读取扫码状态

接口:GET /api/wechat/check-scan-status?id={scanId}

实现:app/api/wechat/check-scan-status/route.ts

返回结构升级为带 status 的状态机:

status含义前端 UI
pending等待用户扫码显示二维码
scanned已扫描,等待用户在手机点击「确认登录」二维码上盖一层"扫描成功,请在手机上确认"提示
confirmed用户已确认,token 可领取(仅本次返回有 token / userId写 cookie → 跳回登录前 URL
expired二维码或确认链接已失效提示用户刷新

读到 confirmed原子化置 valid=0,防止双消费:

// app/api/wechat/check-scan-status/route.ts
async function handler(req: NextRequest) {
  const uuid = getUrlParam(req.url!, 'id');
  if (!uuid) return failureJson('无效的id');

  const record = await db('wx_scan_login')
    .where('uuid', uuid)
    .andWhere('valid', 1)
    .first();
  if (!record) return successJson({ status: 'expired' });

  if (record.token) {
    // 一次性消费:仅当 token 存在且 valid=1 时才置 0;并发请求只有一个会 affectedRows=1
    const affected = await db('wx_scan_login')
      .where('uuid', uuid)
      .andWhere('valid', 1)
      .whereNotNull('token')
      .update({ valid: 0 });
    if (affected !== 1) return successJson({ status: 'expired' });
    return successJson({
      status: 'confirmed',
      token: record.token,
      userId: record.user_id,
    });
  }

  if (record.scanned) return successJson({ status: 'scanned' });
  return successJson({ status: 'pending' });
}

 

注意:原版"先 SELECT 再 UPDATE valid=0"在并发下不是原子的,存在 token 被双消费的风险。这里改用 WHERE valid=1 AND token IS NOT NULL ... UPDATE 一条语句完成判断+消费,靠数据库行锁兜底。


Step 7:跳回登录前 URL,登录完成

由于是单业务、同源,扫码端浏览器拿到 token / userId不需要再带在 URL 上,直接写入 cookie / localStorage,然后跳回用户登录前所在的页面即可(业务侧后续从 cookie 读取业务 JWT 做鉴权)。

// app/wechat/scan-login/scan-client.tsx
const jump = useCallback(() => {
  // 1) 持久化业务身份(同源 cookie 给业务侧后续接口自动携带)
  document.cookie = `auto-login-token=${token}; Path=/; SameSite=Lax`;
  document.cookie = `user_id=${userId}; Path=/; SameSite=Lax`;

  // 2) 跳回「登录前 URL」,即用户被引导到扫码页之前所在的页面
  //    - redirectParam 由入口 /wechat/scan-login?redirect_param=xxx 透传进来
  //    - 没有就回首页
  window.location.href = redirectParam || '/';
}, [token, userId, redirectParam]);

 

也就是说:

  • 入口侧(业务页面在拦截未登录请求时):/wechat/scan-login?redirect_param=${当前 url}
  • 出口侧(扫码登录成功):window.location.href = redirectParam || '/'

这样扫码登录就无感地把用户送回原本想去的页面,前后浏览体验是连续的。


6. 完整时序图

浏览器                Next.js 后端(含用户库)        微信公众平台
  │                      │                              │
  │ 1. GET /wechat/scan-login                           │
  ├─────────────────────▶│                              │
  │                      │ 1.1 SSR getData()            │
  │                      │ ──── GET /api/wechat/generate-qr-code ───▶
  │                      │ 1.2 生成 scan_uuid 并写库 wx_scan_login
  │                      │ 1.3 AccessToken.getToken() — 命中 wx_access_token 表 / 过期则调 cgi-bin/stable_token 并回写
  │                      │ ── POST cgi-bin/qrcode/create ──▶
  │                      │     scene_str = login_{scan_uuid}
  │                      │ ◀── ticket / url ────        │
  │ ◀── HTML(含二维码 URL、scanId、APP_CONFIG)──       │
  │                      │                              │
  │ 2. <img> 加载二维码                                  │
  │ ────────────────────────────── GET showqrcode?ticket=xxx ─────▶
  │ ◀──────── 二维码图片 ───────────────────────        │
  │                      │                              │
  │ 3. 启动 setInterval-like 轮询                        │
  │ ── GET /api/wechat/check-scan-status?id=scanId ─▶   │
  │                      │ ── SELECT wx_scan_login      │
  │ ◀── { token: null }                                 │
  │ (每秒重试,最多 100 次)                              │
  │                      │                              │
  │ 4. 用户用微信扫码                                    │
  │ ───────────────── 扫码 / 关注 ──────────────▶       │
  │                      │ ◀── POST /api/wechat/server (event subscribe/SCAN)
  │                      │     URL?signature=xxx&timestamp=xxx&nonce=xxx
  │                      │     Body: <xml>...EventKey = login_{scan_uuid}...</xml>
  │                      │ 4.1 isFromWechat(signature, timestamp, nonce) → 不通过则 403
  │                      │ 4.2 解析 EventKey,识别为 login
  │                      │ 4.3 生成 confirm_code(CSPRNG 32 字节 hex)
  │                      │ 4.4 UPDATE wx_scan_login
  │                      │      SET scanned=1, openid, confirm_code, confirm_expires_at
  │                      │      WHERE uuid=scanId
  │                      │ ─── 回复图文消息(URL 含 confirm_code,无 token) ─▶
  │                      │                              │ 推送给用户对话窗
  │                      │                              │
  │ 5. 期间前端轮询命中"已扫描"状态                       │
  │ ── GET /api/wechat/check-scan-status?id=scanId ─▶   │
  │ ◀── { status: 'scanned' } UI 提示"扫描成功,请在手机上确认"
  │                      │                              │
  │ 6. 用户在微信对话窗点击图文消息(点链接即确认)       │
  │ ──── GET /api/wechat/confirm-login?code=xxx ───▶    │
  │                      │ 6.1 SELECT FOR UPDATE 校验 confirm_code 未过期、未消费
  │                      │ 6.2 userBridge.checkIsNewUser(openid)
  │                      │ 6.3 新用户 → userBridge.createNewUser(openid)
  │                      │     老用户 → userBridge.getUserIdByOpenid(openid)
  │                      │ 6.4 userBridge.issueToken(userId) → 业务 JWT
  │                      │ 6.5 UPDATE wx_scan_login
  │                      │      SET token, user_id, confirm_code=NULL WHERE uuid=...
  │ ◀── 302 /wechat/confirm-success(微信内置浏览器展示"登录成功,回到电脑端")
  │                      │                              │
  │ 7. 下一次轮询命中"已确认"                             │
  │ ── GET /api/wechat/check-scan-status?id=scanId ─▶   │
  │                      │ 原子 UPDATE valid=0 WHERE token IS NOT NULL
  │ ◀── { status: 'confirmed', token, userId }          │
  │                      │                              │
  │ 8. 写 cookie(auto-login-token, user_id) → window.location.href = redirect_param || '/'
  │ ────────────────────▶ 回到登录前的页面,业务侧从 cookie 读 token 完成鉴权

 


7. 数据流与关键约定

7.1 scene_str 协议

二维码上的场景值:

login_{scanUuid}

 

  • 已关注用户扫码:微信回传 EventKey = login_{scanUuid}
  • 未关注用户扫码后关注:微信回传 EventKey = qrscene_login_{scanUuid}

后端通过 replace('qrscene_', '') 后按 _ 拆两段,即可拿回扫码会话 id:

const [, scanId] = msg.EventKey.replace('qrscene_', '').split('_');

 

微信限制:scene_str 长度上限 64,login_ + uuid(36 位)= 42 字符,安全。

7.2 wx_scan_login 状态机

INSERT (valid=1, scanned=0, token=NULL)         ──── 生成二维码
        │
        ▼
扫码事件 UPDATE scanned=1, openid,
                confirm_code, confirm_expires_at ──── 微信回调(不写 token)
        │
        ▼
点击确认 SELECT FOR UPDATE +
         UPDATE token, user_id,
                confirm_code=NULL                ──── 用户在手机点链接(一次性消费 confirm_code)
        │
        ▼
PC 轮询  UPDATE valid=0
         WHERE valid=1 AND token IS NOT NULL     ──── 一次性消费 token

 

7.3 token / 凭证生命周期

凭证谁颁发谁持有流转链路失效条件
公众号 access_token微信 cgi-bin/stable_tokenNext.js 后端仅在后端内部使用,前端永不接触默认 7200s,按表 expires_at 自动刷新
一次性 confirm_code业务后端(crypto.randomBytes(32).hex仅出现在「微信服务器 → 用户对话窗 → 用户点击 → 业务后端」链路Step 5 写库 → Step 5b 原子消费5 分钟过期 / 一次消费即作废(confirm_code=NULL
业务 auto-login-tokenuserBridge.issueToken(userId)暂存在 wx_scan_login.token,前端轮询取走后写同源 cookieStep 5b 写入 → Step 6 取走 → ScanClient 写 cookie一次取走即 valid=0;cookie 侧 TTL 由 issueToken 决定

8. 安全与边界

维度现状建议
扫码登录钓鱼(QRLJacking)已通过 Step 5 / 5b 的「扫码 + 点链接二次确认」拆分缓解:扫码事件不再直接颁发 token,必须由扫码人在自己微信内点击带 confirm_code 的图文消息链接才会下发 token;confirm_code 是一次性的、5 分钟过期,PC 端任何接口都拿不到仍可叠加:①确认页展示生成端 IP / 设备 / 城市供用户人工核验;②PC 端生成时下发 HttpOnly cookie 与 scanId 强绑定;③对 generate-qr-code 加 IP 维度限频,防钓鱼海量制造二维码
微信回调验签已在 /api/wechat/server 入口对 GET/POST 统一调用 isFromWechat 校验(见 Step 4.1 ~ 4.2),不通过直接 403进阶:①拒绝 timestamp 偏差超 ±5 分钟的请求;②引入 nonce 防重放表(短 TTL);③金融场景升「安全模式」启用 AES + msg_signature
token 一次性消费轮询接口已用「UPDATE ... WHERE valid=1 AND token IS NOT NULL」原子化置 valid=0,并发请求只有一个能读到 token;confirm_code 在 Step 5b 同样以事务 + forUpdate 一次性消费增加定时任务清理 created_at 过期 ≥ 5 分钟的记录,避免表无限膨胀
自动注册的密码策略createNewUser 若使用统一初始密码会让所有新用户共享密码随机密码 + 仅后端持有,或改为后端代签 JWT,避免可猜测的共享密码
数据库凭证app/api/kenx-config.ts 中明文写死迁移到环境变量 / 密钥管理服务
公众号凭证app/api/wechat/config.ts 中明文写死迁移到环境变量 / 密钥管理服务
跳回 URL 校验window.location.href = redirectParam 来自入口 query,未做白名单jump() 侧只允许同源相对路径(如 /dashboard),或对完整 URL 做同域校验,避免开放重定向
业务身份持久化直接 document.cookie = "auto-login-token=...",未带 Secure / HttpOnly生产建议改为后端通过 Set-CookieHttpOnly + Secure + SameSite=Lax,前端不再触碰 token
轮询频率1 秒 1 次,最多 100 次可结合 WebSocket / SSE 推送替代轮询,降低后端压力
wx_access_token 并发刷新多实例同时发现 token 过期会并发调 cgi-bin/stable_token,可能踩上限refresh() 内加分布式锁(DB 行锁 / Redis 锁),或改用 Redis SETNX + 过期时间作为唯一刷新者

9. 一句话概括

「后端把 login_{scan_uuid} 烧进二维码场景值并入库占坑;前端拿二维码并轮询;用户扫码后微信把事件推给后端,后端只把会话标记为『已扫描』并下发一段带一次性 confirm_code 的『确认登录』图文消息;用户在自己微信里点击链接才触发后端按 openid 自动注册或反查得到 userId、用 userBridge.issueToken 颁发业务 JWT 写回那条占坑记录;前端下一次轮询命中『已确认』后把 JWT 写入 cookie,并跳回登录前的 URL,登录完成——「扫码 → 点确认」两段式拆分是反 QRLJacking 钓鱼的核心。」

版权声明:
作者:东明兄
链接:https://blog.crazyming.com/note/3303/
来源:CrazyMing
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
海报
利用微信公众号带参二维码实现微信扫码登录的通用方案
入口 URL:/wechat-scan-login ⚠️ 说明:本方案 ≠ 微信官方「网站应用扫码登录」 1. 总体架构 整体方案由 3 个角色协作完成: ……
<<上一篇
下一篇>>
chat