SpringBoot扫码登录实现

在移动互联网时代,扫码登录已成为Web应用不可或缺的登录方式。

本文基于SpringBoot框架实现了一个完整的扫码登录系统DEMO。

一、扫码登录原理

扫码登录的基本流程如下:

  1. Web端向服务器请求生成唯一二维码
  2. 服务器生成二维码图片并返回
  3. 用户通过手机App扫描该二维码
  4. 手机App发送确认请求到服务器
  5. 服务器通知Web端登录成功
  6. 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后端服务需要与移动设备在同一个局域网下

  1. 打开浏览器,访问 http://192.168.1.101:8080 进入Web端登录页面
  2. 在另一个浏览器窗口或标签页中打开 http://192.168.1.101:8080/mobile.html 模拟移动端App,或者使用移动设备扫码二维码
  3. 在移动端页面中输入二维码ID或直接点击Web端页面提供的链接
  4. 按照界面提示完成扫码登录流程

六、扫码登录流程详解

整个扫码登录的流程如下:

6.1 二维码生成阶段

  1. 用户打开Web登录页面
  2. 前端请求后端生成唯一的二维码ID
  3. 后端生成二维码ID,初始状态为"等待扫描"
  4. 后端将二维码ID及状态存储到Redis
  5. 后端生成包含二维码ID的二维码图片并返回给前端
  6. 前端建立WebSocket连接,准备接收状态更新

6.2 扫描确认阶段

  1. 用户通过移动端App扫描二维码,获取二维码ID
  2. 移动端发送扫描请求到服务端
  3. 服务端更新二维码状态为"已扫描"
  4. 服务端通过WebSocket推送状态变更到Web端
  5. Web端更新UI显示"已扫描"状态
  6. 移动端显示用户选择界面
  7. 用户在移动端选择要登录的账号并确认

6.3 登录完成阶段

  1. 移动端发送确认登录请求到服务端
  2. 服务端验证二维码状态,生成用户令牌
  3. 服务端更新二维码状态为"已确认",并附带用户信息
  4. 服务端通过WebSocket推送登录成功信息到Web端
  5. Web端接收到登录成功消息,获取用户信息
  6. Web端完成登录流程,显示用户信息
  7. 移动端显示登录成功界面

七、安全性考虑

实际生产环境中,还需要考虑以下安全因素

7.1 二维码安全

  • 短期有效:二维码应设置较短的有效期,本例中设置为300秒
  • 一次性使用:登录成功后立即使二维码失效
  • 状态验证:严格检查二维码状态的转换合法性
  • 防止遍历攻击:使用足够长的随机UUID,避免被暴力破解

7.2 通信安全

  • HTTPS:生产环境必须启用HTTPS加密传输
  • WebSocket安全:考虑为WebSocket连接添加认证机制
  • 防重放攻击:添加时间戳和nonce值防止请求重放
  • 跨站点请求伪造(CSRF)防护:添加CSRF令牌验证

7.3 用户信息安全

  • 敏感信息加密:Redis中存储的用户信息应该加密
  • 令牌管理:实现完善的令牌生成、验证和过期机制
  • 登录通知:当用户完成扫码登录时,向用户发送登录通知
  • 异常监测:监测异常登录行为,如短时间内多次扫码
相关推荐
数据潜水员1 小时前
C#基础语法
java·jvm·算法
你这个代码我看不懂2 小时前
Java项目OOM排查
java·开发语言
Zong_09152 小时前
AutoCompose - 携程自动编排【开源】
java·spring boot·开源·自动编排
烛阴2 小时前
自动化测试、前后端mock数据量产利器:Chance.js深度教程
前端·javascript·后端
.生产的驴2 小时前
SpringCloud 分布式锁Redisson锁的重入性与看门狗机制 高并发 可重入
java·分布式·后端·spring·spring cloud·信息可视化·tomcat
虾球xz3 小时前
CppCon 2014 学习:C++ Memory Model Meets High-Update-Rate Data Structures
java·开发语言·c++·学习
攒了一袋星辰3 小时前
Spring @Autowired自动装配的实现机制
java·后端·spring
我的golang之路果然有问题3 小时前
快速了解GO+ElasticSearch
开发语言·经验分享·笔记·后端·elasticsearch·golang
Bug缔造者3 小时前
若依+vue2实现模拟登录
java·前端框架
麦兜*3 小时前
【后端架构师的发展路线】
java·spring boot·spring·spring cloud·kafka·tomcat·hibernate