引入依赖
bash
<!-- 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.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
配置
bash
spring:
redis:
host: localhost
port: 6379
database: 0
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
session:
store-type: redis
生成二维码
bash
/**
* 二维码生成工具类
*/
public class QRCodeUtil {
private static final int WIDTH = 300;
private static final int HEIGHT = 300;
private static final String FORMAT = "png";
/**
* 生成二维码字节数组
* @param content 二维码内容
* @return 二维码图片字节数组
*/
public static byte[] generateQRCode(String content) throws WriterException, IOException {
Map<EncodeHintType, Object> hints = new HashMap<>();
// 设置字符编码
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
// 设置容错级别
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
// 设置边距
hints.put(EncodeHintType.MARGIN, 1);
BitMatrix bitMatrix = new MultiFormatWriter().encode(
content, BarcodeFormat.QR_CODE, WIDTH, HEIGHT, hints);
BufferedImage image = MatrixToImageWriter.toBufferedImage(bitMatrix);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(image, FORMAT, outputStream);
return outputStream.toByteArray();
}
/**
* 生成二维码Base64字符串
*/
public static String generateQRCodeBase64(String content) throws WriterException, IOException {
byte[] bytes = generateQRCode(content);
return "data:image/png;base64," + java.util.Base64.getEncoder().encodeToString(bytes);
}
}
定义常量和实体类
bash
/**
* 常量类
*/
public class Constants {
// Redis 中二维码状态的前缀
public static final String QR_CODE_PREFIX = "qr:code:";
// 二维码过期时间(秒)
public static final long QR_CODE_EXPIRE = 5 * 60;
}
/**
* 二维码状态枚举
*/
public enum QRCodeStatus {
WAITING("waiting", "等待扫描"),
SCANNED("scanned", "已扫描"),
CONFIRMED("confirmed", "已确认"),
CANCELLED("cancelled", "已取消"),
EXPIRED("expired", "已过期"),
ERROR("error", "错误");
private String code;
private String message;
QRCodeStatus(String code, String message) {
this.code = code;
this.message = message;
}
// getter 方法
public String getCode() {
return code;
}
public String getMessage() {
return message;
}
}
/**
* WebSocket消息实体
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class WebSocketMessage {
private String uuid;
private QRCodeStatus status;
private String message;
private Object data;
}
/**
* 用户信息实体
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserInfo {
private Long userId;
private String username;
private String nickname;
private String avatar;
}
配置WebSocket
bash
/**
* WebSocket配置
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 启用简单消息代理,前缀为/topic的消息会被代理转发到订阅了相应主题的客户端
config.enableSimpleBroker("/topic");
// 客户端发送消息的前缀
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 注册STOMP端点,客户端通过此端点连接到WebSocket服务器
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(8192);
container.setMaxBinaryMessageBufferSize(8192);
return container;
}
}
二维码服务实现
bash
/**
* 二维码服务
*/
@Service
public class QRCodeService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 创建新的二维码
* @return 二维码UUID
*/
public String createQRCode() {
String uuid = java.util.UUID.randomUUID().toString();
String redisKey = Constants.QR_CODE_PREFIX + uuid;
// 存储二维码状态到Redis
redisTemplate.opsForHash().put(redisKey, "status", QRCodeStatus.WAITING.getCode());
redisTemplate.expire(redisKey, Constants.QR_CODE_EXPIRE, TimeUnit.SECONDS);
return uuid;
}
/**
* 更新二维码状态
* @param uuid 二维码UUID
* @param status 新状态
*/
public void updateQRCodeStatus(String uuid, QRCodeStatus status) {
String redisKey = Constants.QR_CODE_PREFIX + uuid;
redisTemplate.opsForHash().put(redisKey, "status", status.getCode());
redisTemplate.expire(redisKey, getRemainingTime(uuid), TimeUnit.SECONDS);
}
/**
* 更新二维码状态并关联用户信息
* @param uuid 二维码UUID
* @param status 新状态
* @param userInfo 用户信息
*/
public void updateQRCodeStatusWithUser(String uuid, QRCodeStatus status, UserInfo userInfo) {
String redisKey = Constants.QR_CODE_PREFIX + uuid;
// 使用Hash结构存储状态和用户信息
redisTemplate.opsForHash().put(redisKey, "status", status.getCode());
redisTemplate.opsForHash().put(redisKey, "userInfo", userInfo);
redisTemplate.expire(redisKey, getRemainingTime(uuid), TimeUnit.SECONDS);
}
/**
* 获取二维码状态
* @param uuid 二维码UUID
* @return 状态
*/
public QRCodeStatus getQRCodeStatus(String uuid) {
String redisKey = Constants.QR_CODE_PREFIX + uuid;
Object status = redisTemplate.opsForHash().get(redisKey, "status");
if (status == null) {
return QRCodeStatus.EXPIRED;
}
for (QRCodeStatus qrCodeStatus : QRCodeStatus.values()) {
if (qrCodeStatus.getCode().equals(status.toString())) {
return qrCodeStatus;
}
}
return QRCodeStatus.ERROR;
}
/**
* 获取二维码关联的用户信息
* @param uuid 二维码UUID
* @return 用户信息
*/
public UserInfo getUserInfo(String uuid) {
String redisKey = Constants.QR_CODE_PREFIX + uuid;
return (UserInfo) redisTemplate.opsForHash().get(redisKey, "userInfo");
}
/**
* 获取Redis键的剩余时间
* @param uuid 二维码UUID
* @return 剩余时间(秒)
*/
private Long getRemainingTime(String uuid) {
String redisKey = Constants.QR_CODE_PREFIX + uuid;
return redisTemplate.getExpire(redisKey, TimeUnit.SECONDS);
}
/**
* 使二维码过期
* @param uuid 二维码UUID
*/
public void expireQRCode(String uuid) {
String redisKey = Constants.QR_CODE_PREFIX + uuid;
redisTemplate.delete(redisKey);
}
}
后端控制器实现
bash
/**
* 登录控制器
*/
@RestController
@RequestMapping("/api/login")
public class LoginController {
@Autowired
private QRCodeService qrCodeService;
@Autowired
private SimpMessagingTemplate messagingTemplate;
/**
* 生成二维码
*/
@GetMapping("/qrCode")
public Map<String, Object> generateQRCode() throws Exception {
String uuid = qrCodeService.createQRCode();
String qrCodeBase64 = QRCodeUtil.generateQRCodeBase64(uuid);
Map<String, Object> result = new HashMap<>();
result.put("uuid", uuid);
result.put("qrCode", qrCodeBase64);
result.put("expireTime", Constants.QR_CODE_EXPIRE);
return result;
}
/**
* 处理扫码请求
*/
@PostMapping("/scan")
public Map<String, Object> scanQRCode(@RequestBody Map<String, String> request) {
String uuid = request.get("uuid");
Long userId = Long.parseLong(request.get("userId"));
Map<String, Object> result = new HashMap<>();
// 检查二维码是否存在且有效
QRCodeStatus status = qrCodeService.getQRCodeStatus(uuid);
if (status != QRCodeStatus.WAITING) {
result.put("success", false);
result.put("message", "二维码无效或已过期");
return result;
}
// 获取用户信息(这里应该从数据库查询,简化示例)
UserInfo userInfo = new UserInfo(userId, "一安", "一安未来", "https://picsum.photos/200/200");
// 更新二维码状态为已扫描
qrCodeService.updateQRCodeStatusWithUser(uuid, QRCodeStatus.SCANNED, userInfo);
// 通过WebSocket通知前端二维码已被扫描
WebSocketMessage message = new WebSocketMessage(
uuid,
QRCodeStatus.SCANNED,
"二维码已被扫描,请确认登录",
userInfo
);
messagingTemplate.convertAndSend("/topic/qr/" + uuid, message);
result.put("success", true);
result.put("message", "扫码成功,请在PC端确认登录");
return result;
}
/**
* 处理授权请求
*/
@PostMapping("/authorize")
public Map<String, Object> authorize(@RequestBody Map<String, String> request) {
String uuid = request.get("uuid");
Boolean confirm = Boolean.parseBoolean(request.get("confirm"));
Map<String, Object> result = new HashMap<>();
// 检查二维码是否存在且已扫描
QRCodeStatus status = qrCodeService.getQRCodeStatus(uuid);
if (status != QRCodeStatus.SCANNED) {
result.put("success", false);
result.put("message", "二维码状态无效");
return result;
}
if (confirm) {
// 获取用户信息
UserInfo userInfo = qrCodeService.getUserInfo(uuid);
// 更新二维码状态为已确认
qrCodeService.updateQRCodeStatusWithUser(uuid, QRCodeStatus.CONFIRMED, userInfo);
// 通过WebSocket通知前端登录成功
WebSocketMessage message = new WebSocketMessage(
uuid,
QRCodeStatus.CONFIRMED,
"登录成功",
userInfo
);
messagingTemplate.convertAndSend("/topic/qr/" + uuid, message);
result.put("success", true);
result.put("message", "授权成功");
result.put("userInfo", userInfo);
} else {
// 更新二维码状态为已取消
qrCodeService.updateQRCodeStatus(uuid, QRCodeStatus.ERROR);
// 通过WebSocket通知前端登录已取消
WebSocketMessage message = new WebSocketMessage(
uuid,
QRCodeStatus.ERROR,
"用户取消登录",
null
);
messagingTemplate.convertAndSend("/topic/qr/" + uuid, message);
result.put("success", false);
result.put("message", "用户取消登录");
}
return result;
}
/**
* 检查二维码状态(轮询方式)
*/
@GetMapping("/checkStatus")
public Map<String, Object> checkStatus(@RequestParam String uuid) {
Map<String, Object> result = new HashMap<>();
QRCodeStatus status = qrCodeService.getQRCodeStatus(uuid);
result.put("status", status.getCode());
result.put("message", status.getMessage());
if (status == QRCodeStatus.SCANNED) {
// 获取用户信息
UserInfo userInfo = qrCodeService.getUserInfo(uuid);
result.put("userInfo", userInfo);
}
return result;
}
}
WebSocket消息处理器
bash
/**
* WebSocket消息处理器
*/
@Controller
public class WebSocketController {
/**
* 处理客户端订阅二维码状态的消息
*/
@MessageMapping("/subscribeQr")
@SendTo("/topic/qr/{uuid}")
public WebSocketMessage subscribeQr(@PathVariable String uuid) {
// 这里可以根据需要返回初始状态
return new WebSocketMessage(
uuid,
QRCodeStatus.WAITING,
"等待扫描",
null
);
}
}
二维码登录系统技术方案
流程概述
1. 二维码生成阶段
User WebFront WebBack Redis 打开登录页面 请求生成二维码ID 生成唯一二维码ID 存储二维码状态(等待扫描) 返回二维码图片 建立WebSocket连接 User WebFront WebBack Redis
- 扫描确认阶段
User MobileApp WebBack WebFront Redis 扫描二维码 发送扫描请求(二维码ID) 更新状态为已扫描 WebSocket推送状态变更 更新UI显示已扫描 显示账号选择界面 选择账号并确认 User MobileApp WebBack WebFront Redis
- 登录完成阶段
User MobileApp WebBack WebFront Redis 确认登录请求 验证二维码状态 生成用户令牌 更新状态为已确认(含用户信息) WebSocket推送登录成功 完成登录流程 显示用户信息 显示登录成功 User MobileApp WebBack WebFront Redis
数据结构设计
bash
// 二维码状态枚举
enum QRCodeStatus {
PENDING, // 等待扫描
SCANNED, // 已扫描
CONFIRMED, // 已确认
EXPIRED, // 已过期
REJECTED // 已拒绝
}
// 二维码数据结构
public class QRCodeData {
private String qrCodeId; // 二维码ID
private QRCodeStatus status; // 当前状态
private Long createTime; // 创建时间
private Long scanTime; // 扫描时间
private Long confirmTime; // 确认时间
private String userId; // 用户ID(确认后)
private String deviceInfo; // 设备信息
private String token; // 登录令牌
private String ipAddress; // 扫描IP
}
bash
@RestController
@RequestMapping("/api/qrcode")
public class QRCodeController {
@PostMapping("/generate")
public ResponseResult<QRCodeResponse> generateQRCode() {
// 1. 生成唯一二维码ID
String qrCodeId = UUID.randomUUID().toString();
// 2. 创建二维码数据
QRCodeData qrCodeData = new QRCodeData();
qrCodeData.setQrCodeId(qrCodeId);
qrCodeData.setStatus(QRCodeStatus.PENDING);
qrCodeData.setCreateTime(System.currentTimeMillis());
qrCodeData.setExpireTime(System.currentTimeMillis() + 5 * 60 * 1000); // 5分钟过期
// 3. 存储到Redis
redisTemplate.opsForValue().set(
"qrcode:" + qrCodeId,
qrCodeData,
5, TimeUnit.MINUTES
);
// 4. 生成二维码图片
String qrContent = generateQRContent(qrCodeId);
byte[] qrImage = generateQRImage(qrContent);
return ResponseResult.success(new QRCodeResponse(qrCodeId, qrImage));
}
@PostMapping("/scan")
public ResponseResult<Void> scanQRCode(@RequestBody ScanRequest request) {
// 1. 验证二维码存在且未过期
QRCodeData qrCodeData = getQRCodeData(request.getQrCodeId());
if (qrCodeData == null || qrCodeData.isExpired()) {
return ResponseResult.error("二维码已过期");
}
// 2. 更新状态为已扫描
qrCodeData.setStatus(QRCodeStatus.SCANNED);
qrCodeData.setScanTime(System.currentTimeMillis());
qrCodeData.setDeviceInfo(request.getDeviceInfo());
qrCodeData.setIpAddress(request.getIpAddress());
// 3. 保存到Redis
redisTemplate.opsForValue().set(
"qrcode:" + qrCodeId,
qrCodeData
);
// 4. 通过WebSocket通知Web端
notifyWebSocket(qrCodeId, "SCANNED");
return ResponseResult.success();
}
@PostMapping("/confirm")
public ResponseResult<LoginResult> confirmLogin(@RequestBody ConfirmRequest request) {
// 1. 验证二维码状态
QRCodeData qrCodeData = getQRCodeData(request.getQrCodeId());
if (qrCodeData.getStatus() != QRCodeStatus.SCANNED) {
return ResponseResult.error("二维码状态无效");
}
// 2. 验证用户身份
User user = authenticateUser(request.getToken());
// 3. 生成登录令牌
String loginToken = generateLoginToken(user);
// 4. 更新二维码状态
qrCodeData.setStatus(QRCodeStatus.CONFIRMED);
qrCodeData.setConfirmTime(System.currentTimeMillis());
qrCodeData.setUserId(user.getId());
qrCodeData.setToken(loginToken);
// 5. 保存到Redis
redisTemplate.opsForValue().set(
"qrcode:" + qrCodeId,
qrCodeData
);
// 6. 通过WebSocket通知Web端
notifyWebSocket(qrCodeId, "CONFIRMED", user);
return ResponseResult.success(new LoginResult(loginToken));
}
}
WebSocket处理器
bash
@Component
@ServerEndpoint("/ws/qrcode/{qrCodeId}")
public class QRCodeWebSocketHandler {
private static Map<String, Session> sessions = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session session, @PathParam("qrCodeId") String qrCodeId) {
sessions.put(qrCodeId, session);
}
@OnClose
public void onClose(@PathParam("qrCodeId") String qrCodeId) {
sessions.remove(qrCodeId);
}
public void notifyStatusChange(String qrCodeId, String status, Object data) {
Session session = sessions.get(qrCodeId);
if (session != null && session.isOpen()) {
try {
Map<String, Object> message = new HashMap<>();
message.put("event", "QRCODE_STATUS_CHANGE");
message.put("qrCodeId", qrCodeId);
message.put("status", status);
message.put("data", data);
message.put("timestamp", System.currentTimeMillis());
session.getBasicRemote().sendText(
objectMapper.writeValueAsString(message)
);
} catch (Exception e) {
log.error("WebSocket发送失败", e);
}
}
}
}
安全增强实现
bash
@Component
public class SecurityEnhancer {
// 防重放攻击
public boolean verifyReplayAttack(String signature, String timestamp, String nonce) {
// 1. 检查时间戳是否在有效期内(5分钟内)
long currentTime = System.currentTimeMillis();
long requestTime = Long.parseLong(timestamp);
if (Math.abs(currentTime - requestTime) > 5 * 60 * 1000) {
return false;
}
// 2. 检查nonce是否已使用过
if (redisTemplate.hasKey("nonce:" + nonce)) {
return false;
}
redisTemplate.opsForValue().set("nonce:" + nonce, "1", 5, TimeUnit.MINUTES);
// 3. 验证签名
String expectedSignature = calculateSignature(timestamp, nonce);
return signature.equals(expectedSignature);
}
// IP限制
public boolean checkIpLimit(String ipAddress) {
String key = "ip_limit:" + ipAddress;
Long count = redisTemplate.opsForValue().increment(key, 1);
if (count == 1) {
redisTemplate.expire(key, 1, TimeUnit.HOURS);
}
return count <= 100; // 每小时最多100次请求
}
// 数据加密
public String encryptData(String data) {
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] encrypted = cipher.doFinal(data.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new RuntimeException("加密失败", e);
}
}
}
多设备支持实现
bash
@Service
public class DeviceManagementService {
// 设备管理
public void recordDeviceLogin(String userId, DeviceInfo deviceInfo) {
DeviceRecord record = new DeviceRecord();
record.setUserId(userId);
record.setDeviceId(deviceInfo.getDeviceId());
record.setDeviceType(deviceInfo.getDeviceType());
record.setLoginTime(System.currentTimeMillis());
record.setLocation(deviceInfo.getLocation());
record.setIpAddress(deviceInfo.getIpAddress());
// 保存设备记录
deviceRecordRepository.save(record);
// 检查异地登录
checkUnusualLocation(userId, deviceInfo.getLocation());
}
// 异地登录检测
private void checkUnusualLocation(String userId, String newLocation) {
List<DeviceRecord> recentLogins = deviceRecordRepository
.findRecentLogins(userId, 30); // 最近30天
if (recentLogins.isEmpty()) {
return;
}
String commonLocation = findCommonLocation(recentLogins);
if (!isSameRegion(commonLocation, newLocation)) {
// 发送异地登录提醒
sendUnusualLoginAlert(userId, newLocation);
}
}
// 单点登录
public void handleSingleSignOn(String userId, String newToken) {
// 1. 使旧token失效
invalidateOldTokens(userId);
// 2. 保存新token
saveNewToken(userId, newToken);
// 3. 通知其他设备
notifyOtherDevices(userId, "NEW_LOGIN_DETECTED");
}
}
前端实现
Web端二维码处理
bash
class QRCodeLogin {
constructor() {
this.qrCodeId = null;
this.ws = null;
this.pollingInterval = null;
}
// 初始化二维码
async initQRCode() {
try {
const response = await fetch('/api/qrcode/generate');
const data = await response.json();
this.qrCodeId = data.qrCodeId;
this.displayQRCode(data.qrImage);
this.connectWebSocket();
// 启动状态轮询(备用方案)
this.startStatusPolling();
} catch (error) {
console.error('生成二维码失败:', error);
}
}
// WebSocket连接
connectWebSocket() {
this.ws = new WebSocket(`ws://${location.host}/ws/qrcode/${this.qrCodeId}`);
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleStatusChange(message);
};
this.ws.onclose = () => {
console.log('WebSocket连接关闭,切换到轮询模式');
this.switchToPolling();
};
}
// 处理状态变更
handleStatusChange(message) {
switch (message.status) {
case 'SCANNED':
this.showScannedStatus();
break;
case 'CONFIRMED':
this.completeLogin(message.data);
break;
case 'EXPIRED':
this.showExpiredStatus();
this.refreshQRCode();
break;
}
}
}
配置示例
application.yml
bash
# Redis配置
spring:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 3000ms
database: 0
# 二维码配置
qrcode:
expire-time: 300 # 5分钟
polling-interval: 2000 # 轮询间隔2秒
max-scan-attempts: 3 # 最大扫描尝试次数
# 安全配置
security:
encryption:
algorithm: AES
key-size: 256
iv-size: 16
rate-limit:
ip-max-requests: 100
ip-window-hours: 1
# WebSocket配置
websocket:
enabled: true
endpoint: /ws
allowed-origins: "*"
message-size-limit: 65536
监控与日志
bash
@Aspect
@Component
@Slf4j
public class QRCodeLogAspect {
@Around("@annotation(QRCodeOperation)")
public Object logQRCodeOperation(ProceedingJoinPoint joinPoint) throws Throwable {
String operation = getOperationName(joinPoint);
String qrCodeId = getQRCodeId(joinPoint.getArgs());
log.info("QRCode操作开始: operation={}, qrCodeId={}", operation, qrCodeId);
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
long duration = System.currentTimeMillis() - startTime;
log.info("QRCode操作成功: operation={}, qrCodeId={}, duration={}ms",
operation, qrCodeId, duration);
// 记录监控指标
recordMetrics(operation, duration, "success");
return result;
} catch (Exception e) {
long duration = System.currentTimeMillis() - startTime;
log.error("QRCode操作失败: operation={}, qrCodeId={}, duration={}ms, error={}",
operation, qrCodeId, duration, e.getMessage());
// 记录监控指标
recordMetrics(operation, duration, "error");
throw e;
}
}
}
注意事项
安全性考虑
二维码ID必须是不可预测的随机值
所有敏感数据传输必须加密
实施严格的访问控制和权限验证
定期更新加密密钥
性能优化
Redis使用连接池
WebSocket连接保持活跃
二维码图片使用缓存
异步处理非关键操作
容错处理
WebSocket断开时自动切换轮询
Redis故障时的降级方案
网络异常的重试机制
状态不一致的恢复策略
用户体验
二维码过期自动刷新
实时状态反馈
多设备登录提示
操作确认机制