掃碼登陸其實就是一個登陸請求,只不過信息存儲在用戶手機上,還須要經過二維碼驗證是否匹配的方式就能夠登陸,免去了用戶屢次輸入密碼的場景,如今愈來愈多登陸方式,其中掃碼登陸算是比較人性化的了javascript
咱們把一個全局惟一id保存在二維碼中,使用手機掃碼能夠獲取到二維碼中的信息,此時就把該二維碼和你的手機用戶帳號創建一種綁定的關係,這個二維碼就只歸你全部了,當你登陸完後這個二維碼就廢棄了,二維碼起的做用就是一種認證的機制css
具體流程以下圖:html
Step 一、用戶 A 訪問網頁客戶端,服務器爲這個會話生成一個全局惟一的 ID,此時系統並不知道訪問者是誰。前端
Step 二、用戶A打開本身的手機App並掃描這個二維碼,並提示用戶是否確認登陸。java
Step 三、手機上的是登陸狀態,用戶點擊確認登陸後,手機上的客戶端將帳號和這個掃描獲得的 ID 一塊兒提交到服務器jquery
Step 四、服務器將這個 ID 和用戶 A 的帳號綁定在一塊兒,並通知網頁版,這個 ID 對應的微信號爲用戶 A,網頁版加載用戶 A 的信息,至此,掃碼登陸所有流程完成git
咱們選取使用本身在服務器端根據建立的全局惟一id生成一個二維碼,使用google
的zxing
二維碼生成類庫github
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.2.1</version>
</dependency>
複製代碼
根據content內容和指定高度和寬度生成二維碼的base64格式圖片,能夠直接在前端顯示web
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來存儲每一張二維碼的狀態ajax
狀態:
因爲一張二維碼只能被掃描一次,因此咱們每一次掃描一張二維碼後,把狀態設置爲
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, uuid, QrCodeStatus.class);
return Result.success(s.getStatus());
}
複製代碼
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,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,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掃描成功後,二維碼狀態變爲SCANNED
,須要發送一個請求給app前端請求用戶確認,用戶點擊確認後請求這個接口,完成登陸
@GetMapping("/verify")
@ResponseBody
public Result verifyQrCode(@RequestParam("uuid")String uuid){
String status = redisService.get(QrCodeKey.UUID,uuid,String.class);
if(status.isEmpty()) return Result.error(ErrorCodeEnum.UUID_EXPIRED);
redisService.set(QrCodeKey.UUID,uuid,QrCodeStatus.VERIFIED);
return Result.success("確認成功");
}
複製代碼
<!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', dataType: "json", type: 'GET', data:{"img":img}, 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>
複製代碼
成功後跳轉到成功頁面
除了輪詢還有一種相對來講更好的實現方式就是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>
複製代碼
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");
}
}
複製代碼
@Autowired
private SimpMessagingTemplate simpMessagingTemplate;
複製代碼
咱們只須要稍微改一下代碼,在第一次掃描後使用WebSocket發送一個信息請求確認給前端WebSocket
@GetMapping("/doScan")
@ResponseBody
public Result doAppScanQrCode(@RequestParam("username")String username, @RequestParam("password")String password, @RequestParam("uuid")String uuid){
QrCodeStatus status = redisService.get(QrCodeKey.UUID,uuid,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,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,uuid,String.class);
if(status.isEmpty()) return Result.error(ErrorCodeEnum.UUID_EXPIRED);
redisService.set(QrCodeKey.UUID,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"
}
});
});
}
複製代碼
demo地址:github.com/ding-zou/qr…