微信扫小程序码实现网页端登录

常见的微信扫码登录有两种

1、微信开放平台

2、微信服务号

这两种方式都需要提交企业资料认证和300元年费,有些想要学习或者自己的网站没有盈利的,其实不舍得花这个钱,特别是个人开发者,没有企业资料去做认证。

既然没法做企业认证,那我们就把矛头指向微信小程序了。

微信小程序无论是个人还是企业的,都开放了获取用户的基本信息,无须认证,不收费。而且,还提供了 1 个可以生成带参数的,数量暂无限制小程序码接口,所以我们就可以通过这个接口实现扫码登录了。

实现原理

  • 登录页面从服务端获取一个带uuid参数的小程序码,然后创建一个websocket并带上这个uuid参数(用于网页端和小程序的通信绑定)
  • 用户通过微信扫码授权后把登录code、用户信息和uuid参数提交到服务端
  • 服务端根据登录code获取openId,然后在根据openId创建用户,最后生成user token广播给前端(通过uuid找到对应的soket链接并发送)
  • 前端接收到token后,auth 登录,页面再重载一下,流程完毕

实战

获取小程序码

前端获取小程序码并创建websocket

import { Form, Tabs, Input, Button, Checkbox, Spin, Icon, message } from 'antd';
import React, { Component } from 'react';
import { FormComponentProps } from 'antd/es/form';
import { connect } from 'dva';
import { Link } from 'umi';
import { ConnectState, ConnectProps } from '@/models/connect';
import { getLoginCode } from './service';
import styles from './style.less';

const FormItem = Form.Item;
const { TabPane } = Tabs;

interface LoginProps extends ConnectProps, FormComponentProps {
  submitting: boolean;
}

interface LoginState {
  base64Img: string;
  codeExpired: boolean;
  codeLoading: boolean;
}

@connect(({ loading }: ConnectState) => ({
  submitting: loading.effects['authLogin/login'],
}))
class Login extends Component<LoginProps, LoginState> {
  static socketTimeout = 120000;

  state: LoginState = {
    base64Img: '',
    codeExpired: false,
    codeLoading: false,
  };

  ws: any;

  timer: any;

  componentDidMount() {
    this.createWebSocket();
  }

  componentWillUnmount() {
    clearTimeout(this.timer);
    if (this.ws && this.ws.readyState === 1) {
      this.ws.close();
    }
  }

  createWebSocket = async () => {
    clearTimeout(this.timer);

    if (this.ws && this.ws.readyState === 1) {
      this.ws.close();
    }

    this.setState({ codeExpired: false, codeLoading: true });

    const { data: { base64_img: base64Img, token } } = await getLoginCode();

    const socketUrl = `wss://${window.location.host}/wss?token=${token}`;
    this.ws = new WebSocket(socketUrl);

    this.ws.addEventListener('message', (e: any) => {
      const { data: msg } = e;

      const { event, data } = JSON.parse(msg);
      /* eslint no-case-declarations:0 */
      switch (event) {
        case 'App\\Events\\WechatScanLogin':
          const { token, permissions } = data;
          // 获取到token后Auth登录
          this.props.dispatch({
            type: 'authLogin/loginSuccess',
            payload: { token, permissions },
            callback: () => {
              message.success('登录成功!');
              clearTimeout(this.timer);
              if (this.ws && this.ws.readyState === 1) {
                this.ws.close();
              }
            },
          });
          break;
        default:
          break;
      }
    });

    this.setState({ base64Img, codeExpired: false, codeLoading: false });

    this.timer = setTimeout(this.handleWebSocketTimeout, Login.socketTimeout);
  };

  handleWebSocketTimeout = () => {
    if (this.ws && this.ws.readyState === 1) {
      this.ws.close();
    }

    this.setState({ codeExpired: true });
  };

  handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const { form } = this.props;

    form.validateFields({ force: true }, (err: any, values: object) => {
      if (!err) {
        const { dispatch } = this.props;
        dispatch({
          type: 'authLogin/login',
          payload: values,
        });
      }
    });
  };

  renderCode = () => {
    const { base64Img, codeExpired, codeLoading } = this.state;
    if (codeExpired) {
      return (
        <>
          <Icon type="close-circle" /><span className={styles.noticeTitle}>小程序码已失效</span>
          <Button
            className={styles.noticeBtn}
            type="primary"
            size="large"
            block
            onClick={this.createWebSocket}
          >
            刷新小程序码
          </Button>
        </>
      );
    }

    return (
      <>
        <p>微信扫码后点击“登录”,</p>
        <p>即可完成账号绑定及登录。</p>
        {
          codeLoading
            ? <Spin indicator={<Icon type="loading" spin />} tip="正在加载..." />
            : <img src={`data:image/png;base64,${base64Img}`} alt="小程序码" width="260" height="260" />
        }
      </>
    );
  };

  render() {
    const { form, submitting } = this.props;
    const { getFieldDecorator } = form;

    return (
      <div className={styles.main}>
        <Form onSubmit={this.handleSubmit}>
          <Tabs size="large">
            <TabPane tab="微信扫码登录" key="1">
              <div className={styles.qrcodeBox}>
                {this.renderCode()}
              </div>
            </TabPane>
            <TabPane tab="账户密码登录" key="2">
              <div>
                <FormItem hasFeedback>
                  {getFieldDecorator('account', {
                    rules: [{ required: true, message: '请输入账户名称!' }],
                  })(<Input size="large" placeholder="账户名称" />)}
                </FormItem>
                <FormItem hasFeedback>
                  {getFieldDecorator('password', {
                    rules: [{ required: true, message: '请输入账户密码!'}],
                  })(<Input.Password size="large" placeholder="账户密码" />)}
                </FormItem>
                <FormItem>
                  {getFieldDecorator('remember')(
                    <Checkbox>自动登录</Checkbox>,
                  )}
                  <Link style={{ float: 'right' }} to="#">
                    忘记密码
                  </Link>
                  <Button size="large" type="primary" block loading={submitting} htmlType="submit">
                    登录
                  </Button>
                </FormItem>
              </div>
            </TabPane>
          </Tabs>
        </Form>
      </div>
    );
  }
}

export default Form.create<LoginProps>()(Login);

服务端生成小程序码逻辑

<?php

namespace App\Http\Controllers\V2;

use EasyWeChat;
use Illuminate\Support\Str;
use EasyWeChat\Kernel\Http\StreamResponse;
use JWTFactory;
use JWTAuth;

class WechatController extends Controller
{
    public function loginCode()
    {
        $uuid = Str::random(16);

        $miniProgram = EasyWeChat::miniProgram();

        $response = $miniProgram->app_code->getUnlimit('scan-login/' . $uuid, [
            'page' => 'pages/auth/scan-login',
            'width' => 280,
        ]);

        if ($response instanceof StreamResponse) {
            $payload = JWTFactory::setTTL(2)->sub($uuid)->make();
            $token = (string)JWTAuth::encode($payload);

            $response->getBody()->rewind();

            $base64_img = base64_encode($response->getBody()->getContents());

            $data = compact('token', 'base64_img');
            return compact('data');
        }

        return $response;
    }
}

小程序扫码处理逻辑

// pages/auth/scan-login.js
import regeneratorRuntime from '../../utils/runtime'
const { setToken } = require('../../utils/authority');
const { login } = require('../../utils/helpers')

Page({
  data: {
    uuid: '',
  },

  async onGetUserInfo(e) {
    if (e.detail.userInfo) { // 用户按了允许授权按钮
      setToken();
      await login({ uuid: this.data.uuid });

      wx.reLaunch({
        url: '/pages/user/index'
      })

      wx.showToast({
        title: '登录成功',
        icon: 'none',
      });
    }
  },

  async onLoad(query) {
    const scene = decodeURIComponent(query.scene);
    const uuid = scene.split('/')[1];
    this.setData({ uuid });
  },
})
<!-- pages/auth/scan-login.wxml -->
<view class="page">
    <view class="page__hd">
      <view style='text-align: center'>
        <image src="/images/icon/pc.png" style="width: 200px; height: 200px" />
        <view style='text-align: center'>
          <text style='font-size: 32rpx'>WEB 端登录确认</text>
        </view>
      </view>

      <view style='text-align: center'>
        <button
          style='width: 400rpx'
          class="weui-btn"
          type="primary"
          open-type="getUserInfo"
          bindgetuserinfo="onGetUserInfo"
        >
          登录
        </button>
        <view style='font-size: 32rpx; margin-top: 40rpx; color: rgba(0, 0, 0, 0.5)'>
          <navigator open-type="exit" target="miniProgram">取消登录</navigator>
        </view>
      </view>
    </view>
</view>

扫码授权后服务端处理逻辑

public function login(Request $request)
{
    $this->validate($request, [
        'code' => 'required',
    ]);

    $code = $request->input('code');
    $miniProgram = EasyWeChat::miniProgram();
    $miniProgramSession = $miniProgram->auth->session($code);

    $openId = $miniProgramSession->openid;
    $sessionKey = $miniProgramSession->session_key;

    $lockName = self::class . "@store:$openId";
    $lock = Cache::lock($lockName, 60);
    abort_if(!$lock->get(), 422, '操作过于频繁,请稍后再试!');

    $userInfo = $request->input('userInfo');
    $rawData = $request->input('rawData');
    $signature = $request->input('signature');
    $signature2 = sha1($rawData . $sessionKey);

    abort_if($signature !== $signature2, 403, '数据不合法!');

    $user = User::where('we_chat_openid', $openId)->first();

    if (!$user) {
        $user = new User;
        // $user->name = Arr::get($userInfo, 'nickName', '');
        $user->we_chat_openid = $openId;
        $user->user_info = $userInfo;

        $user->save();
    }

    $token = Auth::login($user);

    $data = [
        'access_token' => $token,
        'token_type' => 'Bearer',
        'expires_in' => Carbon::now()->addMinutes(config('jwt.ttl'))->toDateTimeString(),
        'unread_count' => $user->unreadNotifications()->count(),
    ];

    $lock->release();

    $uuid = $request->input('uuid');

    if ($uuid) {
        $permissions = $user->getAllPermissions()->pluck('name');
        // 把token广播给前端
        event(new WechatScanLogin($uuid, "Bearer $token", $permissions));
    }

    return compact('data');
}

WechatScanLogin 事件

<?php

namespace App\Events;

use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class WechatScanLogin implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    protected $uuid;

    public $token;

    public $permissions;

    public function __construct($uuid, $token, $permissions)
    {
        $this->uuid = $uuid;
        $this->token = $token;
        $this->permissions = $permissions;
    }

    public function broadcastOn()
    {
        return new PrivateChannel('scan-login.' . $this->uuid);
    }
}

websocket server

const WebSocket = require('ws'); // socket.io 支持的协议版本(4)和 微信小程序 websocket 协议版本(13)不一致,所以选用ws
const Redis = require('ioredis');
const fs = require('fs');
const ini = require('ini');
const jwt = require('jsonwebtoken');
const url = require('url');

const config = ini.parse(fs.readFileSync('./.env', 'utf8')); // 读取.env配置

const redis = new Redis({
    port: env('REDIS_PORT', 6379),          // Redis port
    host: env('REDIS_HOST', '127.0.0.1'),   // Redis host
    // family: 4,           // 4 (IPv4) or 6 (IPv6)
    password: env('REDIS_PASSWORD', null),
    db: 0,
});

const wss = new WebSocket.Server({
    port: 6001,
    clientTracking: false,
    verifyClient({req}, cb) {
        try {
            const urlParams = url.parse(req.url, true);
            const token = urlParams.query.token || req.headers.authorization.split(' ')[1];
            const jwtSecret = env('JWT_SECRET');
            const algorithm = env('JWT_ALGO', 'HS256');

            const {sub, nbf, exp} = jwt.verify(token, jwtSecret, {algorithm});

            if (Date.now() / 1000 > exp) {
                cb(false, 401, 'token已过期.')
            }

            if (Date.now() / 1000 < nbf) {
                cb(false, 401, 'token未到生效时间.')
            }

            if (!sub) {
                cb(false, 401, '无法验证令牌签名.')
            }

            cb(true)
        } catch (e) {
            console.info(e);
            cb(false, 401, 'Token could not be parsed from the request.');
        }

    },
});

const clients = {};

wss.on('connection', (ws, req) => {
    try {
        const urlParams = url.parse(req.url, true);
        const token = urlParams.query.token || req.headers.authorization.split(' ')[1];
        const jwtSecret = env('JWT_SECRET');
        const algorithm = env('JWT_ALGO', 'HS256');

        const {sub} = jwt.verify(token, jwtSecret, {algorithm});
        const uuid = sub;

        ws.uuid = uuid;
        if (!clients[uuid]) {
            clients[uuid] = [];
        }

        clients[uuid].push(ws);
    } catch (e) {
        ws.close();
    }

    ws.on('message', message => { // 接收消息事件
        if (ws.uuid) {
            console.info('[%s] message:%s %s', getNowDateTimeString(), ws.uuid, message);
        }
    });

    ws.on('close', () => { // 关闭链接事件
        if (ws.uuid) {
            console.info('[%s] closed:%s', getNowDateTimeString(), ws.uuid);

            const wss = clients[ws.uuid];

            if (wss instanceof Array) {
                const index = wss.indexOf(ws);

                if (index > -1) {
                    wss.splice(index, 1);
                    if (wss.length === 0) {
                        delete clients[ws.uuid];
                    }
                }
            }
        }
    })
});


// redis 订阅
redis.psubscribe('*', function (err, count) {
});

redis.on('pmessage', (subscrbed, channel, message) => { // 接收 laravel 推送的消息
    console.info('[%s] %s %s', getNowDateTimeString(), channel, message);

    const {event} = JSON.parse(message);
    const uuid = channel.split('.')[1];
    const wss = clients[uuid];

    switch (event) {
        case 'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated':
        case 'App\\Events\\WechatScanLogin':
            if (wss instanceof Array) {
                wss.forEach(ws => {
                    if (ws.readyState === 1) {
                        ws.send(message);
                    }
                });
            }
            break;
    }
});

function env(key, def = '') {
    return config[key] || def
}

function getNowDateTimeString() {
    const date = new Date();
    return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
}

演示地址:www.einsition.com/login

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 8

:+1:不错,这种也是给个人开发者一个不错的选择。

4年前 评论

你好,大佬,请问小程序没发布这种扫码的功能怎么测试呢?扫码的时候提示小程序未发布,所以之后的操作不知道怎么进行。。。

4年前 评论
yanthink (楼主) 4年前

太复杂麻烦了,还要redis pubsub,还要websocket

https://github.com/snower/slock

实现了一个原子操作DB,可以实现普通Event Set和Wait语义,二维码这边循环轮询超时等待就完了,简单多了

4年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!