徒手擼一個掃碼登陸示例工程

徒手擼一個掃碼登陸示例工程javascript

不知道是否是微信的緣由,如今出現掃碼登陸的場景愈來愈多了,做爲一個有追求、有理想新四好碼農,固然得緊跟時代的潮流,得徒手擼一個以儆效尤html

本篇示例工程,主要用到如下技術棧java

  • qrcode-plugin:開源二維碼生成工具包,項目連接: https://github.com/liuyueyi/quick-media
  • SpringBoot:項目基本環境
  • thymeleaf:頁面渲染引擎
  • SSE/異步請求:服務端推送事件
  • js: 原生 js 的基本操做

<!-- more -->git

I. 原理解析

按照以前的計劃,應該優先寫文件下載相關的博文,然而看到了一篇說掃碼登陸原理的博文,發現正好能夠和前面的異步請求/SSE 結合起來,搞一個應用實戰,因此就有了本篇博文github

關於掃碼登陸的原理,請查看: 聊一聊二維碼掃描登陸原理web

1. 場景描述

爲了照顧可能對掃碼登陸不太瞭解的同窗,這裏簡單的介紹一下它究竟是個啥spring

通常來講,掃碼登陸,涉及兩端,三個步驟後端

  • pc 端,登陸某個網站,這個網站的登陸方式和傳統的用戶名/密碼(手機號/驗證碼)不同,顯示的是一個二維碼
  • app 端,用這個網站的 app,首先確保你是登陸的狀態,而後掃描二維碼,彈出一個登陸受權的頁面,點擊受權
  • pc 端登陸成功,自動跳轉到首頁

2. 原理與流程簡述

整個系統的設計中,最核心的一點就是手機端掃碼以後,pc 登陸成功,這個是什麼原理呢?跨域

  • 咱們假定 app 與後端經過 token 進行身份標識
  • app 掃碼受權,並傳遞 token 給後端,後端根據 token 能夠肯定是誰在 pc 端發起登陸請求
  • 後端將登陸成功狀態寫回給 pc 請求者並跳轉首頁(這裏至關於通常的用戶登陸成功以後的流程,能夠選擇 session、cookie 或者 jwt)

藉助上面的原理,進行逐步的要點分析瀏覽器

  • pc 登陸,生成二維碼
    • 二維碼要求惟一,並綁定請求端身份(不然假定兩我的的二維碼一致,一我的掃碼登陸了,另一個豈不是也登陸了?)
    • 客戶端與服務端保持鏈接,以便收到後續的登陸成功並調首頁的事件(能夠選擇方案比較多,如輪詢,長鏈接推送)
  • app 掃碼,受權登陸
    • 掃碼以後,跳轉受權頁面(因此二維碼對應的應該是一個 url)
    • 受權(身份肯定,將身份信息與 pc 請求端綁定,並跳轉首頁)

最終咱們選定的業務流程關係以下圖:

流程

II. 實現

接下來進入項目開發階段,針對上面的流程圖進行逐一的實現

1. 項目環境

首先常見一個 SpringBoot 工程項目,選擇版本2.2.1.RELEASE

pom 依賴以下

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.1.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <java.version>1.8</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>com.github.hui.media</groupId>
        <artifactId>qrcode-plugin</artifactId>
        <version>2.2</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
</dependencies>

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </pluginManagement>
</build>
<repositories>
    <repository>
        <id>spring-releases</id>
        <name>Spring Releases</name>
        <url>https://repo.spring.io/libs-release-local</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
    <repository>
        <id>yihui-maven-repo</id>
        <url>https://raw.githubusercontent.com/liuyueyi/maven-repository/master/repository</url>
    </repository>
</repositories>

關鍵依賴說明

  • qrcode-plugin: 不是我吹,這多是 java 端最好用、最靈活、還支持生成各類酷炫二維碼的工具包,目前最新版本2.2,在引入依賴的時候,請指定倉庫地址https://raw.githubusercontent.com/liuyueyi/maven-repository/master/repository
  • spring-boot-starter-thymeleaf: 咱們選擇的模板渲染引擎,這裏並無採用先後端分離,一個項目包含全部的功能點

配置文件application.yml

server:
  port: 8080

spring:
  thymeleaf:
    mode: HTML
    encoding: UTF-8
    servlet:
      content-type: text/html
    cache: false

獲取本機 ip

提供一個獲取本機 ip 的工具類,避免硬編碼 url,致使不通用

import java.net.*;
import java.util.Enumeration;

public class IpUtils {
    public static final String DEFAULT_IP = "127.0.0.1";

    /**
     * 直接根據第一個網卡地址做爲其內網ipv4地址,避免返回 127.0.0.1
     *
     * @return
     */
    public static String getLocalIpByNetcard() {
        try {
            for (Enumeration<NetworkInterface> e = NetworkInterface.getNetworkInterfaces(); e.hasMoreElements(); ) {
                NetworkInterface item = e.nextElement();
                for (InterfaceAddress address : item.getInterfaceAddresses()) {
                    if (item.isLoopback() || !item.isUp()) {
                        continue;
                    }
                    if (address.getAddress() instanceof Inet4Address) {
                        Inet4Address inet4Address = (Inet4Address) address.getAddress();
                        return inet4Address.getHostAddress();
                    }
                }
            }
            return InetAddress.getLocalHost().getHostAddress();
        } catch (SocketException | UnknownHostException e) {
            return DEFAULT_IP;
        }
    }

    private static volatile String ip;

    public static String getLocalIP() {
        if (ip == null) {
            synchronized (IpUtils.class) {
                if (ip == null) {
                    ip = getLocalIpByNetcard();
                }
            }
        }
        return ip;
    }
}

2. 登陸接口

@CrossOrigin註解來支持跨域,由於後續咱們測試的時候用localhost來訪問登陸界面;可是 sse 註冊是用的本機 ip,因此會有跨域問題,實際的項目中可能並不存在這個問題

登陸頁邏輯,訪問以後返回的一張二維碼,二維碼內容爲登陸受權 url

@CrossOrigin
@Controller
public class QrLoginRest {
    @Value(("${server.port}"))
    private int port;

    @GetMapping(path = "login")
    public String qr(Map<String, Object> data) throws IOException, WriterException {
        String id = UUID.randomUUID().toString();
        // IpUtils 爲獲取本機ip的工具類,本機測試時,若是用127.0.0.1, localhost那麼app掃碼訪問會有問題哦
        String ip = IpUtils.getLocalIP();

        String pref = "http://" + ip + ":" + port + "/";
        data.put("redirect", pref + "home");
        data.put("subscribe", pref + "subscribe?id=" + id);


        String qrUrl = pref + "scan?id=" + id;
        // 下面這一行生成一張寬高200,紅色,圓點的二維碼,並base64編碼
        // 一行完成,就這麼簡單省事,強烈安利
        String qrCode = QrCodeGenWrapper.of(qrUrl).setW(200).setDrawPreColor(Color.RED)
                .setDrawStyle(QrCodeOptions.DrawStyle.CIRCLE).asString();
        data.put("qrcode", DomUtil.toDomSrc(qrCode, MediaType.ImageJpg));
        return "login";
    }
}

請注意上面的實現,咱們返回的是一個視圖,並傳遞了三個數據

  • redirect: 跳轉 url(app 受權以後,跳轉的頁面)
  • subscribe: 訂閱 url(用戶會訪問這個 url,開啓長鏈接,接收服務端推送的掃碼、登陸事件)
  • qrcode: base64 格式的二維碼圖片

注意:subscribeqrcode都用到了全局惟一 id,後面的操做中,這個參數很重要

接着時候對應的 html 頁面,在resources/templates文件下,新增文件login.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="SpringBoot thymeleaf"/>
    <meta name="author" content="YiHui"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>二維碼界面</title>
</head>
<body>

<div>
    <div class="title">請掃碼登陸</div>
    <img th:src="${qrcode}"/>
    <div id="state" style="display: none"></div>

    <script th:inline="javascript">
        var stateTag = document.getElementById('state');

        var subscribeUrl = [[${subscribe}]];
        var source = new EventSource(subscribeUrl);
        source.onmessage = function (event) {
            text = event.data;
            console.log("receive: " + text);
            if (text == 'scan') {
                stateTag.innerText = '已掃描';
                stateTag.style.display = 'block';
            } else if (text.startsWith('login#')) {
                // 登陸格式爲 login#cookie
                var cookie = text.substring(6);
                document.cookie = cookie;
                window.location.href = [[${redirect}]];
                source.close();
            }
        };

        source.onopen = function (evt) {
            console.log("開始訂閱");
        }
    </script>
</div>
</body>
</html>

請注意上面的 html 實現,id 爲 state 這個標籤默認是不可見的;經過EventSource來實現 SSE(優勢是實時且自帶重試功能),並針對返回的結果進行了格式定義

  • 若接收到服務端 scan 消息,則修改 state 標籤文案,並設置爲可見
  • 若接收到服務端 login#cookie 格式數據,表示登陸成功,#後面的爲 cookie,設置本地 cookie,而後重定向到主頁,並關閉長鏈接

其次在 script 標籤中,若是須要訪問傳遞的參數,請注意下面兩點

  • 須要在 script 標籤上添加th:inline="javascript"
  • [[${}]] 獲取傳遞參數

3. sse 接口

前面登陸的接口中,返回了一個sse的註冊接口,客戶端在訪問登陸頁時,會訪問這個接口,按照咱們前面的 sse 教程文檔,能夠以下實現

private Map<String, SseEmitter> cache = new ConcurrentHashMap<>();

@GetMapping(path = "subscribe", produces = {org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE})
public SseEmitter subscribe(String id) {
    // 設置五分鐘的超時時間
    SseEmitter sseEmitter = new SseEmitter(5 * 60 * 1000L);
    cache.put(id, sseEmitter);
    sseEmitter.onTimeout(() -> cache.remove(id));
    sseEmitter.onError((e) -> cache.remove(id));
    return sseEmitter;
}

4. 掃碼接口

接下來就是掃描二維碼進入受權頁面的接口了,這個邏輯就比較簡單了

@GetMapping(path = "scan")
public String scan(Model model, HttpServletRequest request) throws IOException {
    String id = request.getParameter("id");
    SseEmitter sseEmitter = cache.get(request.getParameter("id"));
    if (sseEmitter != null) {
        // 告訴pc端,已經掃碼了
        sseEmitter.send("scan");
    }

    // 受權贊成的url
    String url = "http://" + IpUtils.getLocalIP() + ":" + port + "/accept?id=" + id;
    model.addAttribute("url", url);
    return "scan";
}

用戶掃碼訪問這個頁面以後,會根據傳過來的 id,定位對應的 pc 客戶端,而後發送一個scan的信息

受權頁面簡單一點實現,加一個受權的超鏈就好,而後根據實際的狀況補上用戶 token(因爲並無獨立的 app 和用戶體系,因此下面做爲演示,就隨機生成一個 token 來替代)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="SpringBoot thymeleaf"/>
    <meta name="author" content="YiHui"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>掃碼登陸界面</title>
</head>
<body>

<div>
    <div class="title">肯定登陸嘛?</div>

    <div>
        <a id="login">登陸</a>
    </div>

    <script th:inline="javascript">

        // 生成uuid,模擬傳遞用戶token
        function guid() {

            return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
                var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
                return v.toString(16);

            });
        }

        // 獲取實際的token,補齊參數,這裏只是一個簡單的模擬
        var url = [[${url}]];
        document.getElementById("login").href = url + "&token=" + guid();
    </script>

</div>
</body>
</html>

5. 受權接口

點擊上面的受權超鏈以後,就表示登陸成功了,咱們後端的實現以下

@ResponseBody
@GetMapping(path = "accept")
public String accept(String id, String token) throws IOException {
    SseEmitter sseEmitter = cache.get(id);
    if (sseEmitter != null) {
        // 發送登陸成功事件,並攜帶上用戶的token,咱們這裏用cookie來保存token
        sseEmitter.send("login#qrlogin=" + token);
        sseEmitter.complete();
        cache.remove(id);
    }

    return "登陸成功: " + token;
}

6. 首頁

用戶受權成功以後,就會自動跳轉到首頁了,咱們在首頁就簡單一點,搞一個歡迎的文案便可

@GetMapping(path = {"home", ""})
@ResponseBody
public String home(HttpServletRequest request) {
    Cookie[] cookies = request.getCookies();
    if (cookies == null || cookies.length == 0) {
        return "未登陸!";
    }

    Optional<Cookie> cookie = Stream.of(cookies).filter(s -> s.getName().equalsIgnoreCase("qrlogin")).findFirst();
    return cookie.map(cookie1 -> "歡迎進入首頁: " + cookie1.getValue()).orElse("未登陸!");
}

7. 實測

到此一個完整的登陸受權已經完成,能夠進行實際操做演練了,下面是一個完整的演示截圖(雖然我並無真的用 app 進行掃描登陸,而是識別二維碼地址,在瀏覽器中進行受權,實際並不影響整個過程,你用二維掃一掃受權效果也是同樣的)

演示

請注意上面截圖的幾個關鍵點

  • 掃碼以後,登陸界面二維碼下面會顯示已掃描的文案
  • 受權成功以後,登陸界面會主動跳轉到首頁,並顯示歡迎 xxx,並且注意用戶是一致的

8. 小結

實際的業務開發選擇的方案可能和本文提出的並不太同樣,也可能存在更優雅的實現方式(請有這方面經驗的大佬佈道一下),本文僅做爲一個參考,不表明標準,不表示徹底準確,若是把你們帶入坑了,請留言(固然我是不會負責的 🙃)

上面演示了徒手擼了一個二維碼登陸的示例工程,主要用到了一下技術點

  • qrcode-plugin:生成二維碼,再次強烈安利一個私覺得 java 生態下最好用二維碼生成工具包 https://github.com/liuyueyi/quick-media/blob/master/plugins/qrcode-plugin (雖然吹得比較兇,但我並無收廣告費,由於這也是我寫的 😂)
  • SSE: 服務端推送事件,服務端單通道通訊,實現消息推送
  • SpringBoot/Thymeleaf: 演示項目基礎環境

最後,以爲不錯的能夠贊一下,加個好友有事沒事聊一聊,關注個微信公衆號支持一二,都是能夠的嘛

III. 其餘

0. 項目

相關博文

關於本篇博文,部分知識點能夠查看如下幾篇進行補全


1. 一灰灰 Blog

盡信書則不如,以上內容,純屬一家之言,因我的能力有限,不免有疏漏和錯誤之處,如發現 bug 或者有更好的建議,歡迎批評指正,不吝感激

下面一灰灰的我的博客,記錄全部學習和工做中的博文,歡迎你們前去逛逛

一灰灰blog

相關文章
相關標籤/搜索