回顾:微信小程序用户授权登录

技术探讨  ·  2025-12-14

前言

这两天折腾小程序,遇到了一个不可绕过的话题:用户登录 。我了解到小程序登录目前的两种主流方式:

  1. 手机号授权登录
  2. 用户授权登录

第一种需要消耗手机号授权组件的次数,属于收费的,我就想既然有自己的服务器和数据库那就没必要了,就像着自己搭建后端api接口,给小程序用。

于是昨天晚上我阅遍全网微信登录文章,对线各大厂商 AI,奋战了18h,终于是把用户登录做完了!现在我要写一篇博文,来记录我的过程和心得。

完成后的后端(接口+数据库),可以做到:

项目状态说明
用户隔离openid 唯一索引 + 外键约束
防重复注册ON DUPLICATE KEY UPDATE
Token 安全固定 64 位密钥 + HMAC-SHA256
数据持久化MySQL + 预处理语句
订单隔离WHERE openid = ? 自动过滤

接口调用:https://api.xxx.com/api.php

正文

按我的理解,流程图长这样:

小程序 wx.login() 获取 code → 后端用 code 换取 openid+session_key → 生成 JWT token → 小程序存储 token 和 session_key → 用户授权获取 encryptedData+iv → 后端解密授权信息并保存到 MySQL → 登录完成

了解完流程,需要确保你有以下条件:

  • 一台服务器+域名(https://)
  • 小程序项目
  • PHP+MySQL 环境

小程序端
App.js

globalData: {
    customTabBar: null,
    userInfo: null,
    phone: null,
    token: null,
    baseUrl: 'https://api.xxx.com/api.php' // 你的后端接口地址
},

  login(cb) {
    wx.login({
      success: (res) => {
        if (res.code) {
          wx.request({
            url: `${this.globalData.baseUrl}?action=login`,
            method: 'POST',
            header: { 'content-type': 'application/x-www-form-urlencoded' },
            data: { code: res.code },
            success: (reqRes) => {
              const { token, session_key, exists } = reqRes.data;
              if (!token) {
                wx.showToast({ title: '登录失败', icon: 'none' });
                return;
              }
              this.globalData.token = token;
              this.globalData.sessionKey = session_key;
              wx.setStorageSync('token', token);
              wx.setStorageSync('sessionKey', session_key); // 持久化存储
              cb && cb();
            }
          });
        }
      }
    });
  },

用户登录界面:

// 获取用户信息授权
  onGetUserInfo(e) {
    if (e.detail.errMsg !== 'getUserInfo:ok') return;

    const { encryptedData, iv } = e.detail;

    // 确保 session_key 存在且未过期
    if (!app.globalData.sessionKey) {
      // 尝试从缓存恢复
      app.globalData.sessionKey = wx.getStorageSync('sessionKey');
    }

    if (!app.globalData.sessionKey) {
      wx.showToast({ title: '登录已过期,请重启小程序', icon: 'none' });
      // 重新登录
      app.login(() => {
        this.onGetUserInfo(e); // 重试
      });
      return;
    }

    wx.request({
      url: `${app.globalData.baseUrl}?action=save_userinfo`,
      method: 'POST',
      header: {
        'Authorization': `Bearer ${app.globalData.token}`,
        'content-type': 'application/x-www-form-urlencoded'
      },
      data: {
        encryptedData,
        iv,
        session_key: app.globalData.sessionKey
      },
      success: (res) => {
        console.log('/save_userinfo 返回', res.data);
        if (res.data.success) {
          app.globalData.userInfo = res.data.userInfo;
          this.updateUserInfo();
          wx.showToast({ title: '授权成功', icon: 'success' });
        } else if (res.data.error === '未授权或token过期') {
          // Token 过期,重新登录
          app.login(() => {
            this.onGetUserInfo(e); // 重试
          });
        } else {
          wx.showToast({ title: res.data.error || '保存失败', icon: 'none' });
        }
      },
      fail: (err) => {
        console.error('请求失败', err);
        wx.showToast({ title: '网络错误', icon: 'none' });
      }
    });
  },
// 更新用户信息
  updateUserInfo() {
    const app = getApp();
    // 用最新 globalData 覆盖 page.data
    this.setData({
      'userInfo.avatar': app.globalData.userInfo?.avatarUrl || '/assets/logo.png',
      'userInfo.nickname': app.globalData.userInfo?.nickName || '点击登录',
      'userInfo.phone': app.globalData.phone || ''
    });
    console.log('刷新头像昵称', this.data.userInfo);
  },

  // 点击用户信息区域
  onUserInfoTap() {
    if (!app.globalData.token) {
      wx.showToast({
        title: '请先登录',
        icon: 'none'
      });
      return;
    }

    if (!app.globalData.userInfo) {
      this.showAuthModal();
    } else {
      // 已登录,可以编辑信息
      console.log('用户已登录,可以编辑信息');
    }
  },
// 显示授权弹窗
  showAuthModal() {
    wx.showModal({
      title: '授权登录',
      content: '需要获取您的微信头像和昵称',
      success: (res) => {
        if (res.confirm) {
          // 用户同意,显示授权按钮
          this.setData({ showAuthButton: true });
        }
      }
    });
  },

后端PHP:

<?php
// api.php

ini_set('display_errors', 1);
error_reporting(E_ALL);

header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *'); // 生产环境改为小程序域名
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');

// ==================== 配置区 ====================
define('WX_APPID', '你的小程序AppID');
define('WX_SECRET', '小程序后台生成');
define('JWT_SECRET', '自行生成');

// MySQL 配置
define('DB_HOST', 'localhost');
define('DB_NAME', '数据库名');
define('DB_USER', '数据库用户名');
define('DB_PASS', '数据库密码');

function get_db() {
    static $pdo;
    if (!$pdo) {
        try {
            $pdo = new PDO(
                "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
                DB_USER,
                DB_PASS,
                [
                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                    PDO::ATTR_EMULATE_PREPARES => false
                ]
            );
        } catch (PDOException $e) {
            error_log("DB Connection Error: " . $e->getMessage());
            exit(json_encode(['error' => '系统错误']));
        }
    }
    return $pdo;
}

function get_post_data() {
    $contentType = $_SERVER['CONTENT_TYPE'] ?? '';
    if (strpos($contentType, 'application/json') !== false) {
        return json_decode(file_get_contents('php://input'), true) ?: [];
    }
    return $_POST;
}

function generate_token($openid) {
    $payload = ['openid' => $openid, 'iat' => time(), 'exp' => time() + 86400 * 7];
    $header = base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
    $payload = base64_encode(json_encode($payload));
    $signature = hash_hmac('sha256', "$header.$payload", JWT_SECRET);
    return "$header.$payload.$signature";
}

function verify_token($token) {
    $parts = explode('.', $token);
    if (count($parts) !== 3) return null;
    $signature = hash_hmac('sha256', "$parts[0].$parts[1]", JWT_SECRET);
    if (!hash_equals($signature, $parts[2])) return null;
    $payload = json_decode(base64_decode($parts[1]), true);
    if (!$payload || $payload['exp'] < time()) return null;
    return $payload['openid'];
}

function wx_decrypt($encryptedData, $iv, $sessionKey) {
    $key = base64_decode($sessionKey);
    $data = base64_decode($encryptedData);
    $iv = base64_decode($iv);
    if (!$key || !$data || !$iv) return null;
    $decrypted = openssl_decrypt($data, 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv);
    return $decrypted ? json_decode($decrypted, true) : null;
}

function generate_order_no($pdo) {
    $date = date('Ymd');
    $pdo->beginTransaction();
    try {
        $stmt = $pdo->query("SELECT MAX(CAST(SUBSTRING_INDEX(order_no, '_', -1) AS UNSIGNED)) as max_seq 
                            FROM orders WHERE DATE(created_at) = CURDATE() FOR UPDATE");
        $nextSeq = ($stmt->fetchColumn() ?? 0) + 1;
        $orderNo = "{$date}_{$nextSeq}";
        $pdo->commit();
        return $orderNo;
    } catch (Exception $e) {
        $pdo->rollBack();
        throw $e;
    }
}

$action = $_GET['action'] ?? '';
$input = get_post_data();

try {
    switch ($action) {
        case 'login':         wechat_login($input);         break;
        case 'save_userinfo': wechat_save_userinfo($input); break;
        case 'save_phone':    wechat_save_phone($input);    break;
        case 'user_info':     wechat_user_info();           break;
        case 'create_order':  wechat_create_order($input);  break;
        case 'get_orders':    wechat_get_orders();          break;
        default:              echo json_encode(['error' => '未知接口']);
    }
} catch (Exception $e) {
    error_log("API Error: " . $e->getMessage());
    echo json_encode(['error' => '系统繁忙,请稍后重试']);
}

// 登录/注册(自动处理重复登录)
function wechat_login($input) {
    $code = $input['code'] ?? '';
    if (!$code) exit(json_encode(['error' => '缺少code']));

    $url = "https://api.weixin.qq.com/sns/jscode2session?appid=" . WX_APPID . "&secret=" . WX_SECRET . "&js_code={$code}&grant_type=authorization_code";
    $res = file_get_contents($url);
    $arr = json_decode($res, true);

    if (isset($arr['errcode'])) {
        exit(json_encode(['error' => '微信接口错误', 'msg' => $arr['errmsg']]));
    }

    $openid = $arr['openid'];
    $session_key = $arr['session_key'];

    $pdo = get_db();
    // 核心:ON DUPLICATE KEY UPDATE 确保同一 openid 只存在一条记录
    $stmt = $pdo->prepare("
        INSERT INTO users (openid, session_key, updated_at) 
        VALUES (:openid, :session_key, NOW())
        ON DUPLICATE KEY UPDATE session_key = VALUES(session_key), updated_at = NOW()
    ");
    $stmt->execute([
        ':openid' => $openid,
        ':session_key' => $session_key
    ]);

    $token = generate_token($openid);
    
    // 检查是否已授权
    $stmt = $pdo->prepare("SELECT nickname, points FROM users WHERE openid = ?");
    $stmt->execute([$openid]);
    $user = $stmt->fetch();

    echo json_encode([
        'token' => $token,
        'session_key' => $session_key,
        'exists' => !empty($user['nickname']),
        'points' => $user['points'] ?? 0
    ]);
}

// 在 wx_decrypt 函数后添加 session_key 刷新机制
function refresh_session_key($openid, $code) {
    // 用新 code 换取新 session_key
    $url = "https://api.weixin.qq.com/sns/jscode2session?appid=" . WX_APPID . "&secret=" . WX_SECRET . "&js_code={$code}&grant_type=authorization_code";
    $res = file_get_contents($url);
    $arr = json_decode($res, true);
    
    if (isset($arr['session_key'])) {
        $pdo = get_db();
        $stmt = $pdo->prepare("UPDATE users SET session_key = ? WHERE openid = ?");
        $stmt->execute([$arr['session_key'], $openid]);
        return $arr['session_key'];
    }
    return null;
}

// 保存用户信息
function wechat_save_userinfo($input) {
    // 添加内部 try-catch,直接暴露错误
    try {
        $token = explode(' ', $_SERVER['HTTP_AUTHORIZATION'] ?? '')[1] ?? '';
        $openid = verify_token($token);
        if (!$openid) exit(json_encode(['error' => '未授权或token过期']));

        $encryptedData = $input['encryptedData'] ?? '';
        $iv = $input['iv'] ?? '';
        $sessionKey = $input['session_key'] ?? '';

        if (!$sessionKey) {
            exit(json_encode(['error' => '缺少 session_key']));
        }

        $userInfo = wx_decrypt($encryptedData, $iv, $sessionKey);
        if (!$userInfo) {
            exit(json_encode(['error' => '解密失败']));
        }

        $pdo = get_db();
        $stmt = $pdo->prepare("
            UPDATE users 
            SET nickname = ?, avatar_url = ?, gender = ?, city = ?, province = ?, country = ?, updated_at = NOW()
            WHERE openid = ?
        ");
        $stmt->execute([
            $userInfo['nickName'] ?? '微信用户',
            $userInfo['avatarUrl'] ?? '',
            $userInfo['gender'] ?? 0,
            $userInfo['city'] ?? '',
            $userInfo['province'] ?? '',
            $userInfo['country'] ?? '',
            $openid
        ]);

        echo json_encode(['success' => true, 'userInfo' => $userInfo]);
        
    } catch (Exception $e) {
        // 直接返回真实错误,不隐藏
        echo json_encode(['error' => 'DEBUG: ' . $e->getMessage()]);
        exit;
    }
}

// 保存手机号
function wechat_save_phone($input) {
    //仅做展示功能
}

// 创建订单
function wechat_create_order($input) {
    //仅做展示功能
}

// 获取用户订单列表
function wechat_get_orders() {
    //仅做展示功能
}

总结

第 1 步:获取临时凭证
小程序调用 wx.login(),微信返回一个只能用一次的 code(有效期 5 分钟)。这个 code 就像一次性验证码,用来证明"这个用户正在登录"。

第 2 步:换取用户身份标识
后端收到 code 后,立刻请求微信服务器,用 code 换两条核心信息:

  • openid:用户的微信唯一 ID,一辈子不变,相当于用户的"数字身份证号"
  • session_key:本次登录的临时加密密钥,30 分钟后失效

第 3 步:生成访问令牌
后端用 openid + 固定密钥 + 过期时间,生成一个 JWT Token(一串加密字符串)。Token 就是用户的"会员证",小程序把它存起来,之后每次请求都带着,证明"我已登录"。

第 4 步:授权并加密传输
用户点击"授权"按钮后,微信用 session_key 把头像、昵称加密成 encryptedData。小程序把这个加密包 + Token 一起发给后端。

第 5 步:解密并持久化
后端验证 Token 有效 → 用 session_key 解密出用户信息 → 把 openid、昵称、头像等存入 MySQL 数据库。下次用户再来,直接查 openid 就知道他是谁。

安全保障:

  1. 身份锁openid 全球唯一,数据库里设成"不可重复",保证一个人只对应一条记录
  2. 传输锁session_key 加密,就算信息被拦截,没有密钥也解不开
  3. 权限锁 :后端每个查询都强制加上 WHERE openid='xxx',用户只能读自己的数据,不可能看到别人的订单
上一篇:Git:启程
下一篇:没有了
评论
LimeBit. All Rights Reserved.

Write by GEORGEWU | 琼ICP备2024029190号