在移动互联网时代,扫码登录已成为Web应用不可或缺的登录方式。
本文基于SpringBoot框架实现了一个完整的扫码登录系统DEMO。
一、扫码登录原理
扫码登录的基本流程如下:
- Web端向服务器请求生成唯一二维码
- 服务器生成二维码图片并返回
- 用户通过手机App扫描该二维码
- 手机App发送确认请求到服务器
- 服务器通知Web端登录成功
- Web端完成登录流程
二、项目结构
css
qrcode-login/
├── src/main/java/com/example/qrcodelogin/
│ ├── QrcodeLoginApplication.java
│ ├── config/
│ │ ├── RedisConfig.java
│ │ └── WebSocketConfig.java
│ ├── controller/
│ │ ├── LoginController.java
│ │ └── QRCodeController.java
│ ├── model/
│ │ ├── QRCodeStatus.java
│ │ └── UserInfo.java
│ ├── service/
│ │ ├── QRCodeService.java
│ │ └── UserService.java
│ └── util/
│ └── JsonUtil.java
├── src/main/resources/
│ ├── application.properties
│ └── static/
│ ├── css/
│ │ ├── login.css
│ │ └── mobile.css
│ ├── index.html
│ └── mobile.html
└── pom.xml
三、后端实现
3.1 Maven依赖
首先,创建一个SpringBoot项目,并添加必要的依赖:
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>2.7.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>qrcode-login</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>qrcode-login</name>
<description>SpringBoot QR Code Login Demo</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- ZXing for QR Code generation -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.1</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
3.2 配置文件
在application.yaml
中添加配置:
yaml
spring:
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 5000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
# 二维码配置
qrcode:
# 二维码有效期(秒)
expire:
seconds: 300
width: 100
height: 100
3.3 主应用类
typescript
package com.example.qrcodelogin;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class QrcodeLoginApplication {
public static void main(String[] args) {
SpringApplication.run(QrcodeLoginApplication.class, args);
}
}
3.4 Redis配置
arduino
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
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.Jackson2JsonRedisSerializer;
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);
// 配置ObjectMapper,添加类型信息
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
// 使用Jackson2JsonRedisSerializer作为值的序列化器
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
// 设置key和value的序列化方式
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(jackson2JsonRedisSerializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
3.5 WebSocket配置
typescript
package com.example.qrcodelogin.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(qrCodeWebSocketHandler(), "/ws/qrcode")
.setAllowedOrigins("*");
}
@Bean
public QrCodeWebSocketHandler qrCodeWebSocketHandler() {
return new QrCodeWebSocketHandler();
}
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
3.6 WebSocket处理器
java
package com.example.qrcodelogin.config;
import com.example.qrcodelogin.util.JsonUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
public class QrCodeWebSocketHandler extends TextWebSocketHandler {
// 存储所有WebSocket连接,key为二维码ID
private static final Map<String, WebSocketSession> SESSIONS = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
log.info("WebSocket connection established: {}", session.getId());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
log.info("Received message: {}", payload);
Map<String, String> msgMap = JsonUtil.fromJson(payload, Map.class);
if (msgMap != null && msgMap.containsKey("qrCodeId")) {
String qrCodeId = msgMap.get("qrCodeId");
log.info("Client subscribed to QR code: {}", qrCodeId);
// 将会话与二维码ID关联
SESSIONS.put(qrCodeId, session);
// 发送确认消息
session.sendMessage(new TextMessage("{"type":"CONNECTED","message":"Connected to QR code: " + qrCodeId + ""}"));
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
log.info("WebSocket connection closed: {}, status: {}", session.getId(), status);
// 移除会话
SESSIONS.entrySet().removeIf(entry -> entry.getValue().getId().equals(session.getId()));
}
// 向指定二维码ID的客户端发送消息
public void sendMessage(String qrCodeId, Object message) {
WebSocketSession session = SESSIONS.get(qrCodeId);
if (session != null && session.isOpen()) {
try {
session.sendMessage(new TextMessage(JsonUtil.toJson(message)));
} catch (IOException e) {
log.error("Failed to send message to WebSocket client", e);
}
}
}
}
3.7 模型类
QRCodeStatus.java - 二维码状态类
arduino
package com.example.qrcodelogin.model;
import lombok.Data;
@Data
public class QRCodeStatus {
public static final String WAITING = "WAITING"; // 等待扫描
public static final String SCANNED = "SCANNED"; // 已扫描
public static final String CONFIRMED = "CONFIRMED"; // 已确认
public static final String CANCELLED = "CANCELLED"; // 已取消
public static final String EXPIRED = "EXPIRED"; // 已过期
private String qrCodeId; // 二维码ID
private String status; // 状态
private UserInfo userInfo; // 用户信息
private long createTime; // 创建时间
public QRCodeStatus() {
this.createTime = System.currentTimeMillis();
}
public QRCodeStatus(String qrCodeId, String status) {
this.qrCodeId = qrCodeId;
this.status = status;
this.createTime = System.currentTimeMillis();
}
}
UserInfo.java - 用户信息类
typescript
package com.example.qrcodelogin.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfo {
private String userId;
private String username;
private String avatar;
private String email;
private String token;
}
3.8 工具类
JsonUtil.java - JSON工具类
typescript
package com.example.qrcodelogin.util;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class JsonUtil {
private static final ObjectMapper objectMapper = new ObjectMapper();
public static String toJson(Object object) {
try {
return objectMapper.writeValueAsString(object);
} catch (JsonProcessingException e) {
log.error("Convert object to json failed", e);
return null;
}
}
public static <T> T fromJson(String json, Class<T> clazz) {
try {
return objectMapper.readValue(json, clazz);
} catch (JsonProcessingException e) {
log.error("Convert json to object failed", e);
return null;
}
}
}
3.9 QR码生成工具类
java
package com.example.qrcodelogin.util;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Slf4j
public class QRCodeUtil {
/**
* 生成二维码图片的字节数组
*/
public static byte[] generateQRCodeImage(String text, int width, int height) throws WriterException, IOException {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
hints.put(EncodeHintType.MARGIN, 2);
BitMatrix bitMatrix = qrCodeWriter.encode(text, BarcodeFormat.QR_CODE, width, height, hints);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(bitMatrix, "PNG", outputStream);
return outputStream.toByteArray();
}
}
3.10 服务类
QRCodeService.java - 二维码服务类
java
package com.example.qrcodelogin.service;
import com.example.qrcodelogin.config.QrCodeWebSocketHandler;
import com.example.qrcodelogin.model.QRCodeStatus;
import com.example.qrcodelogin.model.UserInfo;
import com.example.qrcodelogin.util.QRCodeUtil;
import com.google.zxing.WriterException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class QRCodeService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private QrCodeWebSocketHandler webSocketHandler;
@Value("${qrcode.expire.seconds}")
private long qrCodeExpireSeconds;
@Value("${qrcode.width}")
private int qrCodeWidth;
@Value("${qrcode.height}")
private int qrCodeHeight;
private static final String QR_CODE_PREFIX = "qrcode:";
/**
* 生成二维码
*/
public QRCodeStatus generateQRCode() {
String qrCodeId = UUID.randomUUID().toString();
QRCodeStatus qrCodeStatus = new QRCodeStatus(qrCodeId, QRCodeStatus.WAITING);
// 存储到Redis并设置过期时间
redisTemplate.opsForValue().set(QR_CODE_PREFIX + qrCodeId, qrCodeStatus, qrCodeExpireSeconds, TimeUnit.SECONDS);
return qrCodeStatus;
}
/**
* 生成二维码图片
*/
public byte[] generateQRCodeImage(String qrCodeId, String baseUrl) {
try {
// 构建二维码内容
String qrCodeContent = baseUrl + "/mobile.html?qrCodeId=" + qrCodeId;
// 生成二维码图片
return QRCodeUtil.generateQRCodeImage(qrCodeContent, qrCodeWidth, qrCodeHeight);
} catch (WriterException | IOException e) {
log.error("Failed to generate QR code image", e);
return null;
}
}
/**
* 获取二维码状态
*/
public QRCodeStatus getQRCodeStatus(String qrCodeId) {
Object obj = redisTemplate.opsForValue().get(QR_CODE_PREFIX + qrCodeId);
if (obj instanceof QRCodeStatus) {
return (QRCodeStatus) obj;
}
return null;
}
/**
* 更新二维码状态
*/
public boolean updateQRCodeStatus(String qrCodeId, String status) {
QRCodeStatus qrCodeStatus = getQRCodeStatus(qrCodeId);
if (qrCodeStatus == null) {
return false;
}
qrCodeStatus.setStatus(status);
redisTemplate.opsForValue().set(QR_CODE_PREFIX + qrCodeId, qrCodeStatus, qrCodeExpireSeconds, TimeUnit.SECONDS);
// 通过WebSocket发送状态更新
Map<String, Object> message = new HashMap<>();
message.put("type", "STATUS_CHANGE");
message.put("status", status);
webSocketHandler.sendMessage(qrCodeId, message);
return true;
}
/**
* 确认登录
*/
public boolean confirmLogin(String qrCodeId, UserInfo userInfo) {
QRCodeStatus qrCodeStatus = getQRCodeStatus(qrCodeId);
if (qrCodeStatus == null || !QRCodeStatus.SCANNED.equals(qrCodeStatus.getStatus())) {
return false;
}
qrCodeStatus.setStatus(QRCodeStatus.CONFIRMED);
qrCodeStatus.setUserInfo(userInfo);
redisTemplate.opsForValue().set(QR_CODE_PREFIX + qrCodeId, qrCodeStatus, qrCodeExpireSeconds, TimeUnit.SECONDS);
// 通过WebSocket发送状态更新
Map<String, Object> message = new HashMap<>();
message.put("type", "STATUS_CHANGE");
message.put("status", QRCodeStatus.CONFIRMED);
message.put("userInfo", userInfo);
webSocketHandler.sendMessage(qrCodeId, message);
return true;
}
/**
* 取消登录
*/
public boolean cancelLogin(String qrCodeId) {
QRCodeStatus qrCodeStatus = getQRCodeStatus(qrCodeId);
if (qrCodeStatus == null) {
return false;
}
qrCodeStatus.setStatus(QRCodeStatus.CANCELLED);
redisTemplate.opsForValue().set(QR_CODE_PREFIX + qrCodeId, qrCodeStatus, qrCodeExpireSeconds, TimeUnit.SECONDS);
// 通过WebSocket发送状态更新
Map<String, Object> message = new HashMap<>();
message.put("type", "STATUS_CHANGE");
message.put("status", QRCodeStatus.CANCELLED);
webSocketHandler.sendMessage(qrCodeId, message);
return true;
}
/**
* 定时检查并清理过期的二维码
*/
@Scheduled(fixedRate = 60000) // 每分钟执行一次
public void cleanExpiredQRCodes() {
long currentTime = System.currentTimeMillis();
long expireTime = currentTime - qrCodeExpireSeconds * 1000;
// 查找所有二维码记录
Set<String> keys = redisTemplate.keys(QR_CODE_PREFIX + "*");
if (keys == null || keys.isEmpty()) {
return;
}
for (String key : keys) {
Object obj = redisTemplate.opsForValue().get(key);
if (obj instanceof QRCodeStatus) {
QRCodeStatus status = (QRCodeStatus) obj;
// 检查创建时间是否超过过期时间
if (status.getCreateTime() < expireTime && !QRCodeStatus.EXPIRED.equals(status.getStatus())) {
status.setStatus(QRCodeStatus.EXPIRED);
redisTemplate.opsForValue().set(key, status, 60, TimeUnit.SECONDS); // 设置一个短的过期时间,让客户端有机会收到过期通知
// 发送过期通知
Map<String, Object> message = new HashMap<>();
message.put("type", "STATUS_CHANGE");
message.put("status", QRCodeStatus.EXPIRED);
webSocketHandler.sendMessage(status.getQrCodeId(), message);
log.info("QR code expired: {}", status.getQrCodeId());
}
}
}
}
}
UserService.java - 用户服务类
java
package com.example.qrcodelogin.service;
import com.example.qrcodelogin.model.UserInfo;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Service
public class UserService {
// 模拟用户数据库
private static final Map<String, UserInfo> USER_DB = new HashMap<>();
static {
// 添加一些测试用户
USER_DB.put("user1", new UserInfo(
"user1",
"张三",
"https://api.dicebear.com/7.x/avataaars/svg?seed=user1",
"[email protected]",
null
));
USER_DB.put("user2", new UserInfo(
"user2",
"李四",
"https://api.dicebear.com/7.x/avataaars/svg?seed=user2",
"[email protected]",
null
));
}
/**
* 获取所有用户
*/
public Map<String, UserInfo> getAllUsers() {
return USER_DB;
}
/**
* 模拟登录
*/
public UserInfo login(String userId) {
UserInfo userInfo = USER_DB.get(userId);
if (userInfo != null) {
// 生成一个新的token
String token = UUID.randomUUID().toString();
userInfo.setToken(token);
return userInfo;
}
return null;
}
/**
* 验证token
*/
public UserInfo validateToken(String token) {
// 简单模拟,实际应用中应该有更复杂的token验证逻辑
for (UserInfo user : USER_DB.values()) {
if (token != null && token.equals(user.getToken())) {
return user;
}
}
return null;
}
}
3.11 控制器类
QRCodeController.java - 二维码相关API
kotlin
package com.example.qrcodelogin.controller;
import com.example.qrcodelogin.model.QRCodeStatus;
import com.example.qrcodelogin.model.UserInfo;
import com.example.qrcodelogin.service.QRCodeService;
import com.example.qrcodelogin.service.UserService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/qrcode")
public class QRCodeController {
@Autowired
private QRCodeService qrCodeService;
@Autowired
private UserService userService;
/**
* 生成二维码
*/
@GetMapping("/generate")
public ResponseEntity<QRCodeStatus> generateQRCode() {
QRCodeStatus qrCodeStatus = qrCodeService.generateQRCode();
log.info("Generated QR code: {}", qrCodeStatus.getQrCodeId());
return ResponseEntity.ok(qrCodeStatus);
}
/**
* 获取二维码图片
*/
@GetMapping(value = "/image/{qrCodeId}", produces = MediaType.IMAGE_PNG_VALUE)
public ResponseEntity<byte[]> getQRCodeImage(@PathVariable String qrCodeId, HttpServletRequest request) {
// 获取基础URL
String baseUrl = request.getScheme() + "://" + request.getServerName();
if (request.getServerPort() != 80 && request.getServerPort() != 443) {
baseUrl += ":" + request.getServerPort();
}
byte[] qrCodeImage = qrCodeService.generateQRCodeImage(qrCodeId, baseUrl);
if (qrCodeImage != null) {
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.body(qrCodeImage);
} else {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
}
}
/**
* 扫描二维码
*/
@PostMapping("/scan")
public ResponseEntity<String> scanQRCode(@RequestBody Map<String, String> request) {
String qrCodeId = request.get("qrCodeId");
if (qrCodeId == null) {
return ResponseEntity.badRequest().body("QR code ID is required");
}
boolean updated = qrCodeService.updateQRCodeStatus(qrCodeId, QRCodeStatus.SCANNED);
if (!updated) {
return ResponseEntity.badRequest().body("Invalid QR code");
}
log.info("QR code scanned: {}", qrCodeId);
return ResponseEntity.ok("Scanned successfully");
}
/**
* 确认登录
*/
@PostMapping("/confirm")
public ResponseEntity<String> confirmLogin(@RequestBody ConfirmLoginRequest request) {
if (request.getQrCodeId() == null || request.getUserId() == null) {
return ResponseEntity.badRequest().body("QR code ID and user ID are required");
}
// 模拟用户登录
UserInfo userInfo = userService.login(request.getUserId());
if (userInfo == null) {
return ResponseEntity.badRequest().body("User not found");
}
boolean confirmed = qrCodeService.confirmLogin(request.getQrCodeId(), userInfo);
if (!confirmed) {
return ResponseEntity.badRequest().body("Invalid QR code or status");
}
log.info("Login confirmed: {}, user: {}", request.getQrCodeId(), request.getUserId());
return ResponseEntity.ok("Login confirmed successfully");
}
/**
* 取消登录
*/
@PostMapping("/cancel")
public ResponseEntity<String> cancelLogin(@RequestBody Map<String, String> request) {
String qrCodeId = request.get("qrCodeId");
if (qrCodeId == null) {
return ResponseEntity.badRequest().body("QR code ID is required");
}
boolean cancelled = qrCodeService.cancelLogin(qrCodeId);
if (!cancelled) {
return ResponseEntity.badRequest().body("Invalid QR code");
}
log.info("Login cancelled: {}", qrCodeId);
return ResponseEntity.ok("Login cancelled successfully");
}
/**
* 获取二维码状态
*/
@GetMapping("/status/{qrCodeId}")
public ResponseEntity<QRCodeStatus> getQRCodeStatus(@PathVariable String qrCodeId) {
QRCodeStatus qrCodeStatus = qrCodeService.getQRCodeStatus(qrCodeId);
if (qrCodeStatus == null) {
return ResponseEntity.badRequest().body(null);
}
return ResponseEntity.ok(qrCodeStatus);
}
@Data
public static class ConfirmLoginRequest {
private String qrCodeId;
private String userId;
}
}
LoginController.java - 登录相关API
kotlin
package com.example.qrcodelogin.controller;
import com.example.qrcodelogin.model.UserInfo;
import com.example.qrcodelogin.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/auth")
public class LoginController {
@Autowired
private UserService userService;
/**
* 验证token并获取用户信息
*/
@PostMapping("/validate")
public ResponseEntity<UserInfo> validateToken(@RequestBody Map<String, String> request) {
String token = request.get("token");
if (token == null) {
return ResponseEntity.badRequest().body(null);
}
UserInfo userInfo = userService.validateToken(token);
if (userInfo == null) {
return ResponseEntity.badRequest().body(null);
}
log.info("Token validated for user: {}", userInfo.getUsername());
return ResponseEntity.ok(userInfo);
}
/**
* 获取可用的测试用户列表 (仅用于演示)
*/
@GetMapping("/users")
public ResponseEntity<Map<String, UserInfo>> getTestUsers() {
return ResponseEntity.ok(userService.getAllUsers());
}
}
四、前端实现
4.1 Web端登录页面
在src/main/resources/static/index.html
中创建Web端登录页面:
xml
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>扫码登录示例</title>
<link rel="stylesheet" href="css/login.css">
</head>
<body>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h2>扫码登录</h2>
</div>
<div class="login-body">
<!-- 二维码区域 -->
<div id="qrcode-area" class="qrcode-area">
<div class="qrcode">
<img id="qrcode-img" src="" alt="二维码">
</div>
<div id="qrcode-tip" class="qrcode-tip">
请使用手机扫描二维码登录
</div>
</div>
<!-- 登录成功区域 -->
<div id="login-success" class="login-success" style="display: none;">
<div class="avatar">
<img id="user-avatar" src="" alt="头像">
</div>
<div class="welcome">
<h3 id="user-welcome">欢迎回来</h3>
<p id="user-email"></p>
</div>
<div class="logout">
<button id="logout-btn">退出登录</button>
</div>
</div>
</div>
<div class="login-footer">
<p>如果您没有移动端演示App,可以<a href="mobile.html" target="_blank">点击这里</a>打开移动端模拟页面</p>
</div>
</div>
<div class="login-info">
<h3>扫码登录演示系统</h3>
<p>这是一个基于SpringBoot + WebSocket的扫码登录演示系统。</p>
<p>技术栈:</p>
<ul>
<li>后端:SpringBoot + WebSocket + Redis</li>
<li>前端:纯原生HTML/JS</li>
</ul>
<p>演示流程:</p>
<ol>
<li>打开"移动端模拟页面"</li>
<li>在网页端显示二维码</li>
<li>使用移动端模拟页面扫描二维码</li>
<li>在移动端确认登录</li>
<li>网页端自动登录成功</li>
</ol>
</div>
</div>
<script>
// 全局变量
let qrCodeId = '';
let webSocket = null;
let refreshTimer = null;
// DOM元素
const qrcodeArea = document.getElementById('qrcode-area');
const qrcodeImg = document.getElementById('qrcode-img');
const qrcodeTip = document.getElementById('qrcode-tip');
const loginSuccess = document.getElementById('login-success');
const userAvatar = document.getElementById('user-avatar');
const userWelcome = document.getElementById('user-welcome');
const userEmail = document.getElementById('user-email');
const logoutBtn = document.getElementById('logout-btn');
// 页面加载时生成二维码
window.addEventListener('load', generateQRCode);
// 退出登录按钮事件
logoutBtn.addEventListener('click', logout);
// 生成二维码
async function generateQRCode() {
try {
const response = await fetch('/api/qrcode/generate');
if (!response.ok) {
throw new Error('Failed to generate QR code');
}
const data = await response.json();
qrCodeId = data.qrCodeId;
// 更新二维码图片
qrcodeImg.src = `/api/qrcode/image/${qrCodeId}`;
qrcodeTip.textContent = '请使用手机扫描二维码登录';
qrcodeTip.className = 'qrcode-tip';
// 显示二维码区域
qrcodeArea.style.display = 'block';
loginSuccess.style.display = 'none';
// 连接WebSocket
connectWebSocket();
// 设置自动刷新
if (refreshTimer) {
clearTimeout(refreshTimer);
}
refreshTimer = setTimeout(refreshQRCode, 120000); // 2分钟后自动刷新
} catch (error) {
console.error('Error generating QR code:', error);
qrcodeTip.textContent = '生成二维码失败,请刷新页面重试';
qrcodeTip.className = 'qrcode-tip expired';
}
}
// 刷新二维码
function refreshQRCode() {
// 断开WebSocket连接
if (webSocket) {
webSocket.close();
webSocket = null;
}
// 重新生成二维码
generateQRCode();
}
// 连接WebSocket
function connectWebSocket() {
// 关闭现有连接
if (webSocket) {
webSocket.close();
}
// 创建新连接
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws/qrcode`;
webSocket = new WebSocket(wsUrl);
webSocket.onopen = function() {
console.log('WebSocket connected');
// 发送订阅消息
const message = {
qrCodeId: qrCodeId
};
webSocket.send(JSON.stringify(message));
};
webSocket.onmessage = function(event) {
const message = JSON.parse(event.data);
console.log('Received message:', message);
// 处理状态变更消息
if (message.type === 'STATUS_CHANGE') {
handleStatusChange(message);
}
};
webSocket.onerror = function(error) {
console.error('WebSocket error:', error);
};
webSocket.onclose = function() {
console.log('WebSocket disconnected');
};
}
// 处理状态变更
function handleStatusChange(message) {
const status = message.status;
switch (status) {
case 'SCANNED':
qrcodeTip.textContent = '已扫描,请在手机上确认';
qrcodeTip.className = 'qrcode-tip scanned';
break;
case 'CONFIRMED':
if (message.userInfo) {
showLoginSuccess(message.userInfo);
// 清除自动刷新定时器
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
}
break;
case 'CANCELLED':
case 'EXPIRED':
qrcodeTip.textContent = '二维码已失效,请点击刷新';
qrcodeTip.className = 'qrcode-tip expired';
qrcodeTip.innerHTML = '二维码已失效,请<a href="javascript:void(0)" onclick="refreshQRCode()">刷新</a>';
break;
}
}
// 显示登录成功
function showLoginSuccess(userInfo) {
// 更新用户信息
userAvatar.src = userInfo.avatar;
userWelcome.textContent = `欢迎回来,${userInfo.username}`;
userEmail.textContent = userInfo.email;
// 隐藏二维码,显示登录成功
qrcodeArea.style.display = 'none';
loginSuccess.style.display = 'block';
// 存储用户信息到本地存储
localStorage.setItem('userInfo', JSON.stringify(userInfo));
// 关闭WebSocket连接
if (webSocket) {
webSocket.close();
webSocket = null;
}
}
// 退出登录
function logout() {
// 清除本地存储的用户信息
localStorage.removeItem('userInfo');
// 刷新二维码
refreshQRCode();
}
// 检查本地存储中是否有用户信息
(function checkLocalStorage() {
const storedUserInfo = localStorage.getItem('userInfo');
if (storedUserInfo) {
try {
const userInfo = JSON.parse(storedUserInfo);
showLoginSuccess(userInfo);
} catch (e) {
console.error('Failed to parse user info:', e);
localStorage.removeItem('userInfo');
}
}
})();
</script>
</body>
</html>
4.2 移动端模拟页面
在src/main/resources/static/mobile.html
中创建移动端模拟页面:
ini
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>移动端扫码登录</title>
<link rel="stylesheet" href="css/mobile.css">
</head>
<body>
<div class="mobile-container">
<div class="mobile-header">
<h2>移动端扫码登录</h2>
</div>
<!-- 扫码前 -->
<div id="scan-area" class="mobile-body">
<div class="scan-area">
<div class="scan-icon"></div>
<p>请使用摄像头扫描二维码</p>
<div class="scan-input">
<p>或直接输入二维码ID:</p>
<input type="text" id="qrcode-input" placeholder="请输入二维码ID">
<button id="scan-btn">确认</button>
</div>
</div>
</div>
<!-- 扫码后选择用户 -->
<div id="user-select-area" class="mobile-body" style="display: none;">
<div class="scan-result">
<h3>已扫描到二维码</h3>
<p id="scanned-qrcode-id"></p>
<div class="user-select">
<p>选择一个账号登录:</p>
<div id="user-list" class="user-list">
<!-- 用户列表将通过JS动态填充 -->
</div>
</div>
<div class="scan-actions">
<button id="cancel-scan-btn" class="cancel-btn">取消</button>
</div>
</div>
</div>
<!-- 确认登录 -->
<div id="login-confirm-area" class="mobile-body" style="display: none;">
<div class="login-confirm">
<div class="login-user">
<div class="user-avatar">
<img id="selected-user-avatar" src="" alt="头像">
</div>
<div class="user-info">
<h3 id="selected-user-name"></h3>
<p id="selected-user-email"></p>
</div>
</div>
<div class="confirm-tip">
<p>确认在网页端登录该账号?</p>
</div>
<div class="confirm-actions">
<button id="cancel-confirm-btn" class="cancel-btn">取消</button>
<button id="confirm-login-btn" class="confirm-btn">确认登录</button>
</div>
</div>
</div>
<!-- 登录成功 -->
<div id="login-success-area" class="mobile-body" style="display: none;">
<div class="login-success">
<div class="success-icon"></div>
<h3>登录成功</h3>
<p>您已成功在网页端登录账号</p>
<button id="reset-btn" class="reset-btn">返回</button>
</div>
</div>
<div class="mobile-footer">
<p>这是一个移动端App的模拟页面</p>
</div>
</div>
<script>
// DOM元素
const scanArea = document.getElementById('scan-area');
const userSelectArea = document.getElementById('user-select-area');
const loginConfirmArea = document.getElementById('login-confirm-area');
const loginSuccessArea = document.getElementById('login-success-area');
const qrcodeInput = document.getElementById('qrcode-input');
const scanBtn = document.getElementById('scan-btn');
const cancelScanBtn = document.getElementById('cancel-scan-btn');
const cancelConfirmBtn = document.getElementById('cancel-confirm-btn');
const confirmLoginBtn = document.getElementById('confirm-login-btn');
const resetBtn = document.getElementById('reset-btn');
const scannedQrcodeId = document.getElementById('scanned-qrcode-id');
const userList = document.getElementById('user-list');
const selectedUserAvatar = document.getElementById('selected-user-avatar');
const selectedUserName = document.getElementById('selected-user-name');
const selectedUserEmail = document.getElementById('selected-user-email');
// 全局变量
let currentQrCodeId = '';
let selectedUserId = '';
let availableUsers = {};
// 初始化
window.addEventListener('load', init);
// 按钮事件
scanBtn.addEventListener('click', () => scanQRCode(qrcodeInput.value));
cancelScanBtn.addEventListener('click', cancelScan);
cancelConfirmBtn.addEventListener('click', cancelConfirm);
confirmLoginBtn.addEventListener('click', confirmLogin);
resetBtn.addEventListener('click', resetAll);
// 初始化函数
function init() {
// 从URL获取二维码ID
const urlParams = new URLSearchParams(window.location.search);
const qrCodeId = urlParams.get('qrCodeId');
if (qrCodeId) {
scanQRCode(qrCodeId);
}
// 获取可用用户
fetchAvailableUsers();
}
// 获取可用用户
async function fetchAvailableUsers() {
try {
const response = await fetch('/api/auth/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
availableUsers = await response.json();
// 清空用户列表
userList.innerHTML = '';
// 添加用户到列表
for (const userId in availableUsers) {
const user = availableUsers[userId];
const userItem = document.createElement('div');
userItem.className = 'user-item';
userItem.addEventListener('click', () => selectUser(userId));
const userAvatar = document.createElement('div');
userAvatar.className = 'user-avatar';
const img = document.createElement('img');
img.src = user.avatar;
img.alt = user.username;
const userName = document.createElement('div');
userName.className = 'user-name';
userName.textContent = user.username;
userAvatar.appendChild(img);
userItem.appendChild(userAvatar);
userItem.appendChild(userName);
userList.appendChild(userItem);
}
} catch (error) {
console.error('Error fetching users:', error);
alert('获取用户列表失败,请刷新页面重试');
}
}
// 扫描二维码
async function scanQRCode(qrCodeId) {
if (!qrCodeId) {
alert('请输入二维码ID');
return;
}
try {
const response = await fetch('/api/qrcode/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ qrCodeId: qrCodeId })
});
if (!response.ok) {
throw new Error('Failed to scan QR code');
}
// 保存当前二维码ID
currentQrCodeId = qrCodeId;
// 显示扫描结果
scannedQrcodeId.textContent = `ID: ${qrCodeId}`;
// 切换界面
scanArea.style.display = 'none';
userSelectArea.style.display = 'block';
loginConfirmArea.style.display = 'none';
loginSuccessArea.style.display = 'none';
} catch (error) {
console.error('Error scanning QR code:', error);
alert('二维码无效或已过期');
}
}
// 选择用户
function selectUser(userId) {
selectedUserId = userId;
const user = availableUsers[userId];
// 更新选中用户信息
selectedUserAvatar.src = user.avatar;
selectedUserName.textContent = user.username;
selectedUserEmail.textContent = user.email;
// 切换界面
scanArea.style.display = 'none';
userSelectArea.style.display = 'none';
loginConfirmArea.style.display = 'block';
loginSuccessArea.style.display = 'none';
}
// 确认登录
async function confirmLogin() {
try {
const response = await fetch('/api/qrcode/confirm', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
qrCodeId: currentQrCodeId,
userId: selectedUserId
})
});
if (!response.ok) {
throw new Error('Failed to confirm login');
}
// 切换界面
scanArea.style.display = 'none';
userSelectArea.style.display = 'none';
loginConfirmArea.style.display = 'none';
loginSuccessArea.style.display = 'block';
} catch (error) {
console.error('Error confirming login:', error);
alert('确认登录失败,请重试');
}
}
// 取消扫描
async function cancelScan() {
try {
await fetch('/api/qrcode/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
qrCodeId: currentQrCodeId
})
});
} catch (error) {
console.error('Error cancelling scan:', error);
}
resetAll();
}
// 取消确认
function cancelConfirm() {
selectedUserId = '';
// 切换界面
scanArea.style.display = 'none';
userSelectArea.style.display = 'block';
loginConfirmArea.style.display = 'none';
loginSuccessArea.style.display = 'none';
}
// 重置所有状态
function resetAll() {
currentQrCodeId = '';
selectedUserId = '';
qrcodeInput.value = '';
// 切换界面
scanArea.style.display = 'block';
userSelectArea.style.display = 'none';
loginConfirmArea.style.display = 'none';
loginSuccessArea.style.display = 'none';
}
</script>
</body>
</html>
4.3 CSS样式文件
在src/main/resources/static/css/login.css
中添加Web端样式:
css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f0f2f5;
color: #333;
}
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.login-box {
flex: 1;
max-width: 400px;
background-color: #fff;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
padding: 40px;
display: flex;
flex-direction: column;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h2 {
font-size: 24px;
color: #333;
font-weight: 600;
}
.login-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.qrcode-area {
text-align: center;
}
.qrcode {
width: 210px;
height: 210px;
margin: 0 auto 20px;
padding: 5px;
border: 1px solid #e0e0e0;
border-radius: 10px;
overflow: hidden;
}
.qrcode img {
width: 100%;
height: 100%;
object-fit: contain;
}
.qrcode-tip {
font-size: 14px;
color: #666;
margin-top: 15px;
}
.qrcode-tip.scanned {
color: #1890ff;
}
.qrcode-tip.expired {
color: #ff4d4f;
}
.qrcode-tip a {
color: #1890ff;
text-decoration: none;
}
.login-success {
text-align: center;
}
.avatar {
width: 100px;
height: 100px;
margin: 0 auto 20px;
border-radius: 50%;
overflow: hidden;
border: 2px solid #1890ff;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.welcome h3 {
font-size: 20px;
margin-bottom: 5px;
color: #333;
}
.welcome p {
font-size: 14px;
color: #666;
margin-bottom: 20px;
}
.logout button {
background-color: #f0f0f0;
border: none;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
color: #333;
font-size: 14px;
transition: all 0.2s;
}
.logout button:hover {
background-color: #e0e0e0;
}
.login-footer {
margin-top: 30px;
text-align: center;
font-size: 13px;
color: #999;
}
.login-footer a {
color: #1890ff;
text-decoration: none;
}
.login-info {
flex: 1;
max-width: 400px;
margin-left: 20px;
padding: 40px;
background-color: #1890ff;
color: white;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.login-info h3 {
font-size: 22px;
margin-bottom: 20px;
}
.login-info p {
margin-bottom: 15px;
font-size: 15px;
}
.login-info ul, .login-info ol {
margin-left: 20px;
margin-bottom: 15px;
}
.login-info li {
margin-bottom: 8px;
}
@media (max-width: 768px) {
.login-container {
flex-direction: column;
padding: 0;
}
.login-box {
width: 100%;
max-width: none;
border-radius: 0;
}
.login-info {
width: 100%;
max-width: none;
margin-left: 0;
margin-top: 20px;
border-radius: 0;
}
}
在src/main/resources/static/css/mobile.css
中添加移动端样式:
css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f0f2f5;
color: #333;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.mobile-container {
width: 360px;
max-width: 100%;
background-color: #fff;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.mobile-header {
background-color: #1890ff;
color: white;
padding: 15px;
text-align: center;
}
.mobile-header h2 {
font-size: 18px;
font-weight: 500;
}
.mobile-body {
padding: 20px;
min-height: 400px;
display: flex;
flex-direction: column;
justify-content: center;
}
.scan-area {
text-align: center;
}
.scan-icon {
width: 120px;
height: 120px;
margin: 0 auto 20px;
background-color: #f0f0f0;
border-radius: 10px;
position: relative;
}
.scan-icon:before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
background-color: #1890ff;
border-radius: 50%;
opacity: 0.2;
}
.scan-icon:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
background-color: #1890ff;
border-radius: 50%;
}
.scan-area p {
margin-bottom: 20px;
color: #666;
}
.scan-input {
margin-top: 30px;
text-align: center;
}
.scan-input p {
font-size: 14px;
margin-bottom: 10px;
color: #999;
}
.scan-input input {
width: 100%;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
margin-bottom: 10px;
}
.scan-input button {
width: 100%;
padding: 10px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.scan-result h3 {
text-align: center;
margin-bottom: 10px;
color: #1890ff;
}
.scan-result p {
text-align: center;
margin-bottom: 20px;
color: #666;
word-break: break-all;
}
.user-select {
margin: 20px 0;
}
.user-select p {
text-align: center;
margin-bottom: 15px;
color: #333;
}
.user-list {
display: flex;
justify-content: center;
gap: 20px;
}
.user-item {
text-align: center;
cursor: pointer;
padding: 10px;
border-radius: 8px;
transition: all 0.2s;
}
.user-item:hover {
background-color: #f0f0f0;
}
.user-avatar {
width: 60px;
height: 60px;
margin: 0 auto 10px;
border-radius: 50%;
overflow: hidden;
border: 2px solid #e0e0e0;
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-name {
font-size: 14px;
color: #333;
}
.scan-actions {
margin-top: 30px;
text-align: center;
}
.cancel-btn {
padding: 8px 20px;
background-color: #f0f0f0;
border: none;
border-radius: 4px;
color: #333;
cursor: pointer;
}
.login-confirm {
text-align: center;
}
.login-user {
display: flex;
align-items: center;
padding: 15px;
background-color: #f9f9f9;
border-radius: 8px;
margin-bottom: 20px;
}
.login-user .user-avatar {
width: 50px;
height: 50px;
margin: 0 15px 0 0;
}
.login-user .user-info {
text-align: left;
}
.login-user .user-info h3 {
font-size: 16px;
margin-bottom: 5px;
}
.login-user .user-info p {
font-size: 13px;
color: #666;
}
.confirm-tip {
margin: 20px 0;
}
.confirm-tip p {
font-size: 16px;
color: #333;
}
.confirm-actions {
display: flex;
justify-content: space-between;
margin-top: 30px;
}
.confirm-btn {
flex: 1;
margin-left: 10px;
padding: 10px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.login-success {
text-align: center;
}
.success-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
background-color: #52c41a;
border-radius: 50%;
position: relative;
}
.success-icon:before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 20px;
border: 4px solid white;
border-top: none;
border-right: none;
transform: translate(-50%, -60%) rotate(-45deg);
}
.login-success h3 {
font-size: 20px;
color: #52c41a;
margin-bottom: 10px;
}
.login-success p {
color: #666;
margin-bottom: 30px;
}
.reset-btn {
padding: 8px 20px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.mobile-footer {
padding: 15px;
text-align: center;
border-top: 1px solid #f0f0f0;
}
.mobile-footer p {
font-size: 13px;
color: #999;
}
五、运行项目
完成上述代码实现后,可以按照以下步骤运行项目:
5.1 安装Redis
首先,确保你已经安装了Redis并启动服务。可以使用Docker快速启动Redis:
css
docker run --name redis -p 6379:6379 -d redis
5.2 构建并运行SpringBoot应用
bash
mvn clean package
java -jar target/qrcode-login-0.0.1-SNAPSHOT.jar
或者直接通过IDE运行QrcodeLoginApplication
类。
5.3 访问应用
注意,本DEMO后端服务需要与移动设备在同一个局域网下
- 打开浏览器,访问
http://192.168.1.101:8080
进入Web端登录页面 - 在另一个浏览器窗口或标签页中打开
http://192.168.1.101:8080/mobile.html
模拟移动端App,或者使用移动设备扫码二维码 - 在移动端页面中输入二维码ID或直接点击Web端页面提供的链接
- 按照界面提示完成扫码登录流程
六、扫码登录流程详解
整个扫码登录的流程如下:
6.1 二维码生成阶段
- 用户打开Web登录页面
- 前端请求后端生成唯一的二维码ID
- 后端生成二维码ID,初始状态为"等待扫描"
- 后端将二维码ID及状态存储到Redis
- 后端生成包含二维码ID的二维码图片并返回给前端
- 前端建立WebSocket连接,准备接收状态更新
6.2 扫描确认阶段
- 用户通过移动端App扫描二维码,获取二维码ID
- 移动端发送扫描请求到服务端
- 服务端更新二维码状态为"已扫描"
- 服务端通过WebSocket推送状态变更到Web端
- Web端更新UI显示"已扫描"状态
- 移动端显示用户选择界面
- 用户在移动端选择要登录的账号并确认
6.3 登录完成阶段
- 移动端发送确认登录请求到服务端
- 服务端验证二维码状态,生成用户令牌
- 服务端更新二维码状态为"已确认",并附带用户信息
- 服务端通过WebSocket推送登录成功信息到Web端
- Web端接收到登录成功消息,获取用户信息
- Web端完成登录流程,显示用户信息
- 移动端显示登录成功界面
七、安全性考虑
实际生产环境中,还需要考虑以下安全因素
7.1 二维码安全
- 短期有效:二维码应设置较短的有效期,本例中设置为300秒
- 一次性使用:登录成功后立即使二维码失效
- 状态验证:严格检查二维码状态的转换合法性
- 防止遍历攻击:使用足够长的随机UUID,避免被暴力破解
7.2 通信安全
- HTTPS:生产环境必须启用HTTPS加密传输
- WebSocket安全:考虑为WebSocket连接添加认证机制
- 防重放攻击:添加时间戳和nonce值防止请求重放
- 跨站点请求伪造(CSRF)防护:添加CSRF令牌验证
7.3 用户信息安全
- 敏感信息加密:Redis中存储的用户信息应该加密
- 令牌管理:实现完善的令牌生成、验证和过期机制
- 登录通知:当用户完成扫码登录时,向用户发送登录通知
- 异常监测:监测异常登录行为,如短时间内多次扫码