Java SpringBoot实现简易扫码登录流程 附项目代码
先总结流程:
- 移动端请求扫码登录,服务端生成二维码并缓存二维码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 {
@Autowired
private 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
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?