SpringBoot扫码登录实现_springboot配置扫描

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

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

一、扫码登录原理

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

  1. Web端向服务器请求生成唯一二维码
  2. 服务器生成二维码图片并返回
  3. 用户通过手机App扫描该二维码
  4. 手机App发送确认请求到服务器
  5. 服务器通知Web端登录成功
  6. Web端完成登录流程

二、项目结构 #技术分享

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 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>

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-web</artifactId>

</dependency>

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-websocket</artifactId>

</dependency>

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-data-redis</artifactId>

</dependency>

<dependency>

<groupId>com.fasterxml.jackson.core</groupId>

<artifactId>jackson-databind</artifactId>

</dependency>

<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>

<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 中添加配置:

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 主应用类

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配置

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 = new ObjectMapper(); objectMapper.activateDefaultTyping( objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY );

Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class); jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(jackson2JsonRedisSerializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(jackson2JsonRedisSerializer);

template.afterPropertiesSet(); return template; } }

C 3.5 WebSocket配置

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(); } }

C 3.6 WebSocket处理器

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 { 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); 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())); } 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 - 二维码状态类

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; 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 - 用户信息类

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 工具类

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码生成工具类

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 - 二维码服务类

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); 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); 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); 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); 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 - 用户服务类

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", "zhangsan@example.com", null )); USER_DB.put("user2", new UserInfo( "user2", "李四", "https://api.dicebear.com/7.x/avataaars/svg?seed=user2", "lisi@example.com", null )); } public Map<String, UserInfo> getAllUsers() { return USER_DB; } public UserInfo login(String userId) { UserInfo userInfo = USER_DB.get(userId); if (userInfo != null) { String token = UUID.randomUUID().toString(); userInfo.setToken(token); return userInfo; } return null; } public UserInfo validateToken(String token) { for (UserInfo user : USER_DB.values()) { if (token != null && token.equals(user.getToken())) { return user; } } return null; } }

3.11 控制器类

QRCodeController.java - 二维码相关 API

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) { 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

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; @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 端登录页面:

<!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; 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'; connectWebSocket(); if (refreshTimer) { clearTimeout(refreshTimer); } refreshTimer = setTimeout(refreshQRCode, 120000); } catch (error) { console.error('Error generating QR code:', error); qrcodeTip.textContent = '生成二维码失败,请刷新页面重试'; qrcodeTip.className = 'qrcode-tip expired'; } } function refreshQRCode() { if (webSocket) { webSocket.close(); webSocket = null; } generateQRCode(); } 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)); 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
中创建移动端模拟页面:

<!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>

C 4.3 CSS样式文件


src/main/resources/static/css/login.css
中添加 Web 端样式:

* {
    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
中添加移动端样式:

* {
    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:

docker run --name redis -p 6379:6379 -d redis

5.2 构建并运行SpringBoot应用

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中存储的用户信息应该加密
  • 令牌管理 :实现完善的令牌生成、验证和过期机制
  • 登录通知 :当用户完成扫码登录时,向用户发送登录通知
  • 异常监测 :监测异常登录行为,如短时间内多次扫码
原文链接:,转发请注明来源!