扫码登录
目前手机扫描二维码登录已成为一种主流的登录方式,尤其是在 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 应该如何保存呢?之后需要解决此问题;
- 未学习过前端,所以代码不怎么规范。