常見的微信掃碼登入有兩種
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()}`;
}