在日常开发中,扫码登录几乎成了 PC 端网站的标配------从微信、支付宝到各类企业管理后台,都支持用手机 App 一扫即登。作为后端开发者,我们不仅要会用,更要理解其背后的设计思想和实现细节。
本文将从零开始 ,带你完整实现一套扫码登录的后端服务,技术栈为 Spring Boot 3.x + Redis + JJWT 。全文分为原理篇 和实战篇,在实战篇中我会对每个核心代码片段进行详细讲解,让你不仅会复制代码,更懂其所以然。
一、原理篇:扫码登录的"三步走"
扫码登录的本质是 "已认证设备(手机)帮未认证设备(PC)完成身份验证" 。整个过程只需三个核心步骤:
- PC 展示二维码(包含一个临时令牌)
- 手机扫码并确认(携带手机端的登录态)
- PC 轮询获取登录凭证(完成登录)
为了更直观,我们画一张时序图:

二维码的生命周期管理
二维码对应的令牌(Token)在服务端有以下几种状态:
| 状态 | 说明 |
|---|---|
| waiting | 初始状态,等待手机扫码 |
| scanned | 手机已扫码,等待用户点击"确认" |
| confirmed | 用户已确认,PC 端可获取登录凭证 |
| expired | 二维码超时(如 2 分钟)或被主动删除 |
关键点:状态存储在 Redis 中,并设置 TTL(如 120 秒),超时自动清理,避免资源浪费。
二、实战篇:基于 Spring Boot 3.x + Redis 的实现
1. 技术选型与版本
| 组件 | 选型 | 版本 |
|---|---|---|
| 后端框架 | Spring Boot | 3.2.0 |
| JDK | Java | 17+ |
| 缓存 | Redis (Lettuce) | 随 Spring Boot 3.x |
| JWT 生成 | JJWT | 0.12.3 |
| 序列化 | Jackson | 随 Spring Boot 3.x |
注意 :Spring Boot 3.x 基于 Jakarta EE,
javax包已迁移至jakarta。但我们的代码未直接依赖 Servlet API,因此迁移几乎无感。
2. 项目结构
text
src/main/java/com/example/qrlogin/
├── QrLoginApplication.java # 启动类
├── config/
│ └── RedisConfig.java # Redis 配置
├── controller/
│ └── QrLoginController.java # REST API 控制器
├── service/
│ └── QrLoginService.java # 核心业务逻辑
├── dto/ # 请求/响应对象
│ ├── ApiResponse.java
│ ├── QrCreateResponse.java
│ ├── QrScanRequest.java
│ ├── QrConfirmRequest.java
│ └── QrStatusResponse.java
├── enums/
│ └── QrStatus.java # 状态枚举
└── exception/
└── GlobalExceptionHandler.java # 全局异常处理
3. 核心依赖(pom.xml)
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>qr-login</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<java.version>17</java.version>
<jjwt.version>0.12.3</jjwt.version>
</properties>
<dependencies>
<!-- Spring Boot Web 提供 REST API 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Redis 提供 RedisTemplate 操作 Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JJWT 用于生成和解析 JWT 登录凭证 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok 简化 POJO 代码(getter/setter 等) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
讲解 :这里使用了 Spring Boot 3.2.0 的 parent,因此所有依赖版本都已被管理。spring-boot-starter-data-redis 默认使用 Lettuce 客户端,性能优秀。JJWT 0.12.x 版本适配了新的 Java API,且签名方法更简洁。
4. 配置文件(application.yml)
yml
spring:
redis:
host: localhost
port: 6379
database: 0
timeout: 5000ms
qr:
login:
expire-seconds: 120 # 二维码有效期(秒)
jwt-secret: ${JWT_SECRET:change-me-in-production} # 从环境变量读取,提供默认值
jwt-expire-days: 7
讲解 :Redis 配置根据实际情况修改。jwt-secret 建议在生产环境通过环境变量注入,避免硬编码。expire-seconds 和 jwt-expire-days 分别控制二维码和 JWT 的有效时间。
5. 枚举与 DTO 定义
状态枚举
java
package com.example.qrlogin.enums;
public enum QrStatus {
WAITING, SCANNED, CONFIRMED, EXPIRED
}
讲解:这四种状态对应二维码的完整生命周期,清晰明了。
统一响应结构
java
package com.example.qrlogin.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "success", data);
}
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(true, message, data);
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, message, null);
}
}
讲解 :统一的前端返回格式,包含 success 标志、提示信息和数据体,方便前端统一处理。
请求/响应 DTO
java
// QrCreateResponse.java
@Data @NoArgsConstructor @AllArgsConstructor
public class QrCreateResponse {
private String token;
private Integer expireSeconds;
}
// QrScanRequest.java
@Data
public class QrScanRequest {
private String token;
private String userId;
}
// QrConfirmRequest.java
@Data
public class QrConfirmRequest {
private String token;
private String userId;
}
// QrStatusResponse.java
@Data @NoArgsConstructor @AllArgsConstructor
public class QrStatusResponse {
private String status;
private String message;
private String accessToken;
}
讲解 :DTO 严格按照接口需求定义,token 是二维码的唯一标识,userId 是手机端登录用户的 ID(实际生产可能用更复杂的用户标识)。
6. Redis 配置
java
package com.example.qrlogin.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 设置 Key 的序列化方式为 String,方便在 Redis 客户端查看
template.setKeySerializer(new StringRedisSerializer());
// 设置 Value 的序列化方式为 JSON,便于存储复杂对象
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// Hash 的 Key 和 Value 同样设置
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
讲解 :这里自定义了 RedisTemplate,统一使用 JSON 序列化,这样我们在存储哈希时可以直接放入 Map,Redis 会自动转为 JSON 字符串存储,取用时也能自动转回 Map 或对象,非常方便。
7. 核心 Service 实现(含详细注释)
java
package com.example.qrlogin.service;
import com.example.qrlogin.dto.QrCreateResponse;
import com.example.qrlogin.dto.QrStatusResponse;
import com.example.qrlogin.enums.QrStatus;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class QrLoginService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Value("${qr.login.expire-seconds:120}")
private int expireSeconds;
@Value("${qr.login.jwt-secret}")
private String jwtSecret;
@Value("${qr.login.jwt-expire-days:7}")
private int jwtExpireDays;
private static final String REDIS_KEY_PREFIX = "qrcode:";
// ---------- 1. 生成二维码 ----------
/**
* 为 PC 端生成一个新的二维码令牌。
* 生成一个 UUID 作为 token,存入 Redis,状态为 WAITING,并设置 TTL。
*/
public QrCreateResponse createQrCode() {
// 生成无横线的 UUID 作为 token
String token = UUID.randomUUID().toString().replace("-", "");
String key = REDIS_KEY_PREFIX + token;
// 用 Map 表示要存入 Redis Hash 的字段
Map<String, Object> hash = new HashMap<>();
hash.put("status", QrStatus.WAITING.name());
hash.put("userId", ""); // 初始为空
hash.put("createTime", System.currentTimeMillis());
// 存入 Redis
redisTemplate.opsForHash().putAll(key, hash);
// 设置过期时间
redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS);
return new QrCreateResponse(token, expireSeconds);
}
// ---------- 2. 手机扫码 ----------
/**
* 手机端扫码后调用,传入 token 和当前登录用户 ID。
* 检查 token 是否存在且状态为 WAITING,若是则更新为 SCANNED 并绑定 userId。
*/
public boolean scanQrCode(String token, String userId) {
String key = REDIS_KEY_PREFIX + token;
// 检查 key 是否存在
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
return false;
}
// 获取当前状态
String status = (String) redisTemplate.opsForHash().get(key, "status");
if (!QrStatus.WAITING.name().equals(status)) {
return false; // 只有 WAITING 状态才能被扫描
}
// 更新状态和 userId
redisTemplate.opsForHash().put(key, "status", QrStatus.SCANNED.name());
redisTemplate.opsForHash().put(key, "userId", userId);
// 刷新过期时间,给用户足够时间确认
redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS);
return true;
}
// ---------- 3. 手机确认 ----------
/**
* 手机端用户点击"确认登录"后调用。
* 校验 token 状态是否为 SCANNED,并且传入的 userId 与扫码时绑定的 userId 一致。
* 全部通过后生成 JWT,更新状态为 CONFIRMED,并将 JWT 存入 Redis(供 PC 轮询获取)。
*/
public String confirmQrCode(String token, String userId) {
String key = REDIS_KEY_PREFIX + token;
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
return null;
}
String status = (String) redisTemplate.opsForHash().get(key, "status");
if (!QrStatus.SCANNED.name().equals(status)) {
return null; // 必须是已扫描状态
}
String storedUserId = (String) redisTemplate.opsForHash().get(key, "userId");
if (!userId.equals(storedUserId)) {
return null; // 用户不匹配,防止恶意确认
}
// 生成 JWT 登录凭证
String accessToken = generateJwt(userId);
// 更新状态,并保存 accessToken
redisTemplate.opsForHash().put(key, "status", QrStatus.CONFIRMED.name());
redisTemplate.opsForHash().put(key, "accessToken", accessToken);
// 保留 60 秒供 PC 轮询获取,之后自动过期
redisTemplate.expire(key, 60, TimeUnit.SECONDS);
return accessToken;
}
// ---------- 4. PC轮询 ----------
/**
* PC 端定时调用此接口查询二维码状态。
* 根据状态返回不同的信息,如果是 CONFIRMED,则返回 accessToken 并删除二维码(一次性使用)。
*/
public QrStatusResponse pollStatus(String token) {
String key = REDIS_KEY_PREFIX + token;
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
return new QrStatusResponse(QrStatus.EXPIRED.name(), "二维码已过期", null);
}
String status = (String) redisTemplate.opsForHash().get(key, "status");
if (QrStatus.WAITING.name().equals(status)) {
return new QrStatusResponse(QrStatus.WAITING.name(), "等待扫码", null);
} else if (QrStatus.SCANNED.name().equals(status)) {
return new QrStatusResponse(QrStatus.SCANNED.name(), "已扫码,等待确认", null);
} else if (QrStatus.CONFIRMED.name().equals(status)) {
String accessToken = (String) redisTemplate.opsForHash().get(key, "accessToken");
// 返回后立即删除,确保一次性使用
redisTemplate.delete(key);
return new QrStatusResponse(QrStatus.CONFIRMED.name(), "登录成功", accessToken);
} else {
return new QrStatusResponse(QrStatus.EXPIRED.name(), "未知状态", null);
}
}
// ---------- 5. 刷新二维码 ----------
public QrCreateResponse refreshQrCode() {
// 直接复用生成逻辑
return createQrCode();
}
// ---------- 生成 JWT ----------
private String generateJwt(String userId) {
// 使用 HMAC-SHA256 密钥
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
long now = System.currentTimeMillis();
long exp = now + TimeUnit.DAYS.toMillis(jwtExpireDays);
return Jwts.builder()
.claim("userId", userId)
.claim("loginTime", now)
.issuedAt(new Date(now))
.expiration(new Date(exp))
.signWith(key) // 默认使用 HS256
.compact();
}
}
讲解:这个 Service 封装了所有核心业务逻辑。
- 使用 Redis Hash 存储二维码的多个属性,方便分别读写。
- 每个方法都做了必要的校验(存在性、状态、用户匹配),保证了流程的正确性和安全性。
- JWT 生成使用了 JJWT 的新 API,更加清晰。
- 在确认成功后,将二维码的存活时间缩短至 60 秒,既保证 PC 能及时获取 JWT,又不会长期占用 Redis 资源。
8. Controller 层
java
package com.example.qrlogin.controller;
import com.example.qrlogin.dto.*;
import com.example.qrlogin.service.QrLoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/qrcode")
public class QrLoginController {
@Autowired
private QrLoginService qrLoginService;
@PostMapping("/create")
public ApiResponse<QrCreateResponse> create() {
return ApiResponse.success(qrLoginService.createQrCode());
}
@PostMapping("/scan")
public ApiResponse<String> scan(@RequestBody QrScanRequest request) {
boolean ok = qrLoginService.scanQrCode(request.getToken(), request.getUserId());
return ok ? ApiResponse.success("扫码成功,请确认登录") : ApiResponse.error("二维码无效或已过期");
}
@PostMapping("/confirm")
public ApiResponse<String> confirm(@RequestBody QrConfirmRequest request) {
String accessToken = qrLoginService.confirmQrCode(request.getToken(), request.getUserId());
return accessToken != null ? ApiResponse.success("登录确认成功") : ApiResponse.error("确认失败");
}
@GetMapping("/status/{token}")
public ApiResponse<QrStatusResponse> pollStatus(@PathVariable String token) {
return ApiResponse.success(qrLoginService.pollStatus(token));
}
@PostMapping("/refresh")
public ApiResponse<QrCreateResponse> refresh() {
return ApiResponse.success(qrLoginService.refreshQrCode());
}
}
讲解 :Controller 层非常薄,只负责接收请求、调用 Service 并包装响应。每个接口都对应原理篇中的一个动作,命名清晰。注意 @RestController 和 @RequestMapping 的使用,所有接口统一前缀 /api/qrcode。
9. 启动类与全局异常处理
java
// QrLoginApplication.java
package com.example.qrlogin;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class QrLoginApplication {
public static void main(String[] args) {
SpringApplication.run(QrLoginApplication.class, args);
}
}
// GlobalExceptionHandler.java
package com.example.qrlogin.exception;
import com.example.qrlogin.dto.ApiResponse;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ApiResponse<?> handleException(Exception e) {
// 生产环境建议记录日志,并返回更友好的提示
return ApiResponse.error("服务器内部错误:" + e.getMessage());
}
}
讲解 :GlobalExceptionHandler 使用 @RestControllerAdvice 统一处理所有未捕获的异常,保证接口始终返回统一的 JSON 格式,避免前端收到不友好的错误堆栈。
三、API 接口一览
| 接口 | 方法 | 说明 | 请求体 |
|---|---|---|---|
/api/qrcode/create |
POST | PC 端获取二维码 Token | 无 |
/api/qrcode/scan |
POST | 手机端扫码 | {token, userId} |
/api/qrcode/confirm |
POST | 手机端确认 | {token, userId} |
/api/qrcode/status/{token} |
GET | PC 端轮询状态 | 无 |
/api/qrcode/refresh |
POST | 刷新二维码 | 无 |
讲解:这些接口构成了完整的扫码登录交互流程,顺序不能颠倒,状态转换由服务端控制。
四、安全性深度解析
扫码登录的安全性是开发时不可忽略的一环,以下是几个关键保障措施:
✅ 动态 Token & 一次性使用
- 每次生成的 Token 都是 UUID,随机且唯一。
- Token 在确认登录后立即从 Redis 删除,防止重放攻击。
✅ 短有效期
- 二维码 120 秒后自动过期(Redis TTL),即使被截图也无法长期有效。
✅ 双重令牌分离
- 二维码中的 Token 是临时凭证,仅用于本次登录交互。
- 登录成功颁发的是 JWT 登录凭证,用于后续 API 认证。二者互不干扰。
✅ 用户身份校验
- 手机扫码后,服务端记录
userId,确认时校验该用户与扫码时是否一致,避免"张冠李戴"。
✅ HTTPS 通信(生产环境必备)
- 所有接口应通过 HTTPS 传输,防止中间人窃取 Token。
五、优化方向 & 常见问题
Q1:轮询会不会给服务器造成压力?
- 可以设置合理的轮询间隔(如 2~3 秒),并配合长轮询 或 WebSocket 进一步降低请求量。对于中小型系统,普通轮询足够。
Q2:二维码刷新后旧码如何处理?
- 旧 Token 无需主动删除,Redis TTL 会自动过期。刷新只是生成一个新 Token,旧 Token 仍可被扫描,但状态可能为"已过期"(取决于是否超过 TTL)。
Q3:分布式部署时 Redis 怎么共享?
- 使用集中式 Redis(或 Redis Cluster),所有服务实例共用同一份数据即可。
Q4:如何防止暴力扫码?
- 对
scan和confirm接口增加限流(如 IP 限流、用户限流),可使用 Guava RateLimiter 或 Sentinel。
六、总结
扫码登录的核心在于临时令牌 + 状态机 + 轮询/推送。通过本文的讲解和完整的 Spring Boot 3.x 代码实现,相信你已经掌握了从原理到落地的全过程。
实际生产开发中,你还可以根据业务需求扩展:
- 增加扫码后推送消息(如短信、邮件通知)
- 支持多端登录管理(记录设备信息)
- 集成第三方登录(微信、支付宝扫码登录本质类似,但多了一层 OAuth2 协议)
最后,所有代码已整合成一个完整的 Spring Boot 3.x 项目,你只需修改 Redis 配置即可运行体验。如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区交流你的实现思路!