javaweb扫码登录,java扫码功能

  javaweb扫码登录,java扫码功能

  00-1010基本入门原理分析1。身份认证机制2。过程概述代码实现1。环境准备2。主要依赖3。二维码第四代。二维码扫描5。登录确认6。PC轮询7。拦截器配置效果演示1。工具准备2。资料准备3。代码扫描登录过程演示结论。

  00-1010相信大家对二维码都很熟悉。生活中充满了扫码登录的场景,比如登录网页版的微信和支付宝。最近学习了扫码登录的原理,挺有意思的,就实现了一个简单版的扫码登录的Demo来记录学习过程。

  其实面试的时候有人问我。

  

目录

 

  00-1010在介绍扫码登录原理之前,先说一下服务器的认证机制。以普通账号密码登录方式为例。服务器收到用户的登录请求后,首先验证账号和密码的合法性。如果认证成功,服务器会给用户分配一个令牌,这个令牌与用户的身份信息相关联,可以作为用户的登录凭证。当PC再次发送请求时,需要在请求的头或查询参数中携带令牌,服务器可以根据令牌识别当前用户。Token的优点是更加方便和安全,它降低了账号密码被劫持的风险,用户不需要重复输入账号和密码。通过账号和密码登录PC的过程如下:

  扫码登录本质上是一种认证方式。密码登录和密码登录的区别在于,前者使用PC的账号和密码为PC申请一个令牌,后者使用手机的令牌设备信息为PC申请一个令牌。这两种登录方式的目的是一样的,都是为了让PC获得服务器的‘授权’。在为PC申请令牌之前,两者都需要向服务器证明自己的身份,即服务器必须知道当前用户是谁,这样服务器才能为PC生成令牌。因为手机在扫码前必须处于登录状态,所以手机本身保存了一个令牌,可以用来识别服务器。那么为什么手机在验证身份时需要设备信息呢?实际上,手机上的身份验证与PC上的略有不同:

  手机在登录前也需要输入账号和密码,但是登录请求中除了账号和密码之外还包含设备信息,比如设备类型、设备id等。收到登录请求后,服务器将验证帐户和密码。验证通过后,用户信息将与设备信息相关联,即它们将被存储在一个数据结构中。服务器为手机生成一个令牌,将令牌与用户信息和设备信息关联起来,即以令牌为键,以结构为值,持久化地将键-值对保存在本地,然后将令牌返回给手机。手机发送带有令牌和设备信息的请求,服务器根据令牌查询结构,验证结构中的设备信息是否与手机相同,从而判断用户的合法性。我们在PC上登录成功后,可以在短时间内正常浏览网页,但之后访问网站时又要重新登录。这是因为令牌有过期时间,更长的有效时间会增加令牌被劫持的风险。不过手机上好像很少出现这种问题。比如微信,成功登录后可以一直使用,即使关闭微信或者重启手机。这是因为设备信息是唯一的。即使令牌被劫持,由于设备信息不同,攻击者也无法向服务器证明自己的身份,大大提高了安全系数,因此令牌可以长期使用。通过账号密码登录手机的流程如下:

  00-1010了解了服务器的认证机制之后,再来说说扫码登录的全过程。以网页版微信为例。我们点击二维码登录PC后,浏览器页面会弹出一个二维码图片。这时候打开手机微信扫描二维码,PC会立刻显示‘扫码’。手机点击确认登录后,PC会显示‘登录成功’。

  在上面的过程中,服务器可以根据手机的操作来响应PC,那么服务器是如何将两者联系起来的呢?答案是通过‘二维码’,严格来说是通过二维码里的内容。使用二维码解码器扫描网页版微信的二维码,可以得到以下内容:

  从上图我们知道,二维码其实包含了一个网址。扫描二维码后,手机会根据网址向服务器发送请求。接下来,我们打开PC浏览器的开发者工具:

  可以看到,二维码显示出来后,PC并没有一直‘空闲’,它通过轮询的方式不断向服务器发送请求,从而知道手机操作的结果。这里,我们注意到PC发送的URL中有一个参数uuid,值为‘Adv-NP 1 fyw==’。这个uuid也存在于二维码包含的URL中。由此,我们可以推断出服务

  端在生成二维码之前会先生成一个二维码 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. 环境准备

JDK 1.8:项目使用 Java 语言编写。Maven:依赖管理。Redis:Redis 既作为数据库存储用户的身份信息(为了简化操作未使用 MySQL),也作为缓存存储二维码信息、token 信息等。

 

  

2. 主要依赖

SpringBoot:项目基本环境。Hutool:开源工具类,其中的 QrCodeUtil 可用于生成二维码图片。Thymeleaf:模板引擎,用于页面渲染。

 

  

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)@ResponseBodypublic 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(); }}

1.首先根据 uuid 查询 Redis 中存储的 LoginTicket 对象,然后检查二维码的状态是否为 "待扫描" 状态,如果是,那么将二维码的状态改为 "待确认" 状态。如果不是,那么该二维码已被扫描过,服务端提示用户 "二维码已失效"。我们规定,只允许第一个手机端能够扫描成功,加锁的目的是为了保证查询 + 修改操作的原子性,避免两个手机端同时扫码,且同时检测到二维码的状态为 "待扫描"。

 

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

  3.用户信息与二维码信息关联在一起后,服务端为手机端生成一个一次性 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)@ResponseBodypublic 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)@ResponseBodypublic 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 端在轮询时,请求的参数中需要携带二维码当前的状态。

 

  1.首先根据 uuid 查询出二维码的最新状态,并比较其是否与 currentStatus 相同。如果相同,那么当前线程进入阻塞状态,直到被唤醒或者超时。

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

  3.如果二维码状态为 "已确认",那么服务端为 PC 端生成一个 token,在之后的请求中,PC 端可通过该 token 表明自己的身份。

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

  实际上,加锁操作设计得不太合理,因为我们只设置了一把锁。因此对不同二维码的查询或修改操作都会抢占同一把锁。按理来说,不同二维码的操作之间应该是相互独立的,即使加锁,也应该是为每个二维码均配一把锁,但这样做代码会更加复杂,或许有其它更好的实现长轮询的方式?或者干脆直接短轮询。当然,也可以使用 WebSocket 实现长连接。

  

 

  

7. 拦截器配置

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

 

  

@Componentpublic 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 是否有效:

  

@Componentpublic 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. 工具准备

浏览器:PC 端操作Postman:模仿手机端操作。

 

  

2. 数据准备

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

 

  

@Testvoid 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):

 

  

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

手机端 token(accessToken)为 "aae466837d0246d486f644a3bcfaa9e1"(随机值),之后发送 "扫码" 请求时需要携带这个 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。关于原理部分的理解错误以及代码中的不足之处欢迎大家批评指正(⌒.-),源码见扫码登录

 

  以上就是基于Java实现扫码登录的示例代码的详细内容,更多关于Java扫码登录的资料请关注盛行IT其它相关文章!

郑重声明:本文由网友发布,不代表盛行IT的观点,版权归原作者所有,仅为传播更多信息之目的,如有侵权请联系,我们将第一时间修改或删除,多谢。

留言与评论(共有 条评论)
   
验证码: