Spring Boot 实现扫码登录
扫码登录是现代应用中常见的身份验证方式,本文将详细介绍基于 Spring Boot 3 的完整实现方案,包括二维码生成、状态管理、确认流程等核心环节。
一、Maven 依赖
1.1 核心依赖引入(pom.xml)
在项目的 pom.xml 文件中添加依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </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.5.3</version> </dependency> <dependency> <groupId>com.google.zxing</groupId> <artifactId>javase</artifactId> <version>3.5.3</version> </dependency>
|
二、配置文件
在 application.yml 或 application.properties 中配置Redis连接和二维码基础参数。
2.1 YAML 格式配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| spring: servlet: multipart: max-file-size: 100MB max-request-size: 100MB data: redis: host: localhost port: 6379 database: 0 timeout: 5000 password:
scan: login: qrcode: width: 200 height: 200 expire-time: 300 redis: key-prefix: 'scan_login:' state-key: 'scan_state:'
|
三、核心功能实现
3.1 枚举类(扫码状态)
定义扫码的4种状态,避免魔法值,后续判断状态更直观
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import lombok.AllArgsConstructor; import lombok.Getter;
@Getter @AllArgsConstructor public enum ScanLoginStatus { NOT_SCAN(0, "未扫码"), SCANNED(1, "已扫码,待确认"), CONFIRMED(2, "已确认登录"), EXPIRED(3, "二维码已过期");
private final Integer code; private final String desc;
public static ScanLoginStatus getByCode(Integer code) { for (ScanLoginStatus status : values()) { if (status.getCode().equals(code)) { return status; } } return NOT_SCAN; } }
|
3.2 二维码工具类
封装二维码生成逻辑,返回Base64字符串,方便前端直接渲染
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
| import com.google.zxing.BarcodeFormat; import com.google.zxing.EncodeHintType; import com.google.zxing.MultiFormatWriter; import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component;
import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.util.Base64; import java.util.HashMap; import java.util.Map;
@Component public class QrCodeUtil {
@Value("${scan.login.qrcode.width}") private Integer qrcodeWidth;
@Value("${scan.login.qrcode.height}") private Integer qrcodeHeight;
public String generateQrCodeBase64(String content) { if (StringUtils.isBlank(content)) { throw new IllegalArgumentException("二维码内容不能为空"); }
Map<EncodeHintType, Object> hints = new HashMap<>(); hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H); hints.put(EncodeHintType.MARGIN, 1);
try { MultiFormatWriter writer = new MultiFormatWriter(); BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, qrcodeWidth, qrcodeHeight, hints);
BufferedImage image = new BufferedImage(qrcodeWidth, qrcodeHeight, BufferedImage.TYPE_INT_RGB); for (int x = 0; x < qrcodeWidth; x++) { for (int y = 0; y < qrcodeHeight; y++) { image.setRGB(x, y, bitMatrix.get(x, y) ? Color.BLACK.getRGB() : Color.WHITE.getRGB()); } }
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ImageIO.write(image, "png", outputStream); byte[] imageBytes = outputStream.toByteArray(); return "data:image/png;base64," + Base64.getEncoder().encodeToString(imageBytes);
} catch (Exception e) { throw new RuntimeException("二维码生成失败", e); } } }
|
3.3 核心接口实现(Controller)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141
| import cn.iocoder.boot.entity.ResultVo; import cn.iocoder.boot.entity.ScanLoginStatus; import cn.iocoder.boot.utils.QrCodeUtil; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.bind.annotation.*;
import java.util.UUID; import java.util.concurrent.TimeUnit;
@RestController @RequestMapping("/api/scan/login") @Slf4j public class ScanLoginController {
@Resource private QrCodeUtil qrCodeUtil;
@Resource private StringRedisTemplate stringRedisTemplate;
@Value("${scan.login.qrcode.expire-time}") private Integer expireTime;
@Value("${scan.login.redis.key-prefix}") private String redisKeyPrefix;
@Value("${scan.login.redis.state-key}") private String stateKeyPrefix;
@GetMapping("/qrcode") public ResultVo generateLoginQrCode() { String uuid = UUID.randomUUID().toString().replace("-", ""); String qrCodeContent = uuid; String qrCodeBase64 = qrCodeUtil.generateQrCodeBase64(qrCodeContent);
String redisKey = redisKeyPrefix + uuid; stringRedisTemplate.opsForValue().set(redisKey, ScanLoginStatus.NOT_SCAN.getCode().toString(), expireTime, TimeUnit.SECONDS);
return ResultVo.success() .put("qrCodeBase64", qrCodeBase64) .put("uuid", uuid); }
@GetMapping("/state") public ResultVo queryScanState(@RequestParam String uuid) { if (StringUtils.isBlank(uuid)) { return ResultVo.error("临时凭证不能为空"); }
String redisKey = redisKeyPrefix + uuid; String statusCode = stringRedisTemplate.opsForValue().get(redisKey);
if (StringUtils.isBlank(statusCode)) { return ResultVo.success() .put("status", ScanLoginStatus.EXPIRED.getCode()) .put("desc", ScanLoginStatus.EXPIRED.getDesc()); }
ScanLoginStatus status = ScanLoginStatus.getByCode(Integer.parseInt(statusCode)); if (status == ScanLoginStatus.CONFIRMED) { String loginToken = generateLoginToken(uuid); stringRedisTemplate.delete(redisKey); return ResultVo.success() .put("status", status.getCode()) .put("desc", status.getDesc()) .put("token", loginToken); }
return ResultVo.success() .put("status", status.getCode()) .put("desc", status.getDesc()); }
@PostMapping("/confirm") public ResultVo confirmLogin(@RequestParam String uuid, @RequestParam Long userId) { if (StringUtils.isBlank(uuid) || userId == null) { return ResultVo.error("参数不完整"); }
String redisKey = redisKeyPrefix + uuid; String statusCode = stringRedisTemplate.opsForValue().get(redisKey);
if (StringUtils.isBlank(statusCode)) { return ResultVo.error("二维码已过期,请重新生成"); }
ScanLoginStatus status = ScanLoginStatus.getByCode(Integer.parseInt(statusCode)); if (status == ScanLoginStatus.NOT_SCAN || status == ScanLoginStatus.SCANNED) { stringRedisTemplate.opsForValue().set(redisKey, ScanLoginStatus.CONFIRMED.getCode().toString()); log.info("用户{}扫码确认登录,临时凭证:{}", userId, uuid); return ResultVo.success("确认登录成功"); }
return ResultVo.error("当前状态无法确认登录"); }
private String generateLoginToken(String uuid) { return "scan_login_" + uuid + "_" + System.currentTimeMillis(); } }
|
3.4 通用返回类(ResultVo)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| import lombok.Data; import java.util.HashMap; import java.util.Map;
@Data public class ResultVo { private Integer code; private String msg; private Map<String, Object> data = new HashMap<>();
public static ResultVo success() { return success("操作成功"); }
public static ResultVo success(String msg) { ResultVo resultVo = new ResultVo(); resultVo.setCode(0); resultVo.setMsg(msg); return resultVo; }
public static ResultVo error(String msg) { ResultVo resultVo = new ResultVo(); resultVo.setCode(1); resultVo.setMsg(msg); return resultVo; }
public ResultVo put(String key, Object value) { this.data.put(key, value); return this; } }
|
四、前端配合实现
4.1 PC 端实现流程
二维码渲染:页面初始化后,调用后端 /api/scan/login/qrcode 接口,获取二维码 Base64 字符串和临时凭证 uuid,直接渲染到页面即可,无需额外处理二维码生成逻辑。
状态轮询:获取 uuid 后,以 1-2 秒为间隔,调用后端 /api/scan/login/state 接口,传入 uuid 查询扫码状态,同步展示状态提示(未扫码、待确认、已登录、已过期)。
结果处理:若查询到状态为”已确认登录”,保存后端返回的 token 到本地(localStorage/SessionStorage),立即停止轮询并跳转至系统首页;若状态为”二维码已过期”,显示已过期,允许用户重新调用二维码生成接口刷新。
4.2 手机端扫码确认流程
手机端用户在扫码后,解析二维码内容(即 uuid),调用后端 /api/scan/login/confirm 接口,传入 uuid 和当前登录用户 ID,完成确认操作。此时 PC 端会收到更新通知,完成登录流程。