微信掃小程式碼實現網頁端登入

yanthink 發表於 2019-08-09

常見的微信掃碼登入有兩種

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()}`;
}

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