共计 6231 个字符,预计需要花费 16 分钟才能阅读完成。
扫码登录
目前手机扫描二维码登录已成为一种支流的登录形式,尤其是在 PC 网页端。最近学习了一下扫码登录的原理,感觉蛮乏味的,所以借鉴了网上的一些示例,实现了一个简略的扫码登录的 demo,以此记录一下学习过程。
原理解析
流程简述
- PC 端关上二维码登录页面 login.html;
-
login.html 调用后端接口 createQrCodeImg,该接口生成一个随机的 uuid,uuid 可看做是本页面的惟一标识,同时该接口还会创立一个 LoginTicket 对象,该对象中封装了如下信息:
- uuid:页面的惟一标识;
- userId:用户 id;
- status:扫码状态,0 示意期待扫码,1 示意期待确认,2 示意已确认。
- 将上述 uuid 作为 key、LoginTicket 对象作为 value 存储在 Redis 服务器中(或其余数据库),设置其过期工夫为 5 分钟,示意 5 分钟后二维码生效。
- 生成二维码图片,二维码中封装的信息为一个 URL,相似于 http://localhost:8080/login/s…。
- PC 端显示二维码;
- PC 端页面一直轮询(多久轮询一次自行设置)查看扫码的进度,即 LoginTicket 对象的状态。如果为 0 或为 1,持续轮询;如果为 2,进行轮询(已确认登录);
- 手机端扫描二维码;
- 手机端(携带用户的 token,该 token 为手机端 token)拜访二维码中的指标网址,手机端服务器首先验证 token 是否无效,如果无效则将 LoginTicket 对象的 status 更新为 1;
- 手机端服务器询问用户是否确认登录;
- 用户抉择确认登录,手机端服务器将 LoginTicket 对象的 status 更新为 2,并将 userId 设置为以后用户的 id;
- PC 端检测到用户确认登录后,为用户生成 token(此 token 为 PC 端的 token),并将 token 返回给前端;
- 前端获取到 token 后就能够执行其余操作。
流程图
总体流程如下:
实现
环境筹备
- JDK 1.8;
- maven 3.3.6;
- Springboot 2.xx;
- 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;
}
登录接口
- 获取二维码
@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/。
- 扫描二维码
@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,示意曾经扫码,期待确认。
- 确认登录
@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?)。
- 轮询
@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 同学 ”。
演示动图如下:
待改良
- 整个流程中应该存在手机端服务器和 PC 端服务器,但为了简化操作,咱们利用 PC 端模拟手机端,比方扫码和确认申请应该由手机端服务器解决,而程序中咱们间接在 PC 端拜访对应的 Controller;
- 查看扫码状态时采纳了轮询的形式,或者能够采纳 Websocket;
- “ 手机端 ” 验证 “token” 时,咱们应用 userId 来简化操作;
- 最初一步咱们将 token 返回给了前端,前端发送申请时,须要在 header 中寄存 token,但问题是 token 应该如何保留呢?之后须要解决此问题;
- 未学习过前端,所以代码不怎么标准。
欢送批评指正,源码见扫码登录