微信扫小程序码实现网页端登录
常见的微信扫码登录有两种
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()}`;
}
本作品采用《CC 协议》,转载必须注明作者和本文链接
:+1:
:+1:
优秀
:+1:不错,这种也是给个人开发者一个不错的选择。
你好,大佬,请问小程序没发布这种扫码的功能怎么测试呢?扫码的时候提示小程序未发布,所以之后的操作不知道怎么进行。。。
太复杂麻烦了,还要redis pubsub,还要websocket
https://github.com/snower/slock
实现了一个原子操作DB,可以实现普通Event Set和Wait语义,二维码这边循环轮询超时等待就完了,简单多了
:+1:
非常不错
weixunlogin.com