好好学习,天天向上,一流范文网欢迎您!
当前位置: >> 最新范文 内容页

Java语言实现简易版扫码登陆

基本介绍

相信你们对二维码都不陌生,生活中四处参杂着扫码登陆的场景,如登入网页版陌陌、支付宝等。近来学习了一下扫码登陆的原理,觉得蛮有趣的,于是自己实现了一个简易版扫码登陆的Demo,借此记录一下学习过程。

实际上是笔试的时侯被问到了 ̄△ ̄!

原理解析

1.身分认证机制

在介绍扫码登陆的原理之前,我们先聊一聊服务端的身分认证机制。以普通的帐号+密码登入方法为例,服务端收到用户的登陆恳求后,首先验证帐号、密码的合法性。假如验证通过,这么服务端会为用户分配一个token,该token与用户的身分信息相关联,可作为用户的登陆账簿。以后PC端再度发送恳求时,须要在恳求的Header或则Query参数中携带token,服务端按照token便可辨识出当前用户。token的优点是愈发便捷、安全,它增加了帐号密码被绑架的风险,但是用户不须要重复地输入帐号和密码。PC端通过帐号和密码登陆的过程如下:

扫码登陆本质上也是一种身分认证方法,帐号+密码登入与扫码登陆的区别在于,后者是借助PC端的帐号和密码为PC端申请一个token,前者是借助手机端的token+设备信息为PC端申请一个token。这两种登陆方法的目的相同,都是为了使PC端获得服务端的“授权”,在为PC端申请token之前,两者都须要向服务端证明自己的身分,也就是必须让服务端晓得当前用户是谁,这样服务端能够为其生成PC端token。因为扫码前手机端一定是处于已登入状态的,因而手机端本身早已保存了一个token,该token可用于服务端的身分辨识。这么为何手机端在验证身分时还须要设备信息呢?实际上,手机端的身分认证和PC端略有不同:

手机端在登陆前也须要输入帐号和密码,但登陆恳求中不仅帐号密码外还包含着设备信息,比如设备类型、设备id等。

接收到登陆恳求后,服务端会验证帐号和密码二维码解码器,验证通过后,将用户信息与设备信息关联上去,也就是将它们储存在一个数据结构structure中。

服务端为手机端生成一个token,并将token与用户信息、设备信息关联上去,即以token为key,structure为value,将该通配符对持久化保存到本地,然后将token返回给手机端。

手机端发送恳求,携带token和设备信息,服务端按照token查询出structure,并验证structure中的设备信息和手机端的设备信息是否相同,借此判定用户的有效性。

我们在PC端登陆成功后,可以短时间内正常浏览网页,但以后访问网站时就要重新登录了,这是由于token是有过期时间的,较长的有效时间会减小token被绑架的风险。并且,手机端似乎极少有这些问题,比如陌陌登陆成功后可以始终使用,虽然关掉陌陌或重启手机。这是由于设备信息具有惟一性,虽然token被绑架了,因为设备信息不同,功击者也未能向服务端证明自己的身分,这样大大增强了安全系数,因而token可以长久使用。手机端通过帐号密码登入的过程如下:

2.流程概述

了解了服务端的身分认证机制后,我们再聊一聊扫码登陆的整个流程。以网页版陌陌为例,我们在PC端点击二维码登陆后,浏览器页面会弹出二维码图片,此时打开手机陌陌扫描二维码,PC端随后显示“正在扫码”,手机端点击确认登陆后,PC端都会显示“登陆成功”了。

上述过程中,服务端可以依据手机端的操作来响应PC端,这么服务端是怎样将两者关联上去的呢?答案就是通过“二维码”,严格来说是通过二维码中的内容。使用二维码解码器扫描网页版陌陌的二维码,可以得到如下内容:

由上图我们获知,二维码中包含的虽然是一个网址,手机扫描二维码后,会依据该网址向服务端发送“扫码”请求。接着,我们打开PC端浏览器的开发者工具:

可见,在显示出二维码后,PC端仍然都没有“闲着”,它通过寻址的形式不断向服务端发送恳求,以得知手机端操作的结果。这儿我们注意到,PC端发送的URL中有一个参数uuid,值为“Adv-NP1FYw==”,该uuid也存在于二维码包含的网址中。由此我们可以推测,服务端在生成二维码之前会先生成一个二维码id,二维码id与二维码的状态、过期时间等信息绑定在一起,一齐储存在服务端。手机端可以按照二维码id操作服务端二维码的状态,PC端可以按照二维码id向服务端寻问二维码的状态。

二维码最初为“待扫描”状态,手机端扫码后服务端将其状态改为“待确认”状态,此时PC端的协程恳求抵达,服务端向其返回“待确认”的响应。手机端确认登陆后,二维码弄成“已确认”状态,服务端为PC端生成用于身分认证的token,PC端再度寻问时,就可以得到这个token。整个扫码登陆的流程如右图所示:

PC端发送“扫码登陆”请求,服务端生成二维码id,并储存二维码的过期时间、状态等信息。

PC端获取二维码并显示。

PC端开始协程检测二维码的状态,二维码最初为“待扫描”状态。

手机端扫描二维码,获取二维码id。

手机端向服务端发送“扫码”请求,恳请中携带二维码id、手机端token以及设备信息。

服务端验证手机端用户的合法性,验证通过后将二维码状态置为“待确认”,并将用户信息与二维码关联在一起,然后为手机端生成一个一次性token,该token用作确认登陆的账簿。

PC端协程时测量到二维码状态为“待确认”。

手机端向服务端发送“确认登陆”请求,恳请中携带着二维码id、一次性token以及设备信息。

服务端验证一次性token,验证通过后将二维码状态置为“已确认”,并为PC端生成PC端token。

PC端协程时测量到二维码状态为“已确认”,并获取到了PC端token,然后PC端不再寻址。

PC端通过PC端token访问服务端。

上述过程中二维码解码器,我们注意到,手机端扫码后服务端会返回一个一次性token,该token也是一种身分账簿,但它只能使用一次。一次性token的作用是确保“扫码恳求”与“确认登陆”请求由同一个手机端发出,也就是说,手机端用户不能“帮其他用户确认登陆”。

关于一次性token的知识本人也不是很了解,但可以推断,在服务端的缓存中,一次性token映射的value应当包含“扫码”请求传入的二维码信息、设备信息以及用户信息。

代码实现

1.环境打算

2.主要依赖

3.生成二维码

二维码的生成以及二维码状态的保存逻辑如下:

@RequestMapping(path = "/getQrCodeImg", method = RequestMethod.GET)
public String createQrCodeImg(Model model) {
   String uuid = loginService.createQrImg();
   String qrCode = Base64.encodeBase64String(QrCodeUtil.generatePng("http://127.0.0.1:8080/login/uuid=" + uuid, 300, 300));
   model.addAttribute("uuid", uuid);
   model.addAttribute("QrCode", qrCode);
   return "login";
}

PC端访问“登录”请求时,服务端调用createQrImg方式,生成一个uuid和一个LoginTicket对象,LoginTicket对象中封装了用户的userId和二维码的状态。之后服务端将uuid作为key,LoginTicket对象作为value存入到Redis服务器中,并设置有效时间为5分钟(二维码的有效时间),createQrImg方式的逻辑如下:

public String createQrImg() {
   // uuid
   String uuid = CommonUtil.generateUUID();
   LoginTicket loginTicket = new LoginTicket();
   // 二维码最初为 WAITING 状态
   loginTicket.setStatus(QrCodeStatusEnum.WAITING.getStatus());
   // 存入 redis
   String ticketKey = CommonUtil.buildTicketKey(uuid);
   cacheStore.put(ticketKey, loginTicket, LoginConstant.WAIT_EXPIRED_SECONDS, TimeUnit.SECONDS);
   return uuid;
}

我们在前一节中提及,手机端的操作主要影响二维码的状态,PC端协程时也是查看二维码的状态,这么为何还要在LoginTicket对象中封装userId呢?这样做是为了将二维码与用户进行关联,想像一下我们登入网页版陌陌的场景,手机端扫码后,PC端都会显示用户的头像,尽管手机端并未确认登陆,但PC端寻址时早已获取到了当前扫码的用户(仅头像信息)。因而手机端扫码后,须要将二维码与用户绑定在一起,使用LoginTicket对象只是一种实现方法。二维码生成后,我们将其状态置为“待扫描”状态,userId不做处理,默认为null。

4.扫描二维码

手机端发送“扫码”请求时,Query参数中携带着uuid,服务端接收到恳求后,调用scanQrCodeImg方式,依据uuid查询出二维码并将其状态置为“待确认”状态,操作完成后服务端向手机端返回“扫码成功”或“二维码已失效”的信息:

@RequestMapping(path = "/scan", method = RequestMethod.POST)
@ResponseBody
public Response scanQrCodeImg(@RequestParam String uuid) {
   JSONObject data = loginService.scanQrCodeImg(uuid);
   if (data.getBoolean("valid")) {
      return Response.createResponse("扫码成功", data);
   }
   return Response.createErrorResponse("二维码已失效");
}

scanQrCodeImg方式的主要逻辑如下:

public JSONObject scanQrCodeImg(String uuid) {
   // 避免多个移动端同时扫描同一个二维码
   lock.lock();
   JSONObject data = new JSONObject();
   try {
      String ticketKey = CommonUtil.buildTicketKey(uuid);
      LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey);
      // redis 中 key 过期后也可能不会立即删除
      Long expired = cacheStore.getExpireForSeconds(ticketKey);
      boolean valid = loginTicket != null &&
               QrCodeStatusEnum.parse(loginTicket.getStatus()) == QrCodeStatusEnum.WAITING &&
               expired != null &&
               expired >= 0;
      if (valid) {
            User user = hostHolder.getUser();
            if (user == null) {
               throw new RuntimeException("用户未登录");
            }
            // 修改扫码状态
            loginTicket.setStatus(QrCodeStatusEnum.SCANNED.getStatus());
            Condition condition = CONDITION_CONTAINER.get(uuid);
            if (condition != null) {
               condition.signal();
               CONDITION_CONTAINER.remove(uuid);
            }
            // 将二维码与用户进行关联
            loginTicket.setUserId(user.getUserId());
            cacheStore.put(ticketKey, loginTicket, expired, TimeUnit.SECONDS);
            // 生成一次性 token, 用于之后的确认请求
            String onceToken = CommonUtil.generateUUID();
            cacheStore.put(CommonUtil.buildOnceTokenKey(onceToken), uuid, LoginConstant.ONCE_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
            data.put("once_token", onceToken);
      }
      data.put("valid", valid);
      return data;
   } finally {
      lock.unlock();
   }
}

首先按照uuid查询Redis中储存的LoginTicket对象,之后检测二维码的状态是否为“待扫描”状态,倘若是,这么将二维码的状态改为“待确认”状态。倘若不是,这么该二维码已被扫描过,服务端提示用户“二维码已失效”。我们规定,只容许第一个手机端就能扫描成功,加锁的目的是为了保证查询+更改操作的原子性,防止两个手机端同时扫码,且同时测量到二维码的状态为“待扫描”。

上一步操作成功后,服务端将LoginTicket对象中的userId置为当前用户(扫码用户)的userId,也就是将二维码与用户信息绑定在一起。因为扫码请求是由手机端发送的,因而该恳求一定来自于一个有效的用户,我们在项目中配置一个拦截器(也可以是过滤器),当拦截到“扫码”请求后,按照恳求中的token(手机端发送恳求时一定会携带token)查询出用户信息,并将其储存到ThreadLocal容器(hostHolder)中,然后绑定信息时就可以从ThreadLocal容器将用户信息提取下来。注意,这儿的token指的手机端token,实际中应当还有设备信息,但为了简化操作,我们忽视掉设备信息。

用户信息与二维码信息关联在一起后,服务端为手机端生成一个一次性token,并储存到Redis服务器,其中key为一次性token的值,value为uuid。一次性token会返回给手机端,作为“确认登陆”请求的账簿。

上述代码中,当二维码的状态被更改后,我们唤起了在condition中阻塞的线程,这一步的目的是为了实现长协程操作,下文中会介绍长协程的设计思路。

5.确认登陆

手机端发送“确认登陆”请求时,Query参数中携带着uuid,且Header中携带着一次性token,服务端接收到恳求后,首先验证一次性token的有效性,即检测一次性token对应的uuid与Query参数中的uuid是否相同,以确保扫码操作和确认操作来自于同一个手机端,该验证过程可在拦截器中配置。验证通过后,服务端调用confirmLogin方式,将二维码的状态置为“已确认”:

@RequestMapping(path = "/confirm", method = RequestMethod.POST)
@ResponseBody
public Response confirmLogin(@RequestParam String uuid) {
   boolean logged = loginService.confirmLogin(uuid);
   String msg = logged ? "登录成功!" : "二维码已失效!";
   return Response.createResponse(msg, logged);
}

confirmLogin方式的主要逻辑如下:

public boolean confirmLogin(String uuid) {
   String ticketKey = CommonUtil.buildTicketKey(uuid);
   LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey);
   boolean logged = true;
   Long expired = cacheStore.getExpireForSeconds(ticketKey);
   if (loginTicket == null || expired == null || expired == 0) {
      logged = false;
   } else {
      lock.lock();
      try {
            loginTicket.setStatus(QrCodeStatusEnum.CONFIRMED.getStatus());
            Condition condition = CONDITION_CONTAINER.get(uuid);
            if (condition != null) {
               condition.signal();
               CONDITION_CONTAINER.remove(uuid);
            }
            cacheStore.put(ticketKey, loginTicket, expired, TimeUnit.SECONDS);
      } finally {
            lock.unlock();
      }
   }
   return logged;
}

该方式会依照uuid查询二维码是否早已过期,倘若未过期,这么就更改二维码的状态。

6.PC端寻址

寻址操作指的是后端重复多次向前端发送相同的恳求,以得知数据的变化。寻址分为长寻址和短寻址:

因为长寻址相比短寻址就能得到实时的响应,且愈发节省资源,因而项目中我们考虑使用ReentrantLock来实现长协程。协程的目的是为了查看二维码状态的变化:

@RequestMapping(path = "/getQrCodeStatus", method = RequestMethod.GET)
@ResponseBody
public Response getQrCodeStatus(@RequestParam String uuid, @RequestParam int currentStatus) throws InterruptedException {
   JSONObject data = loginService.getQrCodeStatus(uuid, currentStatus);
   return Response.createResponse(null, data);
}

getQrCodeStatus方式的主要逻辑如下:

public JSONObject getQrCodeStatus(String uuid, int currentStatus) throws InterruptedException {
   lock.lock();
   try {
      JSONObject data = new JSONObject();
      String ticketKey = CommonUtil.buildTicketKey(uuid);
      LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey);
      QrCodeStatusEnum statusEnum = loginTicket == null || QrCodeStatusEnum.parse(loginTicket.getStatus()) == QrCodeStatusEnum.INVALID ?
               QrCodeStatusEnum.INVALID : QrCodeStatusEnum.parse(loginTicket.getStatus());
      if (currentStatus == statusEnum.getStatus()) {
            Condition condition = CONDITION_CONTAINER.get(uuid);
            if (condition == null) {
               condition = lock.newCondition();
               CONDITION_CONTAINER.put(uuid, condition);
            }
            condition.await(LoginConstant.POLL_WAIT_TIME, TimeUnit.SECONDS);
      }
      // 用户扫码后向 PC 端返回头像信息
      if (statusEnum == QrCodeStatusEnum.SCANNED) {
            User user = userService.getCurrentUser(loginTicket.getUserId());
            data.put("avatar", user.getAvatar());
      }
      // 用户确认后为 PC 端生成 access_token
      if (statusEnum == QrCodeStatusEnum.CONFIRMED) {
            String accessToken = CommonUtil.generateUUID();
            cacheStore.put(CommonUtil.buildAccessTokenKey(accessToken), loginTicket.getUserId(), LoginConstant.ACCESS_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
            data.put("access_token", accessToken);
      }
      data.put("status", statusEnum.getStatus());
      data.put("message", statusEnum.getMessage());
      return data;
   } finally {
      lock.unlock();
   }
}

该方式接收两个参数,即uuid和currentStatus,其中uuid用于查询二维码,currentStatus用于确认二维码状态是否发生了变化,倘若是,这么须要立刻向PC端反馈。我们规定PC端在协程时,恳求的参数中须要携带二维码当前的状态。

首先按照uuid查询出二维码的最新状态,并比较其是否与currentStatus相同。假如相同,这么当前线程步入阻塞状态,直至被唤起或则超时。

假如二维码状态为“待确认”,这么服务端向PC端返回扫码用户的头像信息(处于“待确认”状态时,二维码已与用户信息绑定在一起,因而可以查询出用户的头像)。

假如二维码状态为“已确认”,这么服务端为PC端生成一个token,在以后的恳求中,PC端可通过该token表明自己的身分。

上述代码中的加锁操作是为了才能令当前处理恳求的线程步入阻塞状态,当二维码的状态发生变化时,我们再将其唤起,因而上文中的扫码操作和确认登陆操作完成后,就会有一个唤起线程的过程。

实际上,加锁操作设计得不太合理,由于我们只设置了一把锁。因而对不同二维码的查询或更改操作还会占据同一把锁。按理来说,不同二维码的操作之间应当是互相独立的,虽然加锁,也应当是为每位二维码均配一把锁,但这样做代码会愈加复杂,其实有其它更好的实现长寻址的形式?或则干脆直接短协程。其实,也可以使用WebSocket实现长联接。

7.拦截器配置

项目中配置了两个拦截器,一个用于确认用户的身分,即验证token是否有效:

@Component
public class LoginInterceptor implements HandlerInterceptor {
    @Autowired
    private HostHolder hostHolder;
    @Autowired
    private CacheStore cacheStore;
    @Autowired
    private UserService userService;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String accessToken = request.getHeader("access_token");
        // access_token 存在
        if (StringUtils.isNotEmpty(accessToken)) {
            String userId = (String) cacheStore.get(CommonUtil.buildAccessTokenKey(accessToken));
            User user = userService.getCurrentUser(userId);
            hostHolder.setUser(user);
        }
        return true;
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        hostHolder.clear();
    }
}

假如token有效,这么服务端按照token获取用户的信息,并将用户信息储存到ThreadLocal容器。手机端和PC端的恳求都由该拦截器处理,如PC端的“查询用户信息”请求,手机端的“扫码”请求。因为我们忽视了手机端验证时所须要的的设备信息,因而PC端和手机端token可以使用同一套验证逻辑。

另一个拦截器用于拦截“确认登陆”请求,即验证一次性token是否有效:

@Component
public class ConfirmInterceptor implements HandlerInterceptor {
    @Autowired
    private CacheStore cacheStore;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String onceToken = request.getHeader("once_token");
        if (StringUtils.isEmpty(onceToken)) {
            return false;
        }
        if (StringUtils.isNoneEmpty(onceToken)) {
            String onceTokenKey = CommonUtil.buildOnceTokenKey(onceToken);
            String uuidFromCache = (String) cacheStore.get(onceTokenKey);
            String uuidFromRequest = request.getParameter("uuid");
            if (!StringUtils.equals(uuidFromCache, uuidFromRequest)) {
                throw new RuntimeException("非法的一次性 token");
            }
            // 一次性 token 检查完成后将其删除
            cacheStore.delete(onceTokenKey);
        }
        return true;
    }
}

该拦截器主要拦截“确认登陆”请求,须要注意的是,一次性token验证通过后要立刻将其删掉。

编码过程中,我们简化了许多操作,比如:1.忽视掉了手机端的设备信息;2.手机端确认登陆后并没有直接为用户生成PC端token,而是在协程时生成。

疗效演示

1.工具打算

2.数据打算

因为我们没有实现真实的手机端扫码的功能,因而使用Postman模仿手机端向服务端发送恳求。首先我们须要确保服务端储存着用户的信息,即在Test类中执行如下代码:

@Test
void insertUser() {
   User user = new User();
   user.setUserId("1");
   user.setUserName("John同学");
   user.setAvatar("/avatar.jpg");
   cacheStore.put("user:1", user);
}

手机端发送恳求时须要携带手机端token,这儿我们为useId为“1”的用户生成一个token(手机端token):

@Test
void loginByPhone() {
   String accessToken = CommonUtil.generateUUID();
   System.out.println(accessToken);
   cacheStore.put(CommonUtil.buildAccessTokenKey(accessToken), "1");
}

手机端token(accessToken)为“aae466837466837dd024602460246dd486486486ff644644644aa33bcfaabcfaabcfaa99ee1”(随机值),然后发送“扫码”请求时须要携带这个token。

3.扫码登陆流程展示

启动项目,访问localhost:8080/index:

点击登陆,并在开发者工具中找到二维码id(uuid):

打开Postman,发送localhost:8080/login/scan恳求,Query参数中携带uuid,Header中携带手机端token:

上述恳求返回“扫码成功”的响应,同时还返回了一次性token。此时PC端显示出扫码用户的头像:

在Postman中发送localhost:8080/login/confirm恳求,Query参数中携带uuid,Header中携带一次性token:

“确认登陆”请求发送完成后,PC端随后获取到PC端token,并成功查询用户信息:

结语

本文主要介绍了扫码登陆的原理,并实现了一个简易版扫码登陆的Demo。关于原理部份的理解错误以及代码中的不足之处欢迎你们批评见谅(⌒.-),源码见扫码登陆,假如认为有收获的话给个Star吧~。

参考文章

聊一聊二维码扫描登陆原理