网页扫码登录的原理及实现过程

783 阅读5分钟
原文链接: boot.ren

网页扫码登录原理及过程

现实中我们经常会用上扫码登录的方式,可以借助微信、qq等oauth2认证方式,也可以通过APP
实现扫码登录。那么现在我们介绍下通过app实现扫码登录的一系列原理,及实现的过程。

扫码的过程分析

  1. 打开登录页面,展示一个二维码(web)
  2. 打开APP扫描该二维码后,APP显示确认、取消按钮(app)
  3. 这时候登录页面展示被扫描的用户头像等信息(web)
  4. 用户在APP上点击确认登录(app)
  5. 页面登录成功,并进入主应用程序页面(web)

原理分析

  • 页面展示的二维码内容(包含一个ticket或者叫token也行),在后台临时缓存中很重要
  • 该ticket是关联扫码人信息及后台用户库的关键

安全性分析

  • 该ticket是随机生成、可以是随机串、url等。只要保证其内容唯一即可。
  • 肯定是登录APP后才可以扫描,所以接收扫描结果的通道要对APP安全。防止第三方模拟请求(关键)。

实现过程

1. 开发一个登录页面,支持用户名、密码登录、同时可以切换成二维码登录

2. 当切换成二维码登录时,从后端接口获取一个ticket生成二维码,并且开始从后台轮训扫描结果

返回结果可以是单独ticket串,也可以是url(方便通过其他扫码工具扫描后转向公司宣传页面)

每隔1~2秒轮循环后台扫描结果。

3. 后端返回ticket的的接口过程

  • 创建一个2分钟有效的唯一ticket串(全局唯一随机码),可以借助redis缓存来做时效性控制。
  • 存储redis中键为ticket值,value默认为unknown表示等待扫描(app扫描会改变value)
  • 把生成的ticket放入session域中,这样轮训状态接口就可以不附带ticket(进一步增强了安全性)

代码参考:

@ResponseBody
@RequestMapping(value = "/qrcode/generate",method = RequestMethod.POST)
public Map<String,String> generateQrCode(HttpServletRequest request){

    String qrcode = qrcodeService.createQrcode();

    //二维码登录ticket 放入session中
    request.getSession().setAttribute(QRCODE_TICKET_KEY,qrcode);

    Map<String,String> map = new HashMap<>();
    map.put(QRCODE_TICKET_KEY,domainUrl+"ierp/qrcode/redirect?q="+qrcode);
    return map;
}

$qrcodeService#createQrcode()

/**
 * 创建一个2分钟有效的用来登录的二维码
 */
public String createQrcode(){

    String qrCodeTicket;
    while (true){
        qrCodeTicket = "sso"+RandomStringUtils.randomAlphanumeric(30);
        if(redisTemplate.hasKey(QRCODE_REDIS_KEY_PREFIX+qrCodeTicket)){
            continue;
        }
        //生成登录ticket对应的缓存key,2分钟有效 , key存在则有效,key不存在表示该ticket失效
        redisTemplate.boundValueOps(QRCODE_REDIS_KEY_PREFIX+qrCodeTicket).set("unknown",TIMEOUT, TimeUnit.MINUTES);
        break;
    }
    return qrCodeTicket;
}

4. 后端轮询扫描状态的接口详解

  • 页面轮询接口不需要向服务器传参,借助session域中的ticket值来向后端获取扫描结果
  • 二维码存在多种状态
    • 二维码超时,状态为-2,获取的value为空,提示无效的二维码。
    • 二维码待扫描,状态为-1,获取的value为初始值unknown。
    • 二维码扫描成功,未确认登录,状态为0,获取的value为包含用户信息的json串,confirm 为false。(获取该状态后可以展示扫描用户的头像)
    • 二维码扫描陈工,确认登录,状态为1,获取的value为包含用户信息的json串,confirm 为true。(获取该状态后可以提交之前,搞个过渡动画)

参考代码:

/**
 * 获取二维码的扫码状态
 * @param qrCodeTicket
 * @return
 */
public QrcodeStatus getQrcodeStatus(String qrCodeTicket){
    String value = redisTemplate.boundValueOps(QRCODE_REDIS_KEY_PREFIX + qrCodeTicket).get();
    if(value==null||"".equals(value)){
        return new QrcodeStatus(-2,"无效的二维码");
    }else if(value.equals("unknown")){
        return new QrcodeStatus(-1,"待扫描的二维码");
    }else{
        try {
            JsonNode jsonNode = objectMapper.readValue(value, JsonNode.class);
            String accountName = jsonNode.path("accountName").asText();
            String accountPic = jsonNode.path("accountPic").asText();
            boolean confirm = jsonNode.path("confirm").asBoolean();
            if(confirm){
                return new QrcodeStatus(1,accountName,accountPic,"确认登录");
            }else {
                return new QrcodeStatus(0,accountName,accountPic,"未确认登录,用来显示头像");
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return new QrcodeStatus(-3,"未知错误");
    }
}

5. APP扫描二维码及点击确认登录的后台接口(注意该接口只开放给app调用,本示例是后端api,其实前面还有一层app网关代理,已经做了相应的令牌校验、sign校验)

  • app扫描的前提必须已经登录。
  • app扫描网页二维码,向后台请求接口,传入用户标识、扫描状态(确认状态)、ticket值
  • 后台判断app提交过来的参数,并校验后,更新redis缓存中ticket的value(存入用户信息json及confirm状态)。
  • 校验失败的相关错误信息要在app端提示出来的。
@ApiOperation("扫码登录")
@RequestMapping(value = "/qrcode/login", method = RequestMethod.POST)
public AppResult qrLogin(
        @ApiParam(value = "手机号", required = true) @RequestParam("accountName") String accountName,
        @ApiParam(value = "账套编号", required = false) @RequestParam(value = "entCode",required = false) String entCode,
        @ApiParam(value = "个人头像url地址", required = false) @RequestParam(value = "accountPic", required = false) String accountPic,
        @ApiParam(value = "是否确认登录 传参: 0否 1是", required = true) @RequestParam("confirm") String confirm,
        @ApiParam(value = "二维码值", required = true) @RequestParam("ticket") String ticket) {
    try {
        if (ticket.contains("=")) {
            ticket = ticket.substring(ticket.indexOf("=") + 1, ticket.length());
        }

        if (ticket == null || !ticket.startsWith("sso")) {
            return error("fail", "无效的二维码");
        }

        String value = redisTemplate.boundValueOps(QRCODE_REDIS_KEY_PREFIX + ticket).get();
        if (value == null || "".equals(value)) {//无效的二维码
            return error("fail", "二维码过期");
        } else {
            if(!value.equals("unknown")){
                Map map = objectMapper.readValue(value, Map.class);
                String name = (String)map.get("accountName");
                if(name!=null&&!name.equals(accountName)){
                    return error("fail","二维码已经被他人使用");
                }
            }
            Map<String, Object> map = new HashMap<>();
            map.put("accountName", accountName);
            if (accountPic != null) {
                map.put("accountPic", accountPic);
            }
            if (entCode != null) {
                map.put("entCode", entCode);
            }
            map.put("confirm", confirm != null && confirm.equals("1"));
            redisTemplate.boundValueOps(QRCODE_REDIS_KEY_PREFIX + ticket).set(objectMapper.writeValueAsString(map), TIMEOUT, TimeUnit.MINUTES);
            return success(); //扫码后
        }
    } catch (Exception e) {
        log.error(e.getMessage(), e);
        return error("10006", e.getMessage());
    }
}

6. 用户在app端确认登录后,前端网页轮询到该结果,自动提交登录请求

  • 登录提交后,后台判断如果是如果二维码登录,从session域中获取ticket值,并从根据ticket获取redis缓存中的用户信息。
  • 得到用户信息后,就可以执行正常的登录校验流程。

7. 彩蛋:就是当生成的二维码是一个包含ticket值的url的时候。

  • app扫描该二维码,需要解析出ticket再执行扫描结果逻辑
  • 如果是其他app扫描该二维码,会访问该url地址,这个时候你就可以做一道重定向到其他你想让用户看到的地址。
@RequestMapping("/qrcode/redirect")
public String redirect(HttpServletRequest request, HttpServletResponse response) throws IOException {
    //判断user Agent 做相应的跳转
    response.sendRedirect("http://www.boot.ren");

    return null;
}

那么这个二维码就会即起到了扫码登陆的作用,也起到了扫描下载app,或者进入宣传网站的作用。可谓一举多得。