Java|Java 语言实现简单扫码登录

扫码登录
目前手机扫描二维码登录已成为一种主流的登录方式,尤其是在 PC 网页端。最近学习了一下扫码登录的原理,感觉蛮有趣的,所以借鉴了网上的一些示例,实现了一个简单的扫码登录的 demo,以此记录一下学习过程。
原理解析 流程简述

  1. PC 端打开二维码登录页面 login.html;
  2. login.html 调用后端接口 createQrCodeImg,该接口生成一个随机的 uuid,uuid 可看做是本页面的唯一标识,同时该接口还会创建一个 LoginTicket 对象,该对象中封装了如下信息:
    • uuid:页面的唯一标识;
    • userId:用户 id;
    • status:扫码状态,0 表示等待扫码,1 表示等待确认,2 表示已确认。
  3. 将上述 uuid 作为 key、LoginTicket 对象作为 value 存储在 Redis 服务器中(或其他数据库),设置其过期时间为 5 分钟,表示 5 分钟后二维码失效。
  4. 生成二维码图片,二维码中封装的信息为一个 URL,类似于 http://localhost:8080/login/s... 。
  5. PC 端显示二维码;
  6. PC 端页面不断轮询(多久轮询一次自行设置)检查扫码的进度,即 LoginTicket 对象的状态。如果为 0 或为 1,继续轮询;如果为 2,停止轮询(已确认登录);
  7. 手机端扫描二维码;
  8. 手机端(携带用户的 token,该 token 为手机端 token)访问二维码中的目标网址,手机端服务器首先验证 token 是否有效,如果有效则将 LoginTicket 对象的 status 更新为 1;
  9. 手机端服务器询问用户是否确认登录;
  10. 用户选择确认登录,手机端服务器将 LoginTicket 对象的 status 更新为 2,并将 userId 设置为当前用户的 id;
  11. PC 端检测到用户确认登录后,为用户生成 token(此 token 为 PC 端的 token),并将 token 返回给前端;
  12. 前端获取到 token 后就可以执行其他操作。
流程图 总体流程如下:
Java|Java 语言实现简单扫码登录
文章图片

实现 环境准备
  1. JDK 1.8;
  2. maven 3.3.6;
  3. Springboot 2.xx;
  4. Redis。
实体对象 LoginTicket 类定义如下:
@Data public class LoginTicket {private String userId; private String uuid; private int status; }

User 类简单封装用户的 id 和 name:
@Data public class User {private String userId; private String userName; }

登录接口
  1. 获取二维码
@RequestMapping(path = "/getQrCodeImg", method = RequestMethod.GET) public String createQrCodeImg(Model model) {// 生成uuid和loginTicket对象并存入Redis String uuid = loginService.createQrImg(); // 使用QrCodeUtil生成二维码 String QrCode = Base64.encodeBase64String(QrCodeUtil.generatePng(loginURL + uuid, 300, 300)); // 返回uuid和二维码 model.addAttribute("uuid", uuid); model.addAttribute("QrCode", QrCode); return "login"; }

当访问 "localhost:8080/login/getQrCodeImg" 时,PC 端服务器会生成一个 uuid 和一个 LoginTicket 对象,然后将 uuid 作为 key,LoginTicket 对象作为 value 存入到 Redis 服务器中(设置其过期时间为 5 分钟)。接着将该 uuid 拼接到 URL 中(此 URL 即为手机端扫码后所访问的网址),并使用开源工具类 Hutool 中的 QrCodeUtil 生成二维码图片。
关于 Hutool 的使用可以参考 https://hutool.cn/ 。
  1. 扫描二维码
@RequestMapping(path = "/scan/{uuid}/{userId}", method = RequestMethod.GET) public String scanQrCodeImg(Model model, @PathVariable("uuid") String uuid, @PathVariable("userId") String userId) { // 判断用户是否成功扫码 boolean scanned = loginService.scanQrCodeImg(uuid, userId); // 返回扫码信息 model.addAttribute("scanned", scanned); model.addAttribute("uuid", uuid); model.addAttribute("userId", userId); return "scan"; }

二维码中封装的信息是一个 URL,手机端扫描二维码时,会访问该 URL 所代表的的网址。此时请求中会携带手机端用户的 token 和 uuid,token 用来确认用户的身份。在上述代码中,我们简化手机端的操作,直接传入 userId,利用 userId 代替 token 来识别用户。服务器(此处为手机端服务器,但我们使用 PC 端服务器模拟手机端服务器)首先根据 userId 查询用户是否已经登录,如果 Redis 中存在该用户的信息,则表示用户已经登录。如果用户未登录或二维码已经过期,则扫码失败,返回 false;否则将 LoginTicket 对象的状态设置为 1,表示已经扫码,等待确认。
  1. 确认登录
@RequestMapping(path = "/confirm/{uuid}/{userId}", method = RequestMethod.GET) @ResponseBody public Response confirmLogin(@PathVariable("uuid") String uuid, @PathVariable("userId") String userId) { // 判断用户是否成功确认 boolean logged = loginService.confirmLogin(uuid, userId); String msg = logged ? "登录成功!" : "二维码已过期!"; return Response.createResponse(msg, logged); }

同扫码请求一样,确认登录时也使用 userId 代替 token 进行身份识别。手机端(在浏览器中模拟手机端操作)发送确认请求时,服务器首先检查二维码是否过期(按理来说扫码后再确认,二维码应该不会过期)。如果确认成功,那么将 LoginTicket 对象的状态设置为 2,并将 userId 置为当前用户的 id(或许 userId 在 scan 在扫码请求就应该设置为用户 id?)。
  1. 轮询
@RequestMapping(path = "/getQrCodeState/{uuid}", method = RequestMethod.GET) @ResponseBody public Response getQrCodeState(@PathVariable("uuid") String uuid) throws InterruptedException { JSONObject data = https://www.it610.com/article/new JSONObject(); // 检查二维码是否过期 String redisKey = CommonUtil.getTicketKey(uuid); LoginTicket loginTicket = (LoginTicket) redisTemplate.opsForValue().get(redisKey); if (loginTicket == null) { data.put("status", -1); return Response.createResponse("二维码已过期!", data); } // 检查status int status = loginTicket.getStatus(); data.put("status", status); if (status == 2) { // 用户已确认登录 String userId = loginTicket.getUserId(); User user = userService.getLoggedUser(userId); if (user != null) { // 生成token String token = TokenUtil.buildToken(userId, user.getUserName()); data.put("token", token); return Response.createResponse(null, data); } return Response.createErrorResponse("无用户信息!"); } // 2s轮询一次 Thread.sleep(2000); String msg = status == 0 ? null : "已扫描, 等待确认"; return Response.createResponse(msg, data); }

轮询的逻辑其实就是根据 uuid 检查 LoginTicket 对象的状态,如果 LoginTicket 对象为空,表示二维码已经过期;如果 status 为 0,表示等待扫码;如果 status 为 1,表示已扫码,等待确认;如果 status 为 2,表示已确认登录。当检测到用户确认登录后,服务器为用户生成 token(此 token 用于 PC 端服务器识别用户身份),然后将 token 返回给前端。注意,上述代码生成 token 之前,调用了 UserService 中的 getLoggedUser 方法来查询用户的身份信息,在此 demo 中,为了简化操作,凡是需要获取用户信息的地方我们都使用该方法去获取,如前面手机端服务器(其实也是在 PC 端模拟)根据 token (为了简化,实际上为 userId)查询用户信息时也调用了该方法。还有一点需要注意,最后一步的 token 也可以使用 cookie 来代替,这样也许会更加简单,这里学习了一下 JWT,所以采用 token(使用 token 访问时,token 应该怎样保存呢,苦恼!!!!!+10086)。getLoggedUser 方法其实就是检测 Redis 中有无用户的身份信息,代码如下:
public User getLoggedUser(String userId) { String redisKey = CommonUtil.getUserKey(userId); return (User) redisTemplate.opsForValue().get(redisKey); }

Service 层 Service 层对应的代码如下:
@Service public class LoginService {private final int WAIT_EXPIRED_SECONDS = 60 * 5; private final int LOGIN_EXPIRED_SECONDS = 3600 * 24; @Autowired private RedisTemplate redisTemplate; public String createQrImg() { // 生成loginTicket String uuid = CommonUtil.generateUUID(); LoginTicket loginTicket = new LoginTicket(); loginTicket.setUuid(uuid); loginTicket.setStatus(0); // 存入redis String redisKey = CommonUtil.getTicketKey(loginTicket.getUuid()); redisTemplate.opsForValue().set(redisKey, loginTicket, WAIT_EXPIRED_SECONDS, TimeUnit.SECONDS); return uuid; }public boolean scanQrCodeImg(String uuid, String userId) { String ticketKey = CommonUtil.getTicketKey(uuid); String userKey = CommonUtil.getUserKey(userId); LoginTicket loginTicket = (LoginTicket) redisTemplate.opsForValue().get(ticketKey); User user = (User) redisTemplate.opsForValue().get(userKey); // 检测用户是否登录以及二维码是否过期 if (user == null || loginTicket == null) { return false; } else { // 将status置为1 loginTicket.setStatus(1); redisTemplate.opsForValue().set(ticketKey, loginTicket, redisTemplate.getExpire(ticketKey, TimeUnit.SECONDS), TimeUnit.SECONDS); } return true; }public boolean confirmLogin(String uuid, String userId) { String redisKey = CommonUtil.getTicketKey(uuid); LoginTicket loginTicket = (LoginTicket) redisTemplate.opsForValue().get(redisKey); boolean logged = true; if (loginTicket == null) { logged = false; } else { // 将userId置为用户id, 并将status置为2 loginTicket.setUserId(userId); loginTicket.setStatus(2); redisTemplate.opsForValue().set(redisKey, loginTicket, LOGIN_EXPIRED_SECONDS, TimeUnit.SECONDS); } return logged; } }

前端的几个 xx.html 文件的代码写得不太好,大家直接看源码吧,源码我会放在文末。
效果演示 执行程序前,我们需要在 Redis 中存储当前用户的信息,表示用户在手机端已经登录,其中 key 的格式为 user:userId,value 为 User 对象。比如在演示前,我们在 Redis 中存储了 userId 为 "1" 的用户 "Join同学"。
【Java|Java 语言实现简单扫码登录】演示动图如下:
Java|Java 语言实现简单扫码登录
文章图片

待改进
  1. 整个流程中应该存在手机端服务器和 PC 端服务器,但为了简化操作,我们利用 PC 端模拟手机端,比如扫码和确认请求应该由手机端服务器处理,而程序中我们直接在 PC 端访问对应的 Controller;
  2. 检查扫码状态时采用了轮询的方式,或许可以采用 Websocket;
  3. "手机端" 验证 "token" 时,我们使用 userId 来简化操作;
  4. 最后一步我们将 token 返回给了前端,前端发送请求时,需要在 header 中存放 token,但问题是 token 应该如何保存呢?之后需要解决此问题;
  5. 未学习过前端,所以代码不怎么规范。
欢迎批评指正,源码见扫码登录

    推荐阅读