网页扫码登录原理及过程

网页扫码登录原理及过程

现实中我们经常会用上扫码登录的方式,可以借助微信、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(进一步增强了安全性)

代码参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
@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()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 创建一个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。(获取该状态后可以提交之前,搞个过渡动画)

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 获取二维码的扫码状态
* @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端提示出来的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@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地址,这个时候你就可以做一道重定向到其他你想让用户看到的地址。
1
2
3
4
5
6
7
@RequestMapping("/qrcode/redirect")
public String redirect(HttpServletRequest request, HttpServletResponse response) throws IOException {
//判断user Agent 做相应的跳转
response.sendRedirect("http://www.boot.ren");

return null;
}

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