扫码登录如何实现?从原理到实战

在日常开发中,扫码登录几乎成了 PC 端网站的标配------从微信、支付宝到各类企业管理后台,都支持用手机 App 一扫即登。作为后端开发者,我们不仅要会用,更要理解其背后的设计思想和实现细节。

本文将从零开始 ,带你完整实现一套扫码登录的后端服务,技术栈为 Spring Boot 3.x + Redis + JJWT 。全文分为原理篇实战篇,在实战篇中我会对每个核心代码片段进行详细讲解,让你不仅会复制代码,更懂其所以然。


一、原理篇:扫码登录的"三步走"

扫码登录的本质是 "已认证设备(手机)帮未认证设备(PC)完成身份验证" 。整个过程只需三个核心步骤:

  1. PC 展示二维码(包含一个临时令牌)
  2. 手机扫码并确认(携带手机端的登录态)
  3. 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-secondsjwt-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:如何防止暴力扫码?

  • scanconfirm 接口增加限流(如 IP 限流、用户限流),可使用 Guava RateLimiter 或 Sentinel。

六、总结

扫码登录的核心在于临时令牌 + 状态机 + 轮询/推送。通过本文的讲解和完整的 Spring Boot 3.x 代码实现,相信你已经掌握了从原理到落地的全过程。

实际生产开发中,你还可以根据业务需求扩展:

  • 增加扫码后推送消息(如短信、邮件通知)
  • 支持多端登录管理(记录设备信息)
  • 集成第三方登录(微信、支付宝扫码登录本质类似,但多了一层 OAuth2 协议)

最后,所有代码已整合成一个完整的 Spring Boot 3.x 项目,你只需修改 Redis 配置即可运行体验。如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区交流你的实现思路!

相关推荐
JRedisX8 小时前
JRedisX 项目骨架搭建指南 从零开始 — 手把手教你搭
java
zx1154508 小时前
Java 反射 (SpringBoot篇)
java·架构
2301_794461578 小时前
Activiti/BPMN 2.0 的 4 种网关
java·服务器·开发语言
她的男孩8 小时前
低代码只能做单表 CRUD?我们一行代码没写,搭了个完整进销存
java·后端·架构
147API8 小时前
Claude Tag 进入 Slack 后,团队智能体需要哪些任务与审计字段
java·开发语言·数据库
dyonggan9 小时前
IDEA从零搭建SpringCloud Alibaba完整工程
java·spring cloud·intellij-idea
代码雕刻家9 小时前
编程语法细节
java·c语言·开发语言
SimonKing9 小时前
Agnes AI出桌面版了,可图可视频,免费用
java·后端·程序员
左左右右左右摇晃9 小时前
手写Tomcat原理整理
java·开发语言·笔记·tomcat