徒手撸一个扫码登陆示例工程

徒手撸一个扫码登陆示例工程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

相关文章
相关标签/搜索