网上搜索的扫码登录大部分采用的都是PC端轮训获取二维码状态的方式实现扫码登录,需要写定时任务,服务端还需要保存二维码主键的扫码状态,感觉比较麻烦,感觉用服务器通知的方法比较简单,所以考虑只用这种方式,服务端通知的方式可以使用websocket或者SSE,websocket是双向通讯,搭建起来需要引入插件,二维码刷新还是需要使用定时任务,但是使用SSE可以根据SSE的超时来触发二维码超时刷新的功能,而且不需要额外引入其他插件.完美解决,废话不说,上代码.
服务端
controler类
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@Slf4j
@RestController
@CrossOrigin
@RequestMapping("/sse")
public class SSEControler {
@Autowired private UserService userService;
//获取uuid
@GetMapping("/createQrCode")
public SseEmitter createQrCode (String uuid){
SseEmitter emitter = SseServer.createConnect(uuid);
SseServer.sendMessage(uuid,"ing");
return emitter;
}
//第3步:扫码
@GetMapping("/scanQrCode")
public Result<Object> scanQrCode (String uuid,String userId){
try{
User u = userService.getById(userId);
SseServer.sendMessage(uuid,"ing",u.getUsername());
}catch (Exception e){
throw new Exception("二维码已过期");
}
return ResultUtil.data("成功");
}
@GetMapping("/scanQrCodeCencel")
public Result<Object> scanQrCodeCencel (String uuid){
try{
SseServer.sendMessage(uuid,"cencel","取消授权");
}catch (Exception e){
throw new Exception("二维码已过期");
}
return ResultUtil.data("成功");
}
@GetMapping("/qrCodeConfirm")
public void payback (String uuid,String userId){
try{
User u = userService.getById(userId);
if (u == null) {
throw new Exception("用户不存在");
}
//拿到用户后自己去鉴权获取token并返回
String accessToken = onAuthentication( u);
SseServer.sendMessage(uuid,"finish",accessToken);
}catch (Exception e){
throw new Exception("二维码已过期");
}
}
}
SSE工具类
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.MapUtils;
import org.springframework.http.MediaType;
import org.springframework.util.CollectionUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* SseServer业务封装类来操作SEE
*/
@Slf4j
public class SseServer {
/**
* 当前连接总数
*/
private static AtomicInteger currentConnectTotal = new AtomicInteger(0);
/**
* messageId的 SseEmitter对象映射集
*/
private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();
/**
* 创建sse连接
*
* @param messageId - 消息id(唯一)
* @return
*/
public static SseEmitter createConnect(String messageId) {
/**
* 设置连接超时时间。0表示不过期,默认是30秒,超过时间未完成会抛出异常
*/
SseEmitter sseEmitter = new SseEmitter(60*1000L);
// 注册回调
sseEmitter.onCompletion(completionCallBack(messageId));
sseEmitter.onTimeout(timeOutCallBack(messageId));
sseEmitter.onError(errorCallBack(messageId));
sseEmitterMap.put(messageId, sseEmitter);
//记录一下连接总数。数量+1
int count = currentConnectTotal.incrementAndGet();
log.info("创建sse连接成功 ==> 当前连接总数={}, messageId={}", count, messageId);
return sseEmitter;
}
/**
* 给指定 messageId发消息
*
* @param messageId - 消息id(唯一)
* @param message - 消息文本
*/
public static void sendMessage(String messageId, String message) {
if (sseEmitterMap.containsKey(messageId)) {
try {
sseEmitterMap.get(messageId).send(message);
} catch (IOException e) {
log.error("发送消息异常 ==> messageId={}, 异常信息:", messageId, e.getMessage());
e.printStackTrace();
}
} else {
throw new RuntimeException("连接不存在或者超时, messageId=" + messageId);
}
}
public static void sendMessage(String messageId, String name,String message) {
if (sseEmitterMap.containsKey(messageId)) {
try {
SseEmitter sseEmitter = sseEmitterMap.get(messageId);
sseEmitter.send(SseEmitter.event().name(name).id(messageId).data(message));
} catch (IOException e) {
log.error("发送消息异常 ==> messageId={}, 异常信息:", messageId, e.getMessage());
e.printStackTrace();
}
} else {
throw new RuntimeException("连接不存在或者超时, messageId=" + messageId);
}
}
/**
* 给所有 messageId广播发送消息
*
* @param message
*/
public static void batchAllSendMessage(String message) {
sseEmitterMap.forEach((messageId, sseEmitter) -> {
try {
sseEmitter.send(message, MediaType.APPLICATION_JSON);
} catch (IOException e) {
log.error("广播发送消息异常 ==> messageId={}, 异常信息:", messageId, e.getMessage());
removeMessageId(messageId);
}
});
}
/**
* 给指定 messageId集合群发消息
*
* @param messageIds
* @param message
*/
public static void batchSendMessage(List<String> messageIds, String message) {
if (CollectionUtils.isEmpty(messageIds)) {
return;
}
// 去重
messageIds = messageIds.stream().distinct().collect(Collectors.toList());
messageIds.forEach(userId -> sendMessage(userId, message));
}
/**
* 给指定组群发消息(即组播,我们让 messageId满足我们的组命名确定即可)
*
* @param groupId
* @param message
*/
public static void groupSendMessage(String groupId, String message) {
if (MapUtils.isEmpty(sseEmitterMap)) {
return;
}
sseEmitterMap.forEach((messageId, sseEmitter) -> {
try {
// 这里 groupId作为前缀
if (messageId.startsWith(groupId)) {
sseEmitter.send(message, MediaType.APPLICATION_JSON);
}
} catch (IOException e) {
log.error("组播发送消息异常 ==> groupId={}, 异常信息:", groupId, e.getMessage());
removeMessageId(messageId);
}
});
}
/**
* 移除 MessageId
*
* @param messageId
*/
public static void removeMessageId(String messageId) {
sseEmitterMap.remove(messageId);
//数量-1
currentConnectTotal.getAndDecrement();
log.info("remove messageId={}", messageId);
}
/**
* 获取所有的 MessageId集合
*
* @return
*/
public static List<String> getMessageIds() {
return new ArrayList<>(sseEmitterMap.keySet());
}
/**
* 获取当前连接总数
*
* @return
*/
public static int getConnectTotal() {
return currentConnectTotal.intValue();
}
/**
* 断开SSE连接时的回调
*
* @param messageId
* @return
*/
private static Runnable completionCallBack(String messageId) {
return () -> {
SseServer.sendMessage(messageId,"timeOut","二维码失效");
log.info("结束连接 ==> messageId={}", messageId);
removeMessageId(messageId);
};
}
/**
* 连接超时时回调触发
*
* @param messageId
* @return
*/
private static Runnable timeOutCallBack(String messageId) {
return () -> {
log.info("连接超时 ==> messageId={}", messageId);
removeMessageId(messageId);
};
}
/**
* 连接报错时回调触发。
*
* @param messageId
* @return
*/
private static Consumer<Throwable> errorCallBack(String messageId) {
return throwable -> {
log.error("连接异常 ==> messageId={}", messageId);
removeMessageId(messageId);
};
}
}
此处工具类是直接使用别人写好的,连接找不到了,等回头找到了再补
PC端
样式自己调,这里只实现功能,图片什么的可以自己找合适的
- 安装二维码生成插件
npm install qrcode
- 代码
<div>
<div v-if="!socialLogining">
<div class="loginContent">
<div class="smallTit">登录</div>
<div class="scanQw" style="width: 30px;height: 30px;position: absolute;right: 8px;top:8px">
<img v-if="!showQrCodeFlag" src="/scanQr.png" class="scanQrImg" @click="showQrCode" >
<img v-if="showQrCodeFlag" src="/accLogin.png" class="scanQrImg" @click="showPwd" >
</div>
<div v-if="showQrCodeFlag" class="loginMain">
<img v-show="!scanQrCodeIng" :src="qrCodeUrl" style="width: 300px;height: 300px">
<div v-show="scanQrCodeIng" style="width: 495px;height: 300px;color:#ffffff">
<div style="font-size:30px;color: #cc0000;text-align: center">{{scanQrCodeName}}</div>
<span style="font-size:30px;margin-top: 20px">已扫码</span>
<div style="font-size:30px;margin-top: 20px">请在手机上确认登录</div>
</div>
</div>
<div v-else>
<div class="loginMain">
<Form
ref="usernameLoginForm"
:model="form"
:rules="rules"
class="form"
>
<FormItem class="username" prop="username">
<i class="userIcon"></i>
<Input
v-model="form.username"
size="large"
placeholder="请输入用户名"
autocomplete="off"
/>
</FormItem>
<FormItem class="pwd" prop="password">
<i class="pwdIcon"></i>
<Input
type="password"
v-model="form.password"
size="large"
placeholder="请输入密码"
autocomplete="off"
/>
</FormItem>
</Form>
</div>
<Button
class="login-btn"
type="primary"
size="large"
:loading="loading"
@click="submitLogin"
long
>
<span >登录</span>
</Button>
</div>
</div>
</div>
<div v-if="socialLogining">
<RectLoading />
</div>
</div>
<script>
import QRCode from 'qrcode'
export default {
data() {
return {
scanQrCodeUuid:"",
scanQrCodeIng:false,
showQrCodeFlag:false,
scanQrCodeName:"",
source:null,
qrCodeUrl:"",
};
},
methods: {
createQrCode(data){
var qrUrl = ""
QRCode.toDataURL(data, function (err, url) {
qrUrl = url
})
this.qrCodeUrl = qrUrl;
},
showPwd(){
if(this.showQrCodeFlag){
this.showQrCodeFlag = !this.showQrCodeFlag;
return;
}
},
async showQrCode(){
var that = this;
this.scanQrCodeIng = false;
var url = "http://"+window.location.host
var uuid = this.uuid();
this.scanQrCodeUuid = uuid
this.source = new EventSource(
url+'/xboot/sse/createQrCode?uuid='+uuid);
await this.sseWatch();
this.createQrCode(uuid)
this.showQrCodeFlag = true;
},
sseWatch(){
var that = this;
this.source.onopen = function(e) {
};
//自定义finish事件,主动关闭EventSource
this.source.addEventListener('ing', function(e) {
that.scanQrCodeIng = true;
that.scanQrCodeName = e.data
}, false);
this.source.addEventListener('finish', function(e) {
//e中携带后端登录后的token
that.$Message.success("登录中");
//登录方法,调用自己的登录方法,下面的方法是我系统获取到token的方法
that.afterLogin({result:e.data})
}, false);
this.source.addEventListener('cencel', function(e) {
that.$Message.success("取消授权");
that.scanQrCodeIng = false;
}, false);
this.source.addEventListener('timeOut', function(e) {
}, false);
//监听服务器端发来的事件:error
this.source.onerror = function(e) {
that.source.close();
that.source=null;
if(that.showQrCodeFlag){
that.showQrCode()
}
};
},
uuid() {
var s = [];
var hexDigits = "0123456789abcdef";
for (var i = 0; i < 36; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
}
s[14] = "4"; // bits 12-15 of the time_hi_and_version field to 0010
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1); // bits 6-7 of the clock_seq_hi_and_reserved to 01
s[8] = s[13] = s[18] = s[23] = "-";
var uuid = s.join("");
return uuid;
},
}};
</script>
<style lang="less">
.scanQw {
font-size: 16px;
font-weight: bold;
color: #333;
cursor: pointer;
}
.scanQrImg {
display: none;
width: 40px;
height: 40px;
}
.scanQw:hover > .scanQrImg {
display: block;
width: 30px;
height: 30px;
}
</style>
移动端
移动端使用uni-app开发,由于H5不支持扫码,所以需要使用uni的条件编译,里面提示请求后台的方法请改为使用自己的
注意:H5端使用摄像头必须使用https,本地调试可以使用花生壳做穿透(自带https)
页面
<template>
<view>
<!-- #ifdef H5 -->
<view class="slot-wrap" style="margin-right: 25px;" @click="$refs.sc.init()">
<u-icon name="scan" color="white" size="50"></u-icon>
</view>
<!-- #endif -->
<!-- #ifdef APP-PLUS || MP-WEIXIN-->
<view class="slot-wrap" style="margin-right: 25px;" @click="scanInfo">
<u-icon name="scan" color="white" size="50"></u-icon>
</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<scanCode ref="sc" @on-success="scanResult"></scanCode>
<!-- #endif -->
<u-modal v-model="showPcLogin" content="确认登录PC端" :show-confirm-button="true" :show-cancel-button = "true" @cancel="pcCancel" @confirm="pcLogin"></u-modal>
</view>
</template>
<script>
// #ifdef H5
import scanCode from '@/components/scanCode/scanCode.vue';//直接调用扫码方法调用
// #endif
export default {
data() {
return {
pcUUID:"",
showPcLogin:false,
}
},
// #ifdef H5
components: {
scanCode
},
// #endif
methods: {
pcCancel(){
var that = this;
this.$u.get('/sse/scanQrCodeCencel', {uuid:this.pcUUID}).then(res => {
if(res.success){
that.showPcLogin = false;
that.$refs.uToast.show({
title: '取消登录',
type: 'success',
})
}
})
.catch(err => {});
},
pcLogin(){
var that = this;
this.$u.get('/sse/qrCodeConfirm', {uuid:this.pcUUID,userId:"自己获取当前登录用户的userId"}).then(res => {
if(res.success){
that.showPcLogin = false;
that.$refs.uToast.show({
title: '登录成功',
type: 'success',
})
}
})
.catch(err => {});
},
scanInfo() {
var _this = this
uni.scanCode({
success: function(res) {
_this.scanQrCodeIng(res.result)
}
})
},
scanResult(v){
var _this = this
_this.scanQrCodeIng(v)
},
scanQrCodeIng(data){
var that = this;
this.pcUUID = data
this.$u.get('/sse/scanQrCode', {uuid:data,userId:"自己获取当前登录用户的userId"}).then(res => {
if(res.success){
that.showPcLogin = true;
}
})
.catch(err => {});
},
}
}
}
</script>
扫码组件
首先安装html5扫码,如果没有H5扫码可以直接删除条件编译
npm install vue-qrcode-reader
<template>
<view>
<u-modal class="qrcode" v-model="show" width="100%" :show-cancel-button="true" :show-title="false" :show-confirm-button="false" cancel-color="#387BF1">
<view class="slot-content">
<qrcode-stream v-if="show" @decode="onDecode" @init="onInit" />
</view>
</u-modal>
</view>
</template>
<script>
import { QrcodeStream } from 'vue-qrcode-reader';
export default {
name:"scanCode",
components: {
QrcodeStream,
},
data() {
return {
show:false,
qrcode: false,
torchActive: false,
camera: 'off',
result:0,
};
},
methods: {
init(){
this.show = true
},
onDecode(data) {
this.$emit("on-success",data);
this.show = false;
},
async onInit(promise) {
console.log(promise);
try {
await promise;
} catch (error) {
if (error.name === 'NotAllowedError') {
this.error = '请获取摄像头权限';
} else if (error.name === 'NotFoundError') {
this.error = '无摄像头';
} else if (error.name === 'NotSupportedError') {
this.error = '不支持摄像头';
} else if (error.name === 'NotReadableError') {
this.error = '摄像头在使用';
} else if (error.name === 'OverconstrainedError') {
this.error = '摄像头不是套装';
} else if (error.name === 'StreamApiNotSupportedError') {
this.error = 'Api不支持';
}
}
}
}
};
</script>
<style lang="scss">
</style>
大功告成