SpringBoot+輪詢or長連線 實現掃碼登入功能Demo—Postman模擬掃碼請求
掃碼登入功能Demo—Postman模擬掃碼請求
- 掃碼登入功能—輪詢or長連線WebSocket—Zxing生成二維碼
掃碼登入其實就是一個登入請求,只不過資訊儲存在使用者手機上,還需要通過二維碼驗證是否匹配的方式就可以登入,免去了使用者多次輸入密碼的場景,現在越來越多登入方式,其中掃碼登入算是比較人性化的了
我們把一個全域性唯一id儲存在二維碼中,使用手機掃碼可以獲取到二維碼中的資訊,此時就把該二維碼和你的手機使用者賬號建立一種繫結的關係,這個二維碼就只歸你所有了,當你登入完後這個二維碼就廢棄了,二維碼起的作用就是一種認證的機制
流程
具體流程如下圖:
Step 1、使用者 A 訪問網頁客戶端,伺服器為這個會話生成一個全域性唯一的 ID,此時系統並不知道訪問者是誰。
Step 2、使用者A開啟自己的手機App並掃描這個二維碼,並提示使用者是否確認登入。
Step 3、手機上的是登入狀態,使用者點選確認登入後,手機上的客戶端將賬號和這個掃描得到的 ID 一起提交到伺服器
Step 4、伺服器將這個 ID 和使用者 A 的賬號繫結在一起,並通知網頁版,這個 ID 對應的微訊號為使用者 A,網頁版載入使用者 A 的資訊,至此,掃碼登入全部流程完成
建立二維碼
我們選取使用自己在伺服器端根據建立的全域性唯一id生成一個二維碼,使用google
的zxing
二維碼生成類庫
- 依賴
<dependency>
<groupId >com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.2.1</version>
</dependency>
複製程式碼
- 生成二維碼
根據content內容和指定高度和寬度生成二維碼的base64格式圖片,可以直接在前端顯示
public String createQrCode(String content,int width,int height) throws IOException {
String resultImage = "";
if (!StringUtils.isEmpty(content)) {
ServletOutputStream stream = null;
ByteArrayOutputStream os = new ByteArrayOutputStream();
@SuppressWarnings("rawtypes")
HashMap<EncodeHintType,Comparable> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET,"utf-8"); // 指定字元編碼為“utf-8”
hints.put(EncodeHintType.ERROR_CORRECTION,ErrorCorrectionLevel.M); // 指定二維碼的糾錯等級為中級
hints.put(EncodeHintType.MARGIN,2); // 設定圖片的邊距
try {
QRCodeWriter writer = new QRCodeWriter();
BitMatrix bitMatrix = writer.encode(content,BarcodeFormat.QR_CODE,width,height,hints);
BufferedImage bufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix);
ImageIO.write(bufferedImage,"png",os);
/**
* 原生轉碼前面沒有 data:image/png;base64 這些欄位,返回給前端是無法被解析,可以讓前端加,也可以在下面加上
*/
resultImage = new String("data:image/png;base64," + Base64.encode(os.toByteArray()));
return resultImage;
} catch (Exception e) {
e.printStackTrace();
} finally {
if (stream != null) {
stream.flush();
stream.close();
}
}
}
return null;
}
複製程式碼
二維碼狀態管理
我們使用redis來儲存每一張二維碼的狀態
狀態:
- NOT_SCAN 未被掃描
- SCANNED 被掃描
- VERIFIED 確認完後
- EXPIRED 過期
- FINISH 完成
由於一張二維碼只能被掃描一次,所以我們每一次掃描一張二維碼後,把狀態設定為
SCANNED
,SCANNED
狀態的二維碼無法再次被掃描,丟擲已被掃描的資訊
狀態轉移:
NOT_SCANNED->SCANNED->VERIFIED->FINISH
其中EXPIRED狀態可以插在其中任意一個位置,過期了的二維碼也自動過期
生成二維碼介面
- 建立二維碼
使用UUID工具類生成全域性唯一id,也可以使用snowflake生成自增的全域性唯一id,然後儲存到redis中,key為uuid,val為當前二維碼狀態,我們這裡維護了一個map儲存所有uuid對應的二維碼base格式,用於建立對應關係,前端傳遞二維碼base64過來我們來判斷這張二維碼對應的uuid是多少
很多人問為什麼不讓前端傳遞掃描過後的uuid呢?第一,我們只能使用postman模擬請求,我們無法根據手機app掃碼獲取二維碼資訊,所以暫時採取傳輸圖片,實際中肯定採用uuid去傳輸,因為base64本來就很大,儘量傳輸資料量小的資料
@GetMapping("/createQr")
@ResponseBody
public Result<String> createQrCode() throws IOException {
String uuid = UUIDUtil.uuid();
log.info(uuid);
String qrCode = qrCodeService.createQrCode(uuid,200,200);
qrCodeMap.put(qrCode,uuid);
redisService.set(QrCodeKey.UUID,uuid,QrCodeStatus.NOT_SCAN);
return Result.success(qrCode);
}
複製程式碼
前端輪詢法判斷二維碼是否被掃描
目前阿里雲登入控制檯就是使用輪詢的方法,具體為什麼不使用長連線我也不清楚,但是說明這種方法也是比較常見的
後端只需要處理app登入請求和確認請求以及網頁端響應的請求就好了
二維碼是否被掃描介面—前端只需要輪詢該介面
獲取到redis儲存對應uuid的狀態,返回給前端,前端輪詢判斷做處理
@GetMapping("/query")
@ResponseBody
public Result<String> queryIsScannedOrVerified(@RequestParam("img")String img){
String uuid = qrCodeMap.get(img);
QrCodeStatus s = redisService.get(QrCodeKey.UUID,QrCodeStatus.class);
return Result.success(s.getStatus());
}
複製程式碼
app掃描介面
app掃描二維碼後,拿到對應的二維碼資訊傳送一個掃描請求給後端,攜帶app使用者引數,這裡demo演示就模擬一個絕對的使用者資訊
之後就是判斷redis中uuid的狀態度
- 如果為
NOT_SCAN
,就修改為SCANNED
- 如果為
SCANNED
,就返回重複掃描的錯誤 - 如果為
VERIFIED
,就完成本次二維碼登入邏輯,使用者登入成功
@GetMapping("/doScan")
@ResponseBody
public Result doAppScanQrCode(@RequestParam("username")String username,@RequestParam("password")String password,@RequestParam("uuid")String uuid){
QrCodeStatus status = redisService.get(QrCodeKey.UUID,QrCodeStatus.class);
log.info(status.getStatus());
if(status.getStatus().isEmpty()) return Result.error(ErrorCodeEnum.UUID_EXPIRED);
switch (status){
case NOT_SCAN:
//等待確認 todo
if(username.equals("dzou")&&password.equals("1234")){
redisService.set(QrCodeKey.UUID,QrCodeStatus.SCANNED);
return Result.success("請手機確認");
}else{
return Result.error(ErrorCodeEnum.LOGIN_FAIL);
}
case SCANNED:
return Result.error(ErrorCodeEnum.QRCODE_SCANNED);
case VERIFIED:
return Result.success("你已經確認過了");
}
return Result.error(ErrorCodeEnum.SEVER_ERROR);
}
複製程式碼
app確認登入介面
app掃描成功後,二維碼狀態變為SCANNED
,需要傳送一個請求給app前端請求使用者確認,使用者點選確認後請求這個介面,完成登入
@GetMapping("/verify")
@ResponseBody
public Result verifyQrCode(@RequestParam("uuid")String uuid){
String status = redisService.get(QrCodeKey.UUID,String.class);
if(status.isEmpty()) return Result.error(ErrorCodeEnum.UUID_EXPIRED);
redisService.set(QrCodeKey.UUID,QrCodeStatus.VERIFIED);
return Result.success("確認成功");
}
複製程式碼
前端—JQuery
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>掃描二維碼</title>
<!-- jquery -->
<script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
<!-- bootstrap -->
<link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}"/>
<script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
</head>
<body>
<h1>二維碼</h1>
<div>
<table>
<tr>
<td><img id="qrCode" width="200" height="200"/></td>
</tr>
</table>
</div>
</body>
<script>
var img = "";
$.ajax({
url: "/api/createQr",type:"GET",success:function (data) {
$("#qrCode").attr("src",data.data);
img = data.data;
callbackScan($("#qrCode").attr("src"))
}
});
//使用setTimeOut來迴圈請求判斷是否被掃描,被掃描以後呼叫下面一個函式迴圈判斷是否被確認
function callbackScan(img) {
var tID = setTimeout(function() {
$.ajax({
url : '/api/query',dataType: "json",type: 'GET',data:{"img":img},success : function(res) {
//process data here
console.log("img:"+img);
console.log(res.data);
if(res.data=="scanned") {
clearTimeout(tID);
console.log("請求確認")
callbackVerify(img)
}else {
callbackScan(img)
}
}
}) },1500);
}
//迴圈判斷是否被確認
function callbackVerify(img) {
var tID = setTimeout(function() {
$.ajax({
url : '/api/query',success : function(res) {
//process data here
console.log(res.data);
if(res.data=="verified") {
clearTimeout(tID);
console.log("確認成功")
window.location.href = "success";
}else {
callbackVerify(img)
}
}
}) },1500);
}
</script>
</html>
複製程式碼
成功後跳轉到成功頁面
測試
- 開啟主頁建立二維碼
- 拿到伺服器端建立的uuid請求掃描介面
- 拿uuid請求確認介面
- 確認完成,跳轉到登入介面
長連線WebSocket來傳輸二維碼被掃描的資訊
除了輪詢還有一種相對來說更好的實現方式就是WebSocket長連線,但是有些瀏覽器不支援WebSocket,考慮到這點我們決定使用
SockJs
,他是一種優先Websocket的連線方式,不支援的話它會去使用其他類似輪詢的方式
我們伺服器端需要編寫對應的WebSocket處理邏輯,我們在載入頁面時建立長連線,掃描時請求介面,把狀態傳送給前端WebSocket,如果為被掃描,傳送請求確認的資訊,請求確認介面完成確認後傳送狀態給前端WebSocket,跳轉到success頁面
我們使用Springboot提供的WebSocket支援類庫編寫,如果有需要使用netty編寫的同學,可以參考我的另外一篇netty的文章
maven依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>2.0.4.RELEASE</version>
</dependency>
複製程式碼
WebSocket配置類
- 其中第一個方法
registerStompEndpoints
相當於指定代理伺服器的WebSocket路由 - 第二個方法就是客戶端訂閱路由,客戶端可以接收到這個路由傳送的資訊
@Configuration
@EnableWebSocketMessageBroker
public class IWebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
//註冊一個Stomp 協議的endpoint,並指定 SockJS協議
registry.addEndpoint("/websocket").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
//registry.setApplicationDestinationPrefixes("/app");
}
}
複製程式碼
注入WebSocket傳送訊息模板
@Autowired
private SimpMessagingTemplate simpMessagingTemplate;
複製程式碼
掃描二維碼介面
我們只需要稍微改一下程式碼,在第一次掃描後使用WebSocket傳送一個資訊請求確認給前端WebSocket
@GetMapping("/doScan")
@ResponseBody
public Result doAppScanQrCode(@RequestParam("username")String username,QrCodeStatus.class);
log.info(
status.getStatus());
if(status.getStatus().isEmpty()) return Result.error(ErrorCodeEnum.UUID_EXPIRED);
switch (status){
case NOT_SCAN:
if(username.equals("dzou")&&password.equals("1234")){
redisService.set(QrCodeKey.UUID,QrCodeStatus.SCANNED);
simpMessagingTemplate.convertAndSend("/topic/ws","請確認");
return Result.success("請手機確認");
}else{
return Result.error(ErrorCodeEnum.LOGIN_FAIL);
}
case SCANNED:
return Result.error(ErrorCodeEnum.QRCODE_SCANNED);
case VERIFIED:
return Result.success("你已經確認過了");
}
return Result.error(ErrorCodeEnum.SEVER_ERROR);
}
複製程式碼
確認登入介面
我們需要稍改確認的程式碼,因為確認成功我們需要向客戶端訂閱的指定路由傳送一條訊息
呼叫convertAndSend
傳送指定訊息到指定路由下
@GetMapping("/verify")
@ResponseBody
public Result verifyQrCode(@RequestParam("uuid")String uuid){
String status = redisService.get(QrCodeKey.UUID,QrCodeStatus.VERIFIED);
simpMessagingTemplate.convertAndSend("/topic/ws","已經確認");
return Result.success("確認成功");
}
複製程式碼
前端
前端就不需要輪詢的那兩個方法了,只需要連線SockJs就好了,根據WebSocket傳送的資訊進行處理,我們這裡需要客戶端連線上後進行訂閱,指定接收伺服器哪個路由傳送的訊息
function connect() {
var socket = new SockJS('/websocket');
stompClient = Stomp.over(socket);
stompClient.connect({},function (frame) {
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/ws',function (response) {//訂閱路由訊息
console.log(response);
if(response.body=="請確認"){
layer.msg("請在你的app上確認登入")
}else if(response.body=="已經確認"){
window.location.href = "success"
}
});
});
}
複製程式碼
測試
- 開啟主頁建立二維碼,連線WebSocket
- 拿到伺服器端建立的uuid請求掃描介面
- 控制檯列印請求確認資訊
- 拿uuid請求確認介面
- 確認完成,跳轉到登入介面,傳送已經確認
demo地址:github.com/ding-zou/qr…