在移动互联网时代,扫码登录已成为 Web 应用不可或缺的登录方式。
本文基于 SpringBoot 框架实现了一个完整的扫码登录系统 DEMO。
一、扫码登录原理
扫码登录的基本流程如下:
- Web端向服务器请求生成唯一二维码
- 服务器生成二维码图片并返回
- 用户通过手机App扫描该二维码
- 手机App发送确认请求到服务器
- 服务器通知Web端登录成功
- 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后端服务需要与移动设备在同一个局域网下
- 打开浏览器,访问 http://192.168.1.101:8080 进入Web端登录页面
- 在另一个浏览器窗口或标签页中打开 http://192.168.1.101:8080/mobile.html 模拟移动端App,或者使用移动设备扫码二维码
- 在移动端页面中输入二维码ID或直接点击Web端页面提供的链接
- 按照界面提示完成扫码登录流程
六、扫码登录流程详解
整个扫码登录的流程如下:
6.1 二维码生成阶段
- 用户打开Web登录页面
- 前端请求后端生成唯一的二维码ID
- 后端生成二维码ID,初始状态为"等待扫描"
- 后端将二维码ID及状态存储到Redis
- 后端生成包含二维码ID的二维码图片并返回给前端
- 前端建立WebSocket连接,准备接收状态更新
6.2 扫描确认阶段
- 用户通过移动端App扫描二维码,获取二维码ID
- 移动端发送扫描请求到服务端
- 服务端更新二维码状态为"已扫描"
- 服务端通过WebSocket推送状态变更到Web端
- Web端更新UI显示"已扫描"状态
- 移动端显示用户选择界面
- 用户在移动端选择要登录的账号并确认
6.3 登录完成阶段
- 移动端发送确认登录请求到服务端
- 服务端验证二维码状态,生成用户令牌
- 服务端更新二维码状态为"已确认",并附带用户信息
- 服务端通过WebSocket推送登录成功信息到Web端
- Web端接收到登录成功消息,获取用户信息
- Web端完成登录流程,显示用户信息
- 移动端显示登录成功界面
七、安全性考虑
实际生产环境中,还需要考虑以下安全因素
7.1 二维码安全
- 短期有效 :二维码应设置较短的有效期,本例中设置为300秒
- 一次性使用 :登录成功后立即使二维码失效
- 状态验证 :严格检查二维码状态的转换合法性
- 防止遍历攻击 :使用足够长的随机UUID,避免被暴力破解
7.2 通信安全
- HTTPS :生产环境必须启用HTTPS加密传输
- WebSocket安全 :考虑为WebSocket连接添加认证机制
- 防重放攻击 :添加时间戳和nonce值防止请求重放
- 跨站点请求伪造(CSRF)防护 :添加CSRF令牌验证
7.3 用户信息安全
- 敏感信息加密 :Redis中存储的用户信息应该加密
- 令牌管理 :实现完善的令牌生成、验证和过期机制
- 登录通知 :当用户完成扫码登录时,向用户发送登录通知
- 异常监测 :监测异常登录行为,如短时间内多次扫码
