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 语言实现简单扫码登录_第1张图片

实现

环境准备
  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 = 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 语言实现简单扫码登录_第2张图片

待改进

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

你可能感兴趣的:(Java 语言实现简单扫码登录)