利用微信公众号带参二维码实现微信扫码登录的通用方案
入口 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/server、Token 与 OfficialAccount.token 一致、可选 EncodingAESKey;点击「提交」必须显示绿色「Token 验证成功」 |
| 4 | 业务后端 IP 加入公众号「IP 白名单」 | 否则 cgi-bin/stable_token 会返回 40164;多实例部署需把所有出口 IP 都加进去 |
| 5 | 公网可访问的 80 / 443 域名 | 微信回调只走 80(http)和 443(https),且建议强制 HTTPS。本地开发可用 frp / ngrok / cpolar 内网穿透 |
| 6 | MySQL(或可执行 SQL 的关系型数据库) | 用于 wx_scan_login、wx_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 张自有表(占位命名,按业务实际命名替换):
| 表 | 最少字段 | 说明 |
|---|---|---|
users | id (主键)、其余按业务自定 | 业务自身的用户表 |
user_openid_map | openid (唯一)、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)做了三件事:
- 读取
searchParams.redirect_param - 调用
getData()通过服务端 HTTP 客户端请求自家的/api/wechat/generate-qr-code拿到{ qrcode, scanId, appInfo } - 渲染
<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,主流程:
- 生成扫码会话 uuid:在
wx_scan_login写入一条valid=1的记录,得到scan_uuid - 拿公众号 access_token:调用 AccessToken 服务(详见下文),命中表中未过期的 token 则复用,否则自动刷新并回写
- 请求微信
cgi-bin/qrcode/create:expire_seconds: 300action_name: 'QR_STR_SCENE'scene_str: login_{scan_uuid}← 关键回传参数
- 返回前端:
qrcode = https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=...scanId = scan_uuidappInfo(来自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_str里login_前缀是后端用来识别「这是一次登录扫码」的协议约定,下文事件回调依赖它。
AccessToken 服务
AccessToken 是一个通用的 access_token 维护服务,对外只暴露一个 getToken() 方法,内部用一张表 wx_access_token 来持久化 token 与过期时间。调用方完全不需要关心「是否需要刷新、如何刷新、被谁刷新过」。
表结构(占位字段名,按业务实际命名):
| 字段 | 类型 | 说明 |
|---|---|---|
app_id | varchar | 公众号 appId(主键) |
token | varchar | 当前有效的 access_token |
expires_at | datetime | token 过期的绝对时间,用于判断是否仍可用 |
updated_at | datetime | 最近一次写入时间(便于排查) |
取号规则:
- 按
app_id查表 → 命中且距expires_at还有 ≥ 60s(容错),直接返回库里的 token - 否则调微信
cgi-bin/stable_token(稳定版,避免与其他服务互踢)拿到新 token,upsert 回wx_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 核心状态:
status:0等待扫码、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 参数:signature、timestamp、nonce。
算法:把 [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 形如 |
|---|---|---|
| 未关注,扫码后关注 | subscribe | qrscene_login_{scan_uuid} |
| 已关注,再次扫码 | SCAN | login_{scan_uuid} |
只要 EventKey 含 login 就会路由到 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) | 微信 openid | boolean | 判断该 openid 在系统里是否首次出现(user_openid_map 没有对应行) |
getUserIdByOpenid(openid) | 微信 openid | string | 根据 openid 反查本系统的 user_id |
createNewUser(openid) | 微信 openid | string(新建 user_id) | 在 users 中创建新用户,并把 openid 与新 user_id 绑定写入 user_openid_map |
issueToken(userId) | 本系统 user_id | string(业务 JWT) | 用 user_id 颁发业务身份凭证;具体签名算法 / 加密密钥 / TTL 全部由接入方自定 |
4 个钩子覆盖了「微信身份 ↔ 业务身份」的全部桥接动作,主流程对它们一无所知——用什么数据库、用什么签名算法、密码策略如何、是否走 jwt-auth,全部对调用方透明。
handleLoginEvent 主流程(伪代码):
- 解析 EventKey:去掉
qrscene_前缀后按_拆出scanId - 校验扫码会话:在
wx_scan_login按uuid=scanId AND valid=1取出会话;不存在或失效则回复"二维码已失效,请刷新登录页重试" - 生成一次性确认码:
confirm_code = crypto.randomBytes(32).toString('hex'),过期时间 5 分钟 - 更新会话:
wx_scan_login中写入scanned=1, openid, confirm_code, confirm_expires_at(不写 token) - 回复图文消息: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×tamp=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_token | Next.js 后端 | 仅在后端内部使用,前端永不接触 | 默认 7200s,按表 expires_at 自动刷新 |
一次性 confirm_code | 业务后端(crypto.randomBytes(32).hex) | 仅出现在「微信服务器 → 用户对话窗 → 用户点击 → 业务后端」链路 | Step 5 写库 → Step 5b 原子消费 | 5 分钟过期 / 一次消费即作废(confirm_code=NULL) |
业务 auto-login-token | userBridge.issueToken(userId) | 暂存在 wx_scan_login.token,前端轮询取走后写同源 cookie | Step 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-Cookie 写 HttpOnly + 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
文章版权归作者所有,未经允许请勿转载。
共有 0 条评论