先总结流程:
- 移动端请求扫码登录,服务端生成二维码并缓存二维码ID和状态,将二维码的Base64格式返回给前端展示;
- PC端页面轮询检查二维码状态;
- 手机扫码后调用扫码接口,携带移动端的Token和二维码ID请求服务端,服务端根据扫码的信息生成临时Totken,将二维码状态更新为已扫码;
- PC端页面轮询检查二维码状态;
- 用户在移动端确认登录后,携带临时Token和二维码ID请求服务端,服务端校验临时Token,将二维码状态更新为已确认,并生成效期Token;
- 电脑端轮询到二维码状态为已确认后,获取效期Token登录成功;
先看简易流程图, 流程图planUML文件在文末的 Github 链接:
流程大概明白后,再看项目简易效果。
启动项目,进入登录界面:http://localhost:8080/login,控制台查看Network, generate 接口获取到了二维码和uuid等信息,同时页面以 uuid 轮询获取二维码最新状态
先使用postMan模拟扫码动作,请求:http://localhost:8080/api/qrcode/scan/2aa6dd82-96e9-4c13-a476-1fa07b913ae
再模拟确认登录,请求:localhost:8080/api/qrcode/confirm/2aa6dd82-96e9-4c13-a476-1fa07b913ae4
如果用户一直没扫码或扫码后一直没确认,二维码会自动提示过期:
再上代码:先创建一个项目,pom.xml 文件引入二维码以及Jwt扩展包:

<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>com.google.zxing</groupId><artifactId>core</artifactId><version>3.4.1</version></dependency><dependency><groupId>com.google.zxing</groupId><artifactId>javase</artifactId><version>3.4.1</version></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.11.5</version></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.11.5</version><scope>runtime</scope></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.11.5</version><scope>runtime</scope></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId><version>2.1.6.RELEASE</version></dependency><dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>2.0.28</version></dependency>
</dependencies>
登录页面controller:HomeController
package com.cnblog.qrcodeLogIn.controller;import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.ModelAndView;/*** @author AnYuan*/@Controller
@Slf4j
public class HomeController {@GetMapping("/login")public ModelAndView login() {log.info("用户进入登录页面");return new ModelAndView("login");}@GetMapping("/home")public ModelAndView home() {log.info("用户扫码登录成功");return new ModelAndView("home");}}
获取二维码生成、更新的controller: LoginController。为什么使用两个controller,是因为 HomeContrller 使用的是模板页面,使用的是 @Controller 注解声明的,而二维码功能接口是使用 @RestControlle注解声明的
package com.cnblog.qrcodeLogIn.controller;import com.alibaba.fastjson2.JSONObject;
import com.cnblog.qrcodeLogIn.dto.LoginInfoDTO;
import com.cnblog.qrcodeLogIn.vo.ResponseVO;
import com.cnblog.qrcodeLogIn.enums.LoginStatusEnum;
import com.cnblog.qrcodeLogIn.utils.JwtUtil;
import com.cnblog.qrcodeLogIn.utils.QRCodeUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;import java.util.UUID;
import java.util.concurrent.TimeUnit;/*** @author AnYuan*/@Slf4j
@RestController
@RequestMapping("/api/qrcode")
public class LoginController {@Autowiredprivate StringRedisTemplate stringRedisTemplate;private String cacheKey(String uuid) {return "users:login:" + uuid;}private void cache(LoginInfoDTO loginInfoDTO) {// 获取登录缓存信息,有效期2分钟stringRedisTemplate.opsForValue().set(cacheKey(loginInfoDTO.getUuid()), JSONObject.toJSONString(loginInfoDTO), 2, TimeUnit.MINUTES);}private LoginInfoDTO getCache(String uuid) {// 获取登录缓存信息String s = stringRedisTemplate.opsForValue().get(cacheKey(uuid));return s == null ? null : JSONObject.parseObject(s, LoginInfoDTO.class);}/*** 生成二维码* @return ResponseEntity* @throws Exception*/@GetMapping("/generate")public ResponseEntity<ResponseVO> generateQRCode() throws Exception {String uuid = UUID.randomUUID().toString();String base64QR = QRCodeUtil.generateQRCode(uuid, 200, 200);LoginInfoDTO loginInfoDTO = new LoginInfoDTO();loginInfoDTO.setStatus(LoginStatusEnum.UNSCANNED.name());loginInfoDTO.setUuid(uuid);// 二维码uuid绑定,存入缓存
cache(loginInfoDTO);// 返回生成的二维码信息ResponseVO vo = ResponseVO.builder().uuid(uuid).qrcode("data:image/png;base64," + base64QR).build();log.info("-------生成二维码成功:{}-------", uuid);return ResponseEntity.ok(vo);}/*** 检查扫码状态* @param uuid* @return*/@GetMapping("/check/{uuid}")public ResponseEntity<?> checkStatus(@PathVariable String uuid) {LoginInfoDTO loginInfoDTO = getCache(uuid);if (loginInfoDTO == null) {return ResponseEntity.status(410).body("二维码已过期");}String token = "";if (LoginStatusEnum.CONFIRMED.name().equals(loginInfoDTO.getStatus())) {token = JwtUtil.generateAuthToken(uuid);}ResponseVO vo = ResponseVO.builder().token(token).status(loginInfoDTO.getStatus()).build();log.info("-------校验二维码状态uuid:{}, 状态:{}-------", uuid, loginInfoDTO.getStatus());return ResponseEntity.ok(vo);}/*** 手机端确认登录* @param uuid* @return*/@PostMapping("/scan/{uuid}")public ResponseEntity<?> scanQrCode(@PathVariable String uuid) {LoginInfoDTO loginInfoDTO = getCache(uuid);loginInfoDTO.setStatus(LoginStatusEnum.SCANNED.name());cache(loginInfoDTO);log.info("-------扫码成功uuid:{}-------", uuid);return ResponseEntity.ok().build();}/*** 手机端确认登录* @param uuid* @return*/@PostMapping("/confirm/{uuid}")public ResponseEntity<?> confirm(@PathVariable String uuid) {LoginInfoDTO loginInfoDTO = getCache(uuid);loginInfoDTO.setStatus(LoginStatusEnum.CONFIRMED.name());cache(loginInfoDTO);log.info("-------确认登录成功uuid:{}-------", uuid);return ResponseEntity.ok().build();}
}
二维码状态枚举类:LoginStatusEnum
package com.cnblog.qrcodeLogIn.enums;import lombok.Getter;@Getter
public enum LoginStatusEnum {UNSCANNED("未扫描"),SCANNED("已扫描"),CONFIRMED("已确认");private String desc;LoginStatusEnum(String desc) {this.desc = desc;}
}
记录二维码状态存储到Redis的实体DTO:LoginInfoDTO
package com.cnblog.qrcodeLogIn.dto;import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginInfoDTO {/*** 唯一标识*/private String uuid;/*** 设备号*/private String device;/*** jwt令牌*/private String token;/*** 扫码状态*/private String status;
}
返回给前端页面是对象VO:ResponseVO
package com.cnblog.qrcodeLogIn.vo;import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResponseVO {/*** 唯一标识*/private String uuid;/*** 登录二维码*/private String qrcode;/*** jwt令牌*/private String token;/*** 扫码状态*/private String status;
}
生成Jwt令牌的工具类:
package com.cnblog.qrcodeLogIn.utils;import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;import java.nio.charset.StandardCharsets;
import java.util.Date;public class JwtUtil {private static final String SECRET_KEY = "9dad5e7e-bcb7-438f-b39e-ad8c67814915";public static String generateAuthToken(String uuid) {// 生成JWT或其他形式令牌return Jwts.builder().setSubject(uuid).setExpiration(new Date(System.currentTimeMillis() + 3600000)) // 1小时过期
.signWith(SignatureAlgorithm.HS256, SECRET_KEY.getBytes(StandardCharsets.UTF_8)).compact();}
}
生成二维码的工具类:
package com.cnblog.qrcodeLogIn.utils;import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.MultiFormatWriter;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.Hashtable;public class QRCodeUtil {public static String generateQRCode(String content, int width, int height)throws WriterException, IOException {Hashtable<EncodeHintType, Object> hints = new Hashtable<>();hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");BitMatrix matrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints);ByteArrayOutputStream outputStream = new ByteArrayOutputStream();MatrixToImageWriter.writeToStream(matrix, "PNG", outputStream);
return Base64.getEncoder().encodeToString(outputStream.toByteArray()); }
}
一个登录页面:login.html

<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>登录界面</title><style>body {font-family: Arial, sans-serif;background-color: #f4f4f4;display: flex;justify-content: center;align-items: center;height: 100vh;margin: 0;}.login-container {background-color: #fff;padding: 40px;border-radius: 10px;box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);width: 100%;max-width: 400px;text-align: center;}.login-container h2 {margin-bottom: 20px;color: #333;}.login-container .forgot-password {margin-top: 15px;display: block;color: #007bff;text-decoration: none;font-size: 14px;}.login-container .forgot-password:hover {text-decoration: underline;}.login-container .register-link {margin-top: 20px;font-size: 14px;color: #666;}.login-container .register-link a {color: #007bff;text-decoration: none;}.login-container .register-link a:hover {text-decoration: underline;}.blur-image {filter: blur(3px);}</style>
</head>
<body><div class="login-container"><h2>扫码登录</h2><div id="qrcode-container"><img id="qrcode-img" src="" alt="QR Code" class=""><div id="status"></div></div><a href="#" class="forgot-password" id="forgetPsw">忘记密码?</a><div class="register-link" id="signUp">还没有账号? <a href="#">立即注册</a></div>
</div>
<script>let currentUUID = null;let host = "http://localhost:8080";// 初始化二维码function initQRCode() {fetch(host+'/api/qrcode/generate').then(res => res.json()).then(data => {currentUUID = data.uuid;document.getElementById('qrcode-img').src = data.qrcode;startPolling();});}// 轮询检查状态function startPolling() {const interval = setInterval(() => {fetch(host+`/api/qrcode/check/${currentUUID}`).then(res => {if(res.status === 410) {clearInterval(interval);showStatus('二维码已过期,请刷新页面');return;}return res.json();}).then(data => {if(data.status === 'CONFIRMED') {clearInterval(interval);handleLoginSuccess(data.token);} else if(data.status === 'SCANNED') {showStatus('已扫描,请在手机上确认登录');}});}, 2000);}function handleLoginSuccess(token) {localStorage.setItem('authToken', token);window.location.href = '/home';}function showStatus(msg) {document.getElementById('status').innerHTML = msg;document.getElementById('forgetPsw').innerHTML="";document.getElementById('signUp').innerHTML="";document.getElementById('qrcode-img').className = 'blur-image';}// 初始化
initQRCode();
</script>
</body>
</html>
最后是扫码登录成功的跳转首页:home.html

<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>登录成功-首页</title><style>body {font-family: Arial, sans-serif;background-color: #f4f4f4;display: flex;justify-content: center;align-items: center;height: 100vh;margin: 0;}.login-container {background-color: #fff;padding: 40px;border-radius: 10px;box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);width: 100%;max-width: 400px;text-align: center;}.login-container h2 {margin-bottom: 20px;color: #333;}</style>
</head>
<body>
<div class="login-container"><h2>扫码登录成功</h2>
</div>
</body>
</html>
流程中生成临时token的操作没有细写,可根据流程图在对应的逻辑里添加即可。
完整项目结构:
src/main
└── java└── com└── cnblog└── qrcodeLogIn├── controller // 控制器├── dto // 实体类├── enums // 枚举类├── utils // 工具类├── vo // 响应类└── QrcodeLogInApplication.java
└── resources└── templates // html模板文件└── home.html└── login.html
本篇代码Github:https://github.com/Journeyerr/cnblogs/tree/master/qrcodeLogIn