spring boot 扫码登录 后端实现

文章目录

前言

在业内,扫码登陆不是什么新技术了,我这里主要是想自己实现一下这个功能,用的是简单实现,提供的只是思路 具体可以参考网上的其他文章

开发环境

mac+idea+paw+chrome+mysql
开发语言:java+kotlin

mac:我的开发系统
idea:开发工具
paw:http调试工具

插一句

开发语言使用kotlin是有原因,kotlin是构建在jvm上的,而且有很多很方便的语法糖,敲代码速度很快

启动项目

首先配置一个 spring boot 的项目,这里使用 maven 构建的方案,因为我这里使用 gradle 构建总是会出现各种奇怪的问题

pom.xml

  1<?xml version="1.0" encoding="UTF-8"?>
  2<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  3         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  4    <modelVersion>4.0.0</modelVersion>
  5
  6    <groupId>com.kikt</groupId>
  7    <artifactId>myapp</artifactId>
  8    <version>0.0.1-SNAPSHOT</version>
  9    <!--<packaging>war</packaging>-->
 10    <packaging>war</packaging>
 11
 12    <name>myapp</name>
 13    <description>MyApp</description>
 14
 15    <parent>
 16        <groupId>org.springframework.boot</groupId>
 17        <artifactId>spring-boot-starter-parent</artifactId>
 18        <version>1.5.4.RELEASE</version>
 19        <relativePath/> <!-- lookup parent from repository -->
 20    </parent>
 21
 22    <properties>
 23        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 24        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
 25        <java.version>1.8</java.version>
 26        <kotlin.version>1.1.3-2</kotlin.version>
 27    </properties>
 28
 29    <dependencies>
 30        <!--<dependency>-->
 31        <!--<groupId>org.springframework.boot</groupId>-->
 32        <!--<artifactId>spring-boot-starter-data-jpa</artifactId>-->
 33        <!--</dependency>-->
 34        <dependency>
 35            <groupId>org.springframework.boot</groupId>
 36            <artifactId>spring-boot-starter-data-rest</artifactId>
 37        </dependency>
 38        <dependency>
 39            <groupId>org.springframework.boot</groupId>
 40            <artifactId>spring-boot-starter-web</artifactId>
 41        </dependency>
 42
 43        <dependency>
 44            <groupId>mysql</groupId>
 45            <artifactId>mysql-connector-java</artifactId>
 46            <scope>runtime</scope>
 47        </dependency>
 48
 49        <dependency>
 50            <groupId>com.alibaba</groupId>
 51            <artifactId>druid</artifactId>
 52            <version>1.1.0</version>
 53        </dependency>
 54
 55        <dependency>
 56            <groupId>org.springframework.boot</groupId>
 57            <artifactId>spring-boot-starter-tomcat</artifactId>
 58            <!--<scope>provided</scope>-->
 59        </dependency>
 60        <dependency>
 61            <groupId>org.springframework.boot</groupId>
 62            <artifactId>spring-boot-starter-actuator</artifactId>
 63            <!--<scope>provided</scope>-->
 64        </dependency>
 65        <dependency>
 66            <groupId>org.springframework.boot</groupId>
 67            <artifactId>spring-boot-starter-test</artifactId>
 68            <scope>test</scope>
 69        </dependency>
 70
 71        <!-- https://mvnrepository.com/artifact/org.mybatis.spring.boot/mybatis-spring-boot-starter -->
 72        <!--<dependency>-->
 73            <!--<groupId>org.mybatis.spring.boot</groupId>-->
 74            <!--<artifactId>mybatis-spring-boot-starter</artifactId>-->
 75            <!--<version>1.3.0</version>-->
 76        <!--</dependency>-->
 77
 78
 79        <dependency>
 80            <groupId>org.springframework.boot</groupId>
 81            <artifactId>spring-boot-starter-aop</artifactId>
 82        </dependency>
 83
 84        <dependency>
 85            <groupId>org.springframework.boot</groupId>
 86            <artifactId>spring-boot-starter-data-jpa</artifactId>
 87            <version>1.5.4.RELEASE</version>
 88        </dependency>
 89
 90        <dependency>
 91            <groupId>org.jetbrains.kotlin</groupId>
 92            <artifactId>kotlin-stdlib-jre8</artifactId>
 93            <version>${kotlin.version}</version>
 94        </dependency>
 95        <dependency>
 96            <groupId>org.jetbrains.kotlin</groupId>
 97            <artifactId>kotlin-test</artifactId>
 98            <version>${kotlin.version}</version>
 99            <scope>test</scope>
100        </dependency>
101        <!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
102        <dependency>
103            <groupId>com.alibaba</groupId>
104            <artifactId>fastjson</artifactId>
105            <version>1.2.35</version>
106        </dependency>
107
108        <!-- https://mvnrepository.com/artifact/org.json/json -->
109        <dependency>
110            <groupId>org.json</groupId>
111            <artifactId>json</artifactId>
112            <version>20170516</version>
113        </dependency>
114
115        <dependency>
116            <groupId>org.springframework.boot</groupId>
117            <artifactId>spring-boot-starter-jdbc</artifactId>
118            <exclusions>
119                <exclusion>
120                    <groupId>org.apache.tomcat</groupId>
121                    <artifactId>tomcat-jdbc</artifactId>
122                </exclusion>
123            </exclusions>
124        </dependency>
125
126        <dependency>
127            <groupId>org.springframework.boot</groupId>
128            <artifactId>spring-boot-starter-thymeleaf</artifactId>
129        </dependency>
130        <dependency>
131            <groupId>org.springframework.boot</groupId>
132            <artifactId>spring-boot-devtools</artifactId>
133        </dependency>
134
135        <!--<dependency>-->
136        <!--<groupId>org.springframework.boot</groupId>-->
137        <!--<artifactId>spring-boot-starter-ssl</artifactId>-->
138        <!--</dependency>-->
139
140        <!--netty-->
141        <dependency>
142            <groupId>io.netty</groupId>
143            <artifactId>netty-all</artifactId>
144            <version>4.1.13.Final</version>
145        </dependency>
146    </dependencies>
147
148    <build>
149        <finalName>myapp</finalName>
150        <plugins>
151            <plugin>
152                <groupId>org.springframework.boot</groupId>
153                <artifactId>spring-boot-maven-plugin</artifactId>
154            </plugin>
155            <plugin>
156                <groupId>org.jetbrains.kotlin</groupId>
157                <artifactId>kotlin-maven-plugin</artifactId>
158                <version>${kotlin.version}</version>
159                <executions>
160                    <execution>
161                        <id>compile</id>
162                        <phase>compile</phase>
163                        <goals>
164                            <goal>compile</goal>
165                        </goals>
166                    </execution>
167                    <execution>
168                        <id>test-compile</id>
169                        <phase>test-compile</phase>
170                        <goals>
171                            <goal>test-compile</goal>
172                        </goals>
173                    </execution>
174                </executions>
175                <configuration>
176                    <jvmTarget>1.8</jvmTarget>
177                </configuration>
178            </plugin>
179            <plugin>
180                <groupId>org.apache.maven.plugins</groupId>
181                <artifactId>maven-compiler-plugin</artifactId>
182                <executions>
183                    <execution>
184                        <id>compile</id>
185                        <phase>compile</phase>
186                        <goals>
187                            <goal>compile</goal>
188                        </goals>
189                    </execution>
190                    <execution>
191                        <id>testCompile</id>
192                        <phase>test-compile</phase>
193                        <goals>
194                            <goal>testCompile</goal>
195                        </goals>
196                    </execution>
197                </executions>
198            </plugin>
199        </plugins>
200    </build>
201    <repositories>
202        <repository>
203            <id>spring-milestone</id>
204            <url>http://repo.spring.io/libs-release</url>
205        </repository>
206    </repositories>
207
208</project>

其中有一些是其他的配置,比如 netty 是在内部构建一个 netty 服务器,注入 spring 进行管理

 1server:
 2  port: 8433
 3  tomcat:
 4    uri-encoding: utf-8
 5
 6spring:
 7  datasource:
 8    url: jdbc:mysql://localhost:3306/app?autoReconnect=true&useUnicode=true&characterEncoding=utf-8
 9    username: root
10    password: root
11    driver-class-name: com.mysql.jdbc.Driver
12  #    type: com.alibaba.druid.pool.DruidDataSource
13  profiles:
14    active: dev
15  #    active: prod
16  #    active: test
17  #jpg
18  jpa:
19    database: mysql
20    show-sql: true
21    hibernate:
22      ddl-auto: update
23  jooq:
24    sql-dialect:
25  #thymeleaf
26  thymeleaf:
27    mode: HTML5
28#mybatis:
29#  mapperLocations: classpath:mapper/*.xml
30#  type-aliases-package: com.kikt.api.responsedata

配置文件,使用的是 yml 的格式,也算比较容易理解吧

首先配置几个 Controller

除网页外,其他所有的交互方式使用 restful 的方式

 1@RestController
 2@RequestMapping("/user")
 3public class UserCtl extends BaseCtl {
 4
 5    @Autowired
 6    private ScanService scanService;
 7
 8    @Autowired
 9    private LoginService loginService;
10
11    @RequestMapping(value = "/login/{username}", method = RequestMethod.POST)
12    public String login(@PathVariable("username") String username, @RequestParam("pwd") String pwd) {
13        return loginService.login(username, pwd);
14    }
15
16    @RequestMapping(value = "/login", method = RequestMethod.GET)
17    public String index(Model model, HttpServletRequest request) {
18        String sessionId = scanService.getSessionId();
19        String scheme = request.getScheme();
20        logger.debug("URL:" + request.getRequestURL());
21        String serverName = request.getServerName();
22        logger.debug("addr:" + serverName);
23        String contextPath = request.getContextPath();
24        logger.debug("contextPath:" + contextPath);
25        int serverPort = request.getServerPort();
26        logger.debug("serverport:" + serverPort);
27
28        StringBuilder path = new StringBuilder();
29        path.append(scheme).append("://").append(serverName).append(":").append(serverPort).append(contextPath);
30
31        model.addAttribute("sessionId", sessionId);
32        model.addAttribute("qrcode", path + "/user/login/" + sessionId);
33        return "index";
34    }
35
36    //for html wait login
37    @RequestMapping(value = "/wait/{sessionId}", method = RequestMethod.POST)
38    @ResponseBody
39    public String waitLogin(@PathVariable("sessionId") String sessionId) {
40        return scanService.waitForLogin(sessionId);
41    }
42
43    //phone scan for the login
44    @RequestMapping(value = "/login/{sessionId}", method = RequestMethod.POST)
45    @ResponseBody
46    public String scanWithLogin(@PathVariable("sessionId") String sessionId, @RequestParam String username, @RequestParam String token) {
47        loginService.checkTokenWithName(username, token);
48        return scanService.scanWithLogin(sessionId, username);
49    }
50}

这样就可以使用 http://localhost:8433/user/login/user 这样的 url,使用 post 方式,模拟表单
同一个 url,使用 get 的方式,获取的就是二维码的显示页面
这里 index 是定义到一个模板页面,model 中可以设置一些属性在模板文件中进行调用,我这里模板用的是thymeleaf

模板文件

放在 src/main/resources/templates 目录下,也就是在生成放置 application.properties 的目录中新建一个 templates 目录,在其中新建一个 index.html,这样 controller 就会使用模板渲染 html 使用了 jquery 和 jquery.qrcode 两个 js 库,其中 jquery 是网络访问使用,qrcode 依赖于 jquery,同时提供 qrcode 的生成

 1<!DOCTYPE html>
 2<html
 3  xmlns:th="http://www.thymeleaf.org"
 4  xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3"
 5>
 6  <head>
 7    <meta name="viewport" content="width=device-width,initial-scale=1" />
 8    <!--<link th:href="@{bootstrap/css/bootstrap.min.css}" rel="stylesheet"/>-->
 9    <!--<link th:href="@{bootstrap/css/bootstrap-theme.min.css}" rel="stylesheet"/>-->
10    <meta charset="UTF-8" />
11    <script type="text/javascript" th:src="@{/js/jquery-3.2.1.min.js}"></script>
12    <!--<script type="text/javascript" th:src="@{/js/qrcode.js}"></script>-->
13    <script
14      type="text/javascript"
15      src="http://cdn.bootcss.com/jquery.qrcode/1.0/jquery.qrcode.min.js"
16    ></script>
17    <!--<script type="text/javascript" src="../static/js/jquery-3.2.1.min.js"></script>-->
18
19    <script th:inline="javascript" type="text/javascript">
20
21      function makeQrImage(sessionId) {
22          var qrcode = [[${qrcode}]]
23          $('#code').qrcode(qrcode);
24          $('p').text(qrcode)
25
26          $.ajax({
27              url:'./wait/'+sessionId,
28              method:'post',
29              success:login,
30              fail:loginFail
31          })
32      }
33
34      var login = function (result) {
35          $('p').text(result)
36      };
37
38      var loginFail = function (result) {
39          $('p').text(result)
40      }
41
42      $(document).ready(function () {
43          var sessionId = [[${sessionId}]]
44          makeQrImage(sessionId)
45      });
46    </script>
47    <style>
48      .qrcode {
49        width: 150px;
50        height: 150px;
51      }
52    </style>
53    <title>Title</title>
54  </head>
55  <body>
56    <div id="code"></div>
57    <div id="result"><p></p></div>
58  </body>
59</html>

[[${sessionId}]] 就是读取 model 中的 sessionId 属性

同理 [[${qrcode}]]就是 model 中的 qrcode 属性

这里 <script th:inline="javascript" type="text/javascript"></script> 的标签中使用了 th:inline="javascript" 这样的写法,这个就是模板的写法了,让 js 标签内可以识别模板中的变量等等

这里 ajax 中使用了硬编码,可以考虑使用 java 中的 model 传过来,如同 qrcode 的 url 一样,这样就可以在不动 html 的情况下,完成后台 url 的切换

这里其实逻辑比较简单

步骤

前端网页: 访问 /user/login GET 方式,提示扫码,然后使用已经登录的手机扫码,同时创建一个 ajax 连接,后台 hold 住此链接等待扫码,使用的是长轮询的方案

手机端:访问/user/login/adminPOST 方式,先登录,获取了 token 和 username,然后再使用扫码,传入参数 username,token

mysql 数据库表设计(相关逻辑)

用户表
记录用户相关的数据,包括id,用户名,email,注册时间等信息

登录token表
记录用户token,和token更新时间,token信息

具体的 java 端实现
上面只是简单的流程步骤,具体的实现还是需要到 service 中去看

 1package com.kikt.api.service.scan
 2
 3import com.kikt.api.exeption.ErrorEnum
 4import com.kikt.api.ext.toJson
 5import com.kikt.api.service.BaseService
 6import com.kikt.api.service.user.LoginService
 7import com.kikt.response.Response
 8import org.springframework.beans.factory.annotation.Autowired
 9import org.springframework.stereotype.Service
10import java.util.*
11import java.util.concurrent.*
12
13/**
14 * Created by cai on 2017/8/24.
15 */
16@Service
17open class ScanService : BaseService {
18
19    @Autowired
20    private var loginService: LoginService? = null
21
22    private val map: MutableMap<String, LoginSession> = mutableMapOf()
23
24    fun getSessionId(): String {
25        val random = UUID.randomUUID().toString()
26        map.put(random, LoginSession())
27        return random
28    }
29
30    fun waitForLogin(sessionId: String): String {
31        val sessionData = map[sessionId] ?: ErrorEnum.SESSION_SCAN_TIME_OUT.throwError()
32        val waitForLogin: String
33        try {
34            waitForLogin = sessionData.waitForLogin()
35        } catch(e: Exception) {
36            map.remove(sessionId)
37            ErrorEnum.SESSION_SCAN_TIME_OUT.throwError()
38        }
39        map.remove(sessionId)
40        return waitForLogin
41    }
42
43    fun scanWithLogin(sessionId: String, username: String): String {
44        val sessionData = map[sessionId] ?: ErrorEnum.SESSION_NO_FOUNT.throwError()
45        val result = loginService?.login(username, 2)
46        if (result != null) {
47            sessionData.login(result)
48        }
49        return Response.newSuccessResponse("成功").toJson()
50    }
51
52}
53
54class LoginSession {
55
56    private val queue: BlockingQueue<String> = LinkedBlockingQueue(2)
57
58    companion object {
59        val TIME_OUT: Long = 60000
60
61        val threadPool: ExecutorService = Executors.newFixedThreadPool(30)
62    }
63
64    fun waitForLogin(): String {
65        val take: String?
66        try {
67            runDelayTimeout()
68            take = queue.take()
69        } catch(e: InterruptedException) {
70            throw e
71        }
72        return take
73    }
74
75    fun login(result: String) {
76        queue.offer(result)
77    }
78
79    fun runDelayTimeout() {
80        val currentThread = Thread.currentThread()
81        threadPool.execute {
82            Thread.sleep(TIME_OUT)
83            currentThread.interrupt()
84        }
85    }
86}

总体思路是:定义一个 map 用于记录 sesstionId,和具体的 LoginSession

LoginSession 中包含一个阻塞队列,在 index 的 ctl 中创建 sessionId 和 loginSession 对象,在访问/wait/sessionId 时调用,等待扫码,称为连接 1

扫码时,创建连接 2,根据 token 检验手机登陆用户,然后根据 sessionId 找到 LoginSession 对象,给队列传入数据,这样 LoginSession.take()返回后结果后,连接 1 返回登陆信息,同时登陆 2 返回成功的信息

优化

上面的连接 1 中需要设置一个超时时间,超时后返回失败,这里创建一个线程池,30 秒后尝试中断线程,上面

1    fun runDelayTimeout() {
2        val currentThread = Thread.currentThread()
3        threadPool.execute {
4            Thread.sleep(TIME_OUT)
5            currentThread.interrupt()
6        }
7    }

执行 60 秒后过时,连接 1 返回失败信息,前端根据失败信息显示刷新重试的样式即可

后记

总体思路和主要代码都放出来了,具体的实现应该还有更优解,这里我就不尝试了,只起到思路引领