用微信小程序做跳板实现微信扫码登入功能

用「邮箱 + 密码」留住个人产品的新用户太难了——从输入邮箱、查收邮件、点击链接到设定密码,流失率动辄 60%~70%;Google/Facebook OAuth 在国内不便,微信 OAuth 又需要企业主体认证。这里分享一个邪修方案:如果你已经有微信小程序(个人主体即可),完全可以把它作为「扫码跳板」——用户在你的网站看到一个二维码,微信一扫即可自动登录,留存直接翻倍。

在正式开始前,我们先看看主流方案的对比表格:

方案前置条件费用体验
微信开放平台(网站应用)企业营业执照 + 开发者资质认证300 元(一次性)最标准,扫码直接授权
公众号扫码登录认证服务号300 元/年扫码关注后登录
小程序个人实名即可注册0 元扫码进小程序,确认后登录
  • 其实微信开放平台是最正统的方案,用户扫码后直接在微信里弹授权确认,体验也最丝滑。但它要企业资质,个人开发者压根注册不了

本文给出「网页展示小程序码 → 用户扫码 → 小程序确认 → 网页轮询拿 token」的完整实现思路。后端约 250 行(以 WordPress REST API 为例),前端约 150 行,小程序 1 页 + 1 云函数 ~80 行,可移植到任何带 HTTP 接口的网站。

第一部分:架构总览

一、三个角色、四个步骤

整个登录链路涉及 3 个角色:浏览器(用户)你的网站后端你的微信小程序(含云函数)。

Step 1:浏览器 → 网站

用户点「微信扫码登录」」→ 浏览器请求 GET /auth/qr/create → 网站返回 {ticket, qr},其中 ticket 是 32 hex 随机串,qr 是 base64 PNG 小程序码。

Step 2:用户扫码 → 小程序

用户用微信「扫一扫」扫描小程序码 → 自动唤起小程序并跳转到 pages/qr-confirm 页,onLoad(options.scene) 拿到 ticket。

Step 3:小程序云函数 → 网站

onLoad 自动调 wx.cloud.callFunction({name:'wxLoginQr', data:{ticket}}) → 云函数拿 getWXContext().OPENID → POST /qr/confirm {ticket, openid, key} → 网站用 hash_equals 校验共享密钥,绑定 ticket → openid → user。

Step 4:浏览器轮询 → 网站

浏览器每 3 秒 GET /auth/qr/status?ticket=... → 后端返回 {status:'confirmed', token, user} → 浏览器写 localStorage('weiyu_qr_session', token) → 刷新页面 → 已登录态生效。

二、三条核心约束

  • ticket = 一次性握手——32 hex 随机、5 分钟过期、确认后即删,不含任何身份信息
  • 共享密钥 fail-closed——必须 hash_equals 常时比较,没配就拒绝
  • session token 不绑 IP——小程序与浏览器通常不在同一网络(4G/公司 WiFi),绑 IP 必死

第二部分:后端实现

三、注册三个 REST 端点

在插件入口 register_rest_route() 处追加三个 anonymous 端点(安全靠共享密钥兜底):

register_rest_route('weiyu/v1', '/auth/qr/create', [
    'methods'             => 'GET',
    'callback'            => [$this->qrLoginController, 'create'],
    'permission_callback' => '__return_true',
]);

register_rest_route('weiyu/v1', '/auth/qr/status', [
    'methods'             => 'GET',
    'callback'            => [$this->qrLoginController, 'status'],
    'permission_callback' => '__return_true',
    'args'                => [
        'ticket' => ['required' => true, 'sanitize_callback' => 'sanitize_key'],
    ],
]);

register_rest_route('weiyu/v1', '/qr/confirm', [
    'methods'             => 'POST',
    'callback'            => [$this->qrLoginController, 'confirm'],
    'permission_callback' => '__return_true',
    'args'                => [
        'ticket' => ['required' => true, 'sanitize_callback' => 'sanitize_key'],
        'openid' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
        'key'    => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
    ],
]);

四、ticket 状态机(核心)

三个端点的核心逻辑可以浓缩为「签发—等待—领取—失效」四阶段状态机:

// ① create: 签发 ticket + 调 getwxacodeunlimit
$ticket = bin2hex(random_bytes(16));                 // 32 hex,刚好填满 scene 上限
set_transient('weiyu_qr_login_'.$ticket, 'pending', 300);  // 5 分钟
$qr = (new WechatSecurityService())->getWxacode($ticket, 'pages/qr-confirm/qr-confirm');
return ['ticket' => $ticket, 'qr' => $qr ?: '', 'qr_error' => is_wp_error($qr) ? $qr->get_error_message() : ''];

// ② status: 轮询(confirmed 一次性领取后即删)
$state = get_transient('weiyu_qr_login_'.$ticket);
if ($state === 'pending') return ['status' => 'pending'];
if (is_array($state) && isset($state['user_id'])) {
    delete_transient('weiyu_qr_login_'.$ticket);    // 关键:领走即删
    $token = issueSessionToken($state['user_id'], $ttl, false);  // false = 不绑 IP
    return ['status' => 'confirmed', 'token' => $token];
}

// ③ confirm: 小程序云函数调用,绑定 openid
$shared = get_option('weiyu_qr_login_confirm_key', '');
if ($shared === '' || !hash_equals($shared, $key)) return 403;
//              ↑ fail-closed:没配就拒绝,不能"配了才校验"
$userId = resolveUserByOpenid($openid);              // 不存在则建 subscriber
set_transient('weiyu_qr_login_'.$ticket, ['user_id' => $userId], 60);  // 60s 让网页领 token

五、getWxacode:调用 getwxacodeunlimit

复用项目里现有的 access_token 缓存(5 分钟提前过期),scene 限制 32 字符:

// scene 必须 ≤32 字符 → bin2hex(random_bytes(16)) 刚好填满
$resp = wp_remote_post(
    add_query_arg(['access_token' => $token], 'https://api.weixin.qq.com/wxa/getwxacodeunlimit'),
    ['body' => wp_json_encode([
        'scene'      => $scene,
        'page'       => $page,
        'check_path' => true,   // 正式版必须 true
        'width'      => 430,
    ])]
);
$raw = wp_remote_retrieve_body($resp);
// 失败 = errcode≠0;成功 = 二进制 PNG
return is_wp_error($resp) ? new WP_Error('wxacode_failed') : 'data:image/png;base64,' . base64_encode($raw);

六、后台开关 + 共享密钥

密钥字段必须采用「password 不回显」+「空提交 = 保留现值」的模式,防止保存其他设置时静默清空密钥:

register_setting('weiyu_settings_group', 'weiyu_qr_login_enabled',
    ['sanitize_callback' => fn($v) => $v === '1' ? '1' : '0']);

register_setting('weiyu_settings_group', 'weiyu_qr_login_confirm_key',
    ['sanitize_callback' => function ($v) {
        $v = trim((string) $v);
        return $v === ''
            ? (string) get_option('weiyu_qr_login_confirm_key', '')
            : sanitize_text_field($v);
    }]);

第三部分:网站前端(SPA)

七、前端改动点

前端只有两处改动:① 未登录横幅加按钮;② spa.auth.js 增加 3 个方法(showQrLogin / startQrLogin / pollQrStatus)。

1. 横幅按钮

<button class="login-qr-btn" data-action="show-qr-login">
    <i class="fa-brands fa-weixin"></i> 微信扫码登入
</button>

八、弹窗 + 轮询(核心)

// 弹窗:把 loginWrapper 替换为扫码视图(左图右文 + 3 步骤提示)
showQrLogin() {
    this.stopQrPoll();
    DOM.loginWrapper.innerHTML = `
        <div class="login-banner warning">
            <div class="login-banner-header">
                <i class="fa-solid fa-qrcode"></i> 微信掃碼登入
            </div>
            <div class="login-qr-body">
                <div class="login-qr-canvas" id="login-qr-canvas"></div>
                <div class="login-qr-status" id="login-qr-status">等待掃描…</div>
            </div>
        </div>`;
    this.startQrLogin();
}

// 拉小程序码 + 启动轮询
async startQrLogin() {
    const res = await API.fetch('/auth/qr/create');
    if (!res.success) return showError(res.error);
    document.getElementById('login-qr-canvas').innerHTML =
        `<img class="login-qr-img" src="${res.data.qr}">`;
    this._qrTicket = res.data.ticket;
    this._qrTimer  = setInterval(() => this.pollQrStatus(), 3000);
}

async pollQrStatus() {
    const r = await fetch(`/auth/qr/status?ticket=${this._qrTicket}`);
    if (r.status === 410) return showError('二維碼已過期,請重新整理');
    const res = await r.json();
    if (res.success && res.data?.status === 'confirmed' && res.data.token) {
        this.stopQrPoll();
        localStorage.setItem('weiyu_qr_session', res.data.token);  // 与账密登录共用 key
        setTimeout(() => location.reload(), 600);                  // 让 init() 接管登录态
    }
}

事件委托注册

// spa.init.js - 已有 case 'show-login' / 'show-forgot' 旁加一行
case 'show-qr-login': Auth.showQrLogin(); break;

第四部分:小程序端

九、qr-confirm 页(核心:onLoad 自动提交)

用户从微信「扫一扫」进入本页时,scene 参数(32 hex ticket)已经在 onLoad(options.scene) 里——无需用户点任何按钮:

// pages/qr-confirm/qr-confirm.js
onLoad(options) {
    const ticket = (options.scene || '').trim();
    if (!/^[0-9a-f]{32}$/.test(ticket)) return this.setData({ status: '无效二维码' });
    wx.cloud.callFunction({
        name: 'wxLoginQr',                                 // 云函数拿 OPENID
        data: { ticket },
        success: res => res.result?.ok
            ? this.setData({ status: '已确认 ✓ 请返回浏览器' })
            : this.setData({ status: '确认失败:' + res.result?.message }),
    });
}

十、云函数 wxLoginQr

为什么要用云函数而不是小程序直接 fetch 网站?两个原因:

  1. openid 只能从服务端 getWXContext() 拿到,前端无法伪造
  2. 共享密钥不能落到前端代码里(小程序可被反编译),必须放在云函数环境变量
exports.main = async (event) => {
    const { OPENID } = cloud.getWXContext();
    if (!OPENID) return { ok: false, code: 'NO_OPENID' };

    const ticket = String(event.ticket || '');
    if (!/^[0-9a-f]{32}$/.test(ticket)) return { ok: false, code: 'BAD_TICKET' };

    // 共享密钥只从环境变量读,不传给前端
    const sharedKey = process.env.QR_CONFIRM_KEY || '';
    if (!sharedKey) return { ok: false, code: 'NO_KEY' };

    const resp = await fetch(
        process.env.QR_CONFIRM_URL || 'https://your-site.com/wp-json/weiyu/v1/qr/confirm',
        {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ ticket, openid: OPENID, key: sharedKey }),
        }
    );

    // 状态码 2xx 即视为成功(WAF/CDN 可能吞 body,不能依赖 resp.ok)
    if (resp.status >= 200 && resp.status < 300) return { ok: true };
    return { ok: false, code: 'CONFIRM_FAIL', message: `HTTP ${resp.status}` };
};

云函数环境变量配置(控制台 → 云函数 → wxLoginQr → 配置):

  • QR_CONFIRM_URL:网站 /qr/confirm 完整 URL
  • QR_CONFIRM_KEY:与后台「共享密钥」一致的随机串(建议 64 hex)

第五部分:关键决策与踩坑

十一、关键约束

① scene 必须是 32 hex

getwxacodeunlimit 的 scene 参数最长 32 字符。UUID 是 36 字符直接 400。直接 bin2hex(random_bytes(16)) = 32 hex,刚好用满。

② check_path 决定能否生成

默认 check_path: truepage 必须是已发布的小程序页面路径——开发版/未发布的页面会 errcode 40001。开发期传 check_path: false,正式版再开启。

③ session token 不绑 IP

小程序与浏览器几乎从不在同一 IP(4G/公司 WiFi/家庭宽带)。绑 IP 的 token 用户永远领不到——issueSessionToken($id, $ttl, false) 第三参数必须 false。

④ WAF/CDN 可能吞 body

云函数只能根据状态码判断成功,不能依赖 body:

// ✅ 正确写法
if (resp.status >= 200 && resp.status < 300) return { ok: true };

// ❌ 反例
if (resp.body?.success) return { ok: true };   // WAF 可能吞掉 body 导致永远 false

⑤ 共享密钥必须 fail-closed

// ❌ 反模式:if (SECRET && !check(...)) reject;
//    空串时条件短路 = 零鉴权
// ✅ 正例:
if ($shared === '' || !hash_equals($shared, $key)) reject;

第六部分:端到端验证与部署

十二、端到端验证清单

  1. 验证 ticket + 小程序码curl /auth/qr/create 应返回 {ticket, qr}
  2. 验证关闭开关 403:后台关闭开关后重跑,应返回 403 {code:'disabled'}
  3. 验证错误密钥 403curl -X POST /qr/confirm -d '{..., key:'wrong'}' 应返回 403
  4. 验证完整链路:网页点按钮 → 微信扫码 → 小程序显示「已确认」→ 网页 1 秒内自动登录
  5. 验证过期行为:5 分钟内不扫码应返回 410 状态码,提示「已过期」;重新点按钮可拿新 ticket

十三、部署清单

  1. 后端 PHPsrc/ 整个目录(含新增 QrLoginController.php)→ 推完清 OPcache
  2. 后台:「微信小程序配置」填 appid/secret →「微信扫码登入」开关 + 共享密钥(与云函数 QR_CONFIRM_KEY 保持一致)
  3. 前端spa.min.js + spa.css + soul.html / soul.makers.html 推 CDN
  4. 小程序:上传 pages/qr-confirm → 微信开发者工具右键上传 → 控制台「发布为正式版」
  5. 云函数:部署 wxLoginQr → 配 QR_CONFIRM_URL + QR_CONFIRM_KEY 环境变量

结语

整套实现加起来约 500 行(后端 ~250 + 前端 ~150 + 小程序端 ~80),不依赖任何外部 SDK,纯原生实现,可移植到任何带 HTTP 接口的网站。关键不是 API 怎么调通,而是这五条非显然约束:

openid 只能从服务端拿 / scene 限制 32 字符 / token 不能绑 IP / 密钥必须 fail-closed / WAF 会吞 body。

具体效果大家可以访问:https://soul.mlsha.cn体验

相关推荐

小米路由器AX3600 解锁SSH

小米AIoT路由器AX3600,是一台标准的Wi-Fi 6路由,采用高通第二代Wi-Fi 6方案,更加成熟。全套高通芯片,2.4GHz频段支持2T2R ...

暂无评论