扫码登录其实就是一个登录请求,只不过信息存储在用户手机上,还需要通过二维码验证是否匹配的方式就可以登录,免去了用户多次输入密码的场景,现在越来越多登录方式,其中扫码登录算是比较人性化的了
具体流程如下图:
Step 1、用户 A 访问网页客户端,服务器为这个会话生成一个全局唯一的 ID,此时系统并不知道访问者是谁。
Step 2、用户A打开自己的手机App并扫描这个二维码,并提示用户是否确认登录。
Step 3、手机上的是登录状态,用户点击确认登录后,手机上的客户端将账号和这个扫描得到的 ID 一起提交到服务器
Step 4、服务器将这个 ID 和用户 A 的账号绑定在一起,并通知网页版,这个 ID 对应的微信号为用户 A,网页版加载用户 A 的信息,至此,扫码登录全部流程完成
我们选取使用自己在服务器端根据创建的全局唯一id生成一个二维码,使用 google
的 zxing
二维码生成类库
<dependency> <groupId>com.google.zxing</groupId> <artifactId>javase</artifactId> <version>3.2.1</version> </dependency>复制代码
根据content内容和指定高度和宽度生成二维码的base64格式图片,可以直接在前端显示
public String createQrCode(String content, int width, int height) throws IOException { String resultImage = ""; if (!StringUtils.isEmpty(content)) { ServletOutputStream stream = null; ByteArrayOutputStream os = new ByteArrayOutputStream(); @SuppressWarnings("rawtypes") HashMap<EncodeHintType, Comparable> hints = new HashMap<>(); hints.put(EncodeHintType.CHARACTER_SET, "utf-8"); // 指定字符编码为“utf-8” hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); // 指定二维码的纠错等级为中级 hints.put(EncodeHintType.MARGIN, 2); // 设置图片的边距 try { QRCodeWriter writer = new QRCodeWriter(); BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, width, height, hints); BufferedImage bufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix); ImageIO.write(bufferedImage, "png", os); /** * 原生转码前面没有 data:image/png;base64 这些字段,返回给前端是无法被解析,可以让前端加,也可以在下面加上 */ resultImage = new String("data:image/png;base64," + Base64.encode(os.toByteArray())); return resultImage; } catch (Exception e) { e.printStackTrace(); } finally { if (stream != null) { stream.flush(); stream.close(); } } } return null; }复制代码
状态:
由于一张二维码只能被扫描一次,所以我们每一次扫描一张二维码后,把状态设置为 SCANNED
, SCANNED
状态的二维码无法再次被扫描,抛出已被扫描的信息
状态转移:
NOT_SCANNED->SCANNED->VERIFIED->FINISH
其中EXPIRED状态可以插在其中任意一个位置,过期了的二维码也自动过期
很多人问为什么不让前端传递扫描过后的uuid呢?第一,我们只能使用postman模拟请求,我们无法根据手机app扫码获取二维码信息,所以暂时采取传输图片,实际中肯定采用uuid去传输,因为base64本来就很大,尽量传输数据量小的数据
@GetMapping("/createQr") @ResponseBody public Result<String> createQrCode() throws IOException { String uuid = UUIDUtil.uuid(); log.info(uuid); String qrCode = qrCodeService.createQrCode(uuid,200,200); qrCodeMap.put(qrCode,uuid); redisService.set(QrCodeKey.UUID,uuid,QrCodeStatus.NOT_SCAN); return Result.success(qrCode); }复制代码
后端只需要处理app登录请求和确认请求以及网页端响应的请求就好了
获取到redis保存对应uuid的状态,返回给前端,前端轮询判断做处理
@GetMapping("/query") @ResponseBody public Result<String> queryIsScannedOrVerified(@RequestParam("img")String img){ String uuid = qrCodeMap.get(img); QrCodeStatus s = redisService.get(QrCodeKey.UUID, uuid, QrCodeStatus.class); return Result.success(s.getStatus()); }复制代码
app扫描二维码后,拿到对应的二维码信息发送一个扫描请求给后端,携带app用户参数,这里demo演示就模拟一个绝对的用户信息
**之后就是判断redis中uuid的状态,*
NOT_SCAN
,就修改为 SCANNED
SCANNED
,就返回重复扫描的错误 VERIFIED
,就完成本次二维码登录逻辑,用户登录成功 @GetMapping("/doScan") @ResponseBody public Result doAppScanQrCode(@RequestParam("username")String username, @RequestParam("password")String password, @RequestParam("uuid")String uuid){ QrCodeStatus status = redisService.get(QrCodeKey.UUID,uuid,QrCodeStatus.class); log.info(status.getStatus()); if(status.getStatus().isEmpty()) return Result.error(ErrorCodeEnum.UUID_EXPIRED); switch (status){ case NOT_SCAN: //等待确认 todo if(username.equals("dzou")&&password.equals("1234")){ redisService.set(QrCodeKey.UUID,uuid, QrCodeStatus.SCANNED); return Result.success("请手机确认"); }else{ return Result.error(ErrorCodeEnum.LOGIN_FAIL); } case SCANNED: return Result.error(ErrorCodeEnum.QRCODE_SCANNED); case VERIFIED: return Result.success("你已经确认过了"); } return Result.error(ErrorCodeEnum.SEVER_ERROR); }复制代码
app扫描成功后,二维码状态变为 SCANNED
,需要发送一个请求给app前端请求用户确认,用户点击确认后请求这个接口,完成登录
@GetMapping("/verify") @ResponseBody public Result verifyQrCode(@RequestParam("uuid")String uuid){ String status = redisService.get(QrCodeKey.UUID,uuid,String.class); if(status.isEmpty()) return Result.error(ErrorCodeEnum.UUID_EXPIRED); redisService.set(QrCodeKey.UUID,uuid,QrCodeStatus.VERIFIED); return Result.success("确认成功"); }复制代码
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>扫描二维码</title> <!-- jquery --> <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script> <!-- bootstrap --> <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}"/> <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script> </head> <body> <h1>二维码</h1> <div> <table> <tr> <td><img id="qrCode" width="200" height="200"/></td> </tr> </table> </div> </body> <script> var img = ""; $.ajax({ url: "/api/createQr", type:"GET", success:function (data) { $("#qrCode").attr("src",data.data); img = data.data; callbackScan($("#qrCode").attr("src")) } }); //使用setTimeOut来循环请求判断是否被扫描,被扫描以后调用下面一个函数循环判断是否被确认 function callbackScan(img) { var tID = setTimeout(function() { $.ajax({ url : '/api/query', dataType: "json", type: 'GET', data:{"img":img}, success : function(res) { //process data here console.log("img:"+img); console.log(res.data); if(res.data=="scanned") { clearTimeout(tID); console.log("请求确认") callbackVerify(img) }else { callbackScan(img) } } }) }, 1500); } //循环判断是否被确认 function callbackVerify(img) { var tID = setTimeout(function() { $.ajax({ url : '/api/query', dataType: "json", type: 'GET', data:{"img":img}, success : function(res) { //process data here console.log(res.data); if(res.data=="verified") { clearTimeout(tID); console.log("确认成功") window.location.href = "success"; }else { callbackVerify(img) } } }) }, 1500); } </script> </html>复制代码
除了轮询还有一种相对来说更好的实现方式就是WebSocket长连接,但是有些浏览器不支持WebSocket,考虑到这点我们决定使用 SockJs
,他是一种优先Websocket的连接方式,不支持的话它会去使用其他类似轮询的方式
我们使用Springboot提供的WebSocket支持类库编写,如果有需要使用netty编写的同学,可以参考我的另外一篇netty的文章
maven依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> <version>2.0.4.RELEASE</version> </dependency>复制代码
registerStompEndpoints
@Configuration @EnableWebSocketMessageBroker public class IWebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { //注册一个Stomp 协议的endpoint,并指定 SockJS协议 registry.addEndpoint("/websocket").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic"); //registry.setApplicationDestinationPrefixes("/app"); } }复制代码
@Autowired private SimpMessagingTemplate simpMessagingTemplate;复制代码
@GetMapping("/doScan") @ResponseBody public Result doAppScanQrCode(@RequestParam("username")String username, @RequestParam("password")String password, @RequestParam("uuid")String uuid){ QrCodeStatus status = redisService.get(QrCodeKey.UUID,uuid,QrCodeStatus.class); log.info( status.getStatus()); if(status.getStatus().isEmpty()) return Result.error(ErrorCodeEnum.UUID_EXPIRED); switch (status){ case NOT_SCAN: if(username.equals("dzou")&&password.equals("1234")){ redisService.set(QrCodeKey.UUID,uuid, QrCodeStatus.SCANNED); simpMessagingTemplate.convertAndSend("/topic/ws","请确认"); return Result.success("请手机确认"); }else{ return Result.error(ErrorCodeEnum.LOGIN_FAIL); } case SCANNED: return Result.error(ErrorCodeEnum.QRCODE_SCANNED); case VERIFIED: return Result.success("你已经确认过了"); } return Result.error(ErrorCodeEnum.SEVER_ERROR); }复制代码
调用 convertAndSend
发送指定消息到指定路由下
@GetMapping("/verify") @ResponseBody public Result verifyQrCode(@RequestParam("uuid")String uuid){ String status = redisService.get(QrCodeKey.UUID,uuid,String.class); if(status.isEmpty()) return Result.error(ErrorCodeEnum.UUID_EXPIRED); redisService.set(QrCodeKey.UUID,uuid,QrCodeStatus.VERIFIED); simpMessagingTemplate.convertAndSend("/topic/ws","已经确认"); return Result.success("确认成功"); }复制代码
function connect() { var socket = new SockJS('/websocket'); stompClient = Stomp.over(socket); stompClient.connect({}, function (frame) { console.log('Connected: ' + frame); stompClient.subscribe('/topic/ws', function (response) {//订阅路由消息 console.log(response); if(response.body=="请确认"){ layer.msg("请在你的app上确认登录") }else if(response.body=="已经确认"){ window.location.href = "success" } }); }); }复制代码