实现思路
step 1: 网页端提供二维码
step 2: 手机端登录,并保存token,手机扫码后向网页端发送token
step 3: 网页端通过jstoken解析token,向后端服务器获取用户信息
step 4: 最总实现扫码登录
即:需要被扫码客户端向服务端请求创建websocket(后称ws),服务端返回房间号也就是id,然后让可以扫码的设备登录,获取token(登录靠token实现,不用账号和密码,只有在生成token时才需要这些隐私信息),再通过创建的房间号加入ws,手动授权后向加入的房间发送token,最终实现扫码登录
搭建服务端
websocket.js
const WebSocket = require('ws')
const wss = new WebSocket.Server({
port: 3001
})
var id = 1 // ws 链接的最开始id
wss.on('connection', function connection (ws){
// 服务器广播
ws.on('message', function message(data) {
// 处理消息
try{
data = JSON.parse(data)
}catch(e){
console.log('拦截到一条消息')
return
}
// 是连接状态
if (ws.readyState === WebSocket.OPEN) {
switch(data.event){ // 判断消息事件
case 'heartbeat': // 心跳检测
if(data.msg === 'ping'){ // 客户端检测
ws.send(JSON.stringify({
event: 'heartbeat',
msg: 'pong'
}))
}
else if(data.msg === 'pong'){ // 服务端检测
ws.isAlive = true
}
return; // 事件处理完成
case 'succeedLogin':
console.log('收到登录成功请求')
wss.clients.forEach((client) => {
// 给客户端发送登录成功提示!
if(client.id + ''=== data.id + ''){
client.send(JSON.stringify({
event: 'succeedLogin',
id: data.id
}))
console.log('服务器将消息发给了' + client.id)
}
})
return; // 事件处理完成
}
}
console.log("服务器收到消息:")
console.log(data)
wss.clients.forEach((client) => {
// 是连接状态
if(client.readyState === WebSocket.OPEN) {
// 发给自己
if(ws === client){
switch(data.event){
// 创建房间
case 'setUp':
ws.send(JSON.stringify({
event: 'setUp',
id: id
}))
ws.isAlive = true
ws.id = id + ''
ws.type = 'serve'
console.log('已经创建ID为: '+id+' 链接WS')
id++
return
}
}
// 不发给自己,发给指定id
else if(ws !== client && client.id ===data.id){
switch (data.event){
case 'login':
console.log('开始发送登陆信息')
client.send(JSON.stringify({
event: 'login',
token: data.token
}))
console.log('服务器将消息发给了' + client.id)
return
case 'scanfed': // 用户扫码登录入口
ws.type = 'client' // 设置扫描端为客户端
ws.id = data.id + ''
ws.isAlive = true
console.log('开始发送扫描事件信息')
client.send(JSON.stringify({
event: 'scanfed',
id: data.id
}))
console.log('服务器将消息发给了' + client.id)
return
}
}
}
})
console.log('服务器发送此消息成功!')
});
ws.on('close', function() {
console.log(ws.id + ' exit')
console.log('现在还有一下客户端:')
wss.clients.forEach((client) => {
console.log('id: ' + client.id + 'type: ' + client.type)
})
})
})
const timeIntervl = 3000 // 发送心跳检测的时间
setInterval(() => {
wss.clients.forEach((ws) =>{
if(!ws.isAlive){
console.log(ws.id + '无反应,已结束了与它的链接')
return ws.terminate()
}
ws.isAlive = false // 不收到pong就会断开与客户端的链接
// console.log("向ws: "+ws.id+"发送心跳")
ws.send(JSON.stringify({
event: 'heartbeat',
msg: 'ping'
}))
})
}, timeIntervl);
export default wss
Router.js
import Router from 'koa-router'
const qr = require('qr-image');
class PublicController {
constructor() { }
async getQR(ctx){
var text = ctx.query.text;
var img = qr.image(text, {size: 100})
try {
ctx.type= 'image/png';
ctx.body = img;
} catch (e) {
ctx.type='text/html;charset=utf-8';
ctx.body='<h1>Text Too Large</h1>';
}
}
}
const router = new Router()
router.get('/getQR', PublicController.getQR)
export default router
index.js
import koa from 'koa'
import JWT from 'koa-jwt'
import statics from 'koa-static'
const app = new koa()
// 定义公共路径,不需要jwt鉴权
const jwt = JWT({
secret: config.JWT_SECRET,//相当于解析token需要的密码
}).unless({
path: [
/\/getQR/
]})
/**
* 使用koa-compose 集成中间件
*/
const middleware = compose([
statics(path.join(__dirname, '../public')), // 静态文件开始路径
jwt //
])
app.use(middleware)
app.use(router())
app.listen(3000)
需要登录端
我使用的是iview-admin 2.0 的框架,我就只写我修改的部分,大家可以按照这个思路自己编写一个页面.因为我自己写的页面太丑了,就不展示了展示别人优秀的页面
LoginScanfQRForm.vue
<template>
<div>
<img
v-show="loginScanfQR"
class="qr"
:src="loginScanfQR"
alt="wait comebeing"
/>
<Button @click="changeToPasswordLogin" type="primary" long>密码登陆</Button>
</div>
</template>
<script>
import config from '../../config'
/*
export default {
host: '192.168.1.2',
}
*/
import { getQRImg } from '@/api/login'
/*
const getQRImg = (text) => {
return 'http://192.168.1.2:3000/api/public/getQR?text=' + text
}
*/
export default {
name: 'LoginScanfQRForm',
props: {},
data () {
return {
id: '',
isEnter: false,
loginScanfQR: null,
ws: null,
sendPingTimeInretval: null,
reConnectTimeIntretval: null
}
},
watch: {
},
mounted () {
this.connectServe()
},
methods: {
setQr () {
const text = JSON.stringify({
host: config.host,
id: this.id,
tag: 'QRLogin'
})
this.loginScanfQR = getQRImg(text)
},
sendPing () {
if (this.ws.readyState === 1 && this.ws.isLife === true) { // 1 normal
this.ws.send(JSON.stringify({
event: 'heartbeat',
msg: 'ping'
}))
} else { // 链接不正常
this.reConnect()
}
},
sendPong () {
this.ws.send(JSON.stringify({
event: 'heartbeat',
msg: 'pong'
}))
},
onopen: function () {
clearInterval(this.reConnectTimeIntretval)
console.log('open: ' + this.ws.readyState)
this.$Message.info('已经链接到服务器')
this.ws.send(JSON.stringify({ id: this.id, event: 'setUp' })) // 请求创建组
this.sendPingTimeInretval = setInterval(() => {
this.sendPing()
this.ws.isLife = false
}, 3000)
},
onclose: function (event) {
// console.log('已经关闭')
this.$Message.info('已经关闭')
this.loginScanfQR = null
},
onmessage: function (event) {
var data = event.data
var obj = JSON.parse(data)
switch (obj.event) {
case 'heartbeat':
if (obj.msg === 'pong') {
this.ws.isLife = true
} else if (obj.msg === 'ping') {
this.sendPong()
}
break
case 'login':
this.ws.send(JSON.stringify({ event: 'succeedLogin', id: this.id }))
this.$Message.info('成功获取授权!正在跳转到登陆界面')
clearInterval(this.sendPingTimeInretval) // 不再定时发送ping
this.ws.close()
this.scanfSucceed(obj.token)
break
case 'setUp': // 返回创建组的编号
this.id = obj.id
this.ws.isLife = true
this.setQr()
this.$Message.info('可以开始扫描')
break
case 'scanfed':
this.$Message.info('扫描成功!请在手机端上确定!')
break
}
},
onerror: function (errror) { // 链接错误
this.$Message.errror('链接错误!')
},
reConnect () { // 重新链接
clearInterval(this.sendPingTimeInretval)
this.reConnectTimeIntretval = setInterval(() => {
this.connectServe()
this.$Message.info('正在尝试重新链接!')
}, 3000)
},
connectServe () { // 创建一个ws链接
this.ws = new WebSocket('ws://' + config.host + ':3001')
this.ws.onopen = this.onopen
this.ws.onmessage = this.onmessage
this.ws.onclose = this.onclose
this.ws.onerror = this.onerror
},
changeToPasswordLogin () { // 切换为密码登录
clearInterval(this.sendPingTimeInretval)
clearInterval(this.reConnectTimeIntretval)
this.ws.close()
this.$emit('changeToPasswordLogin')
},
scanfSucceed (token) { // 传递参数给父组件
this.$emit('scanfSucceed', token)
}
}
}
</script>
<style lang="css">
.qr {
padding: auto auto;
margin: auto auto;
}
</style>
使用方法,在需要添加扫码登录的地方导入这个组件,实列
父组件接收子组件的参数
演示代码
<style lang="less">
@import 'login.less';
</style>
<template>
<div class="login">
<div class="login-con">
<Card icon="log-in" title="欢迎登录" :bordered="false">
<div class="form-con" v-if="!qrLogin">
<login-form
@on-success-valid="handleSubmit"
@changeToQrLogin="changeToQrLogin"
></login-form>
<p class="login-tip">输入您的账号信息!</p>
</div>
<div class="form-con" v-else>
<LoginScanfQRForm
@changeToPasswordLogin="changeToPasswordLogin"
@scanfSucceed="scanfSucceed"
></LoginScanfQRForm>
</div>
</Card>
</div>
</div>
</template>
<script>
import LoginForm from '_c/login-form'
import LoginScanfQRForm from '_c/login-QR'
import { mapActions } from 'vuex'
export default {
components: {
LoginForm,
LoginScanfQRForm
},
data () {
return {
qrLogin: false
}
},
methods: {
...mapActions(['handleLogin', 'getUserInfo']),
handleSubmit ({ userLoginInfo }) {
this.handleLogin({ userLoginInfo }).then(res => {
if (res.code === 200) {
this.$router.push({ name: this.$config.homeName })
} else {
alert(res.msg)
}
})
},
changeToQrLogin () { // 切换为二维码登录
this.qrLogin = true
},
changeToPasswordLogin () { // 切换为账号和密码登录
this.qrLogin = false
},
scanfSucceed (token) {
this.$store.commit('setToken', token)
this.getUserInfo().then(res => {
console.log(res)
if (res.code === 200) {
this.$router.push({ name: this.$config.homeName })
} else {
alert(res.msg)
}
})
}
}
}
</script>
<style></style>
效果
现在就差实现一个扫码的移动端
移动端实现
在需要添加此功能的Android项目中导入以下依赖:
代码:
// websocket
compile "org.java-websocket:Java-WebSocket:1.3.7"
// 相机识别二维码
implementation('com.journeyapps:zxing-android-embedded:4.1.0')
页面实现
1,扫码登录页面
(1)界面(很简陋),后期优化一下
解析: 真机可通过开始扫描扫描二维码加入,模拟器可以手动输入房间号
(2),layout代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.ScanfQR">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:layout_editor_absoluteX="263dp"
tools:layout_editor_absoluteY="164dp">
<Button
android:id="@+id/begainScan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="开始扫描" />
<EditText
android:id="@+id/ScanOfQr_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="id"
></EditText>
<Button
android:id="@+id/ScanOfQr_id_submit"
android:text="加入"
android:layout_width="match_parent"
android:layout_height="wrap_content"></Button>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
(3),java代码
public class ScanfQR extends AppCompatActivity {
private Context context = ScanfQR.this;
private Activity activity = ScanfQR.this;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_scanf_q_r);
findViewById(R.id.begainScan).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 创建IntentIntegrator对象
IntentIntegrator intentIntegrator = new IntentIntegrator(ScanfQR.this);
// 开始扫描
intentIntegrator.initiateScan();
}
});
findViewById(R.id.ScanOfQr_id_submit).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
setUpConnection();
}
});
}
// 处理扫码结果
private boolean handleScanResult(int requestCode, int resultCode, Intent data){
IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
if (result != null) { // 获取拍照结果并处理
if (result.getContents() == null) {
Toast.makeText(this, "取消扫描", Toast.LENGTH_LONG).show();
}else {
JsonHelper jsonHelper = new JsonHelper(result.getContents());
if(jsonHelper.getStringValue("tag").equals("QRLogin")){
User user = (User) getApplication();
if(user.isLogin()){
Toast.makeText(context, "正在跳转到登录授权", Toast.LENGTH_SHORT).show();
String host = jsonHelper.getStringValue("host");
int id = jsonHelper.getIntValue("id");
qr_websocket = new QR_Websocket(host,user,id+"");
initScanQR();
}else { // 未登录
//tip
Toast.makeText(context, "此二维码为扫码登录码,需要登录后才能扫码,请登录后重新扫描!", Toast.LENGTH_SHORT).show();
User.alertLogin(this);
}
return true;
}
}
}
return false;
}
// 初始化扫码,并监听扫码链接结果,如果成功链接则跳转到授权页面
private void initScanQR(){
qr_websocket.connect();
qr_websocket.getIsConnect().observe(this, new Observer<Boolean>() {
@Override
public void onChanged(Boolean aBoolean) {
if(aBoolean){
User.getAuth(activity); // 跳转用户授权界面
}
}
});
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
// 获取二维码解析结果并处理
boolean isHandle = handleScanResult(requestCode, resultCode, data);
if(isHandle){
return;
}
// //登录结果
// boolean isLogin = User.handleLoginState(context,requestCode,resultCode);
// if(isLogin){
// setUpConnection();
// return;
// }
// 授权状态
String token = User.handleAuthState(context,requestCode,resultCode);
if(token!=null){
qr_websocket.sendLoginInfo(token);
return;
}
}
QR_Websocket qr_websocket;
// 通过输入框链接ws,因为模拟器无法打开相机,可以通过手动输入房间id加入
public void setUpConnection() {
User user = (User)getApplication(); // 获取全局用户信息
EditText editText = findViewById(R.id.ScanOfQr_id);
String id = editText.getText().toString();
if(id.length()!=0){
qr_websocket = new QR_Websocket( SettingsActivity.getHost(context), user,id);
initScanQR();
}else {
Toast.makeText(context, "no id", Toast.LENGTH_SHORT).show();
}
}
// 重写返回方法,关闭开启的ws
@Override
public void onBackPressed() {
if(qr_websocket.isOpen()){
qr_websocket.closeConnection(1,"返回关闭");
}
super.onBackPressed();
}
}
2,注我将授权跳转和处理授权全写在了一个类中,方便调用
3,授权界面
(1) 布局
(2) Layout代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.login.ScanLoginSubmit">
<LinearLayout
android:id="@+id/linearLayout2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<Button
android:id="@+id/ScanLoginSubmit_Exit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="exit" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/ScanLoginSubmit_avatar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:srcCompat="@tools:sample/avatars" />
<TextView
android:id="@+id/ScanLoginSubmit_username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"></TextView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="100dp"
android:orientation="vertical">
<Button
android:id="@+id/ScanLoginSubmit_submit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="授权"
android:layout_gravity="center"></Button>
<Button
android:id="@+id/ScanLoginSubmit_subject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="拒绝"
android:layout_gravity="center"></Button>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
(3)Java代码
public class ScanLoginSubmit extends AppCompatActivity {
public static final int SUBMIT = 0;
public static final int SUBJECT = 1;
public static final int CANCEL = 2;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_scan_login_submit);
User user = (User) getApplication();
ImageView avatar = findViewById(R.id.ScanLoginSubmit_avatar);
Glide.with(this).load(user.getUserData().avatar).into(avatar);
TextView username = findViewById(R.id.ScanLoginSubmit_username);
username.setText(user.getUserName());
findViewById(R.id.ScanLoginSubmit_Exit).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
setResult(CANCEL);
finish();
}
});
findViewById(R.id.ScanLoginSubmit_submit).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
setResult(SUBMIT);
finish();
}
});
findViewById(R.id.ScanLoginSubmit_subject).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
setResult(SUBJECT);
finish();
}
});
}
@Override
public void onBackPressed() {
setResult(CANCEL);
super.onBackPressed();
}
}
终于写完了,感谢大家看到这里,我后期会专门出这三个模块的demo,有兴趣可以关注一下哦~,今天除夕夜,祝大家新年快乐!