SpringBoot 掃碼登錄全流程:UUID 生成、狀態輪詢、授權回調詳解
作者:一安
用戶使用手機端應用掃描二維碼,手機端應用攜帶掃碼信息請求后端,后端驗證掃碼信息并標記二維碼為已掃描狀態,同時返回授權頁面或直接進行授權操作。
前言
在移動互聯網時代,掃碼登錄以其便捷性和安全性,成為眾多應用首選的登錄方式。
技術原理
掃碼登錄的核心流程基于WebSocket、Redis等技術,主要包含以下幾個關鍵步驟:
- 生成二維碼:用戶點擊掃碼登錄后,后端生成一個唯一的UUID作為標識,將該標識與用戶設備信息等關聯,存儲到Redis中,并生成包含此UUID的二維碼返回給前端展示。
- 前端輪詢或WebSocket監聽:前端通過輪詢接口或使用WebSocket長連接,不斷向后端查詢二維碼對應的登錄狀態。
- 掃碼與授權:用戶使用手機端應用掃描二維碼,手機端應用攜帶掃碼信息請求后端,后端驗證掃碼信息并標記二維碼為已掃描狀態,同時返回授權頁面或直接進行授權操作。
- 完成登錄:授權通過后,后端更新Redis中二維碼對應的登錄狀態為已登錄,前端監聽到登錄狀態變化后,完成登錄流程,跳轉至相應頁面。
實現
演示效果
輪詢方式
圖片
websocket
圖片
引入依賴
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- ZXing for QR Code generation -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.1</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
配置
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
生成二維碼
/**
* 二維碼生成工具類
*/
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);
}
}
定義常量和實體類
/**
* 常量類
*/
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
/**
* 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;
}
}
二維碼服務實現
/**
* 二維碼服務
*/
@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);
}
}
后端控制器實現
/**
* 登錄控制器
*/
@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消息處理器
/**
* WebSocket消息處理器
*/
@Controller
public class WebSocketController {
/**
* 處理客戶端訂閱二維碼狀態的消息
*/
@MessageMapping("/subscribeQr")
@SendTo("/topic/qr/{uuid}")
public WebSocketMessage subscribeQr(@PathVariable String uuid) {
// 這里可以根據需要返回初始狀態
return new WebSocketMessage(
uuid,
QRCodeStatus.WAITING,
"等待掃描",
null
);
}
}
總結
二維碼生成階段
- 用戶打開Web登錄頁面
- 前端請求后端生成唯一的二維碼ID
- 后端生成二維碼ID,初始狀態為等待掃描
- 后端將二維碼ID及狀態存儲到Redis
- 后端生成包含二維碼ID的二維碼圖片并返回給前端
- 前端建立WebSocket連接,準備接收狀態更新(輪詢方式不需要)
掃描確認階段
- 用戶通過移動端App掃描二維碼,獲取二維碼ID
- 移動端發送掃描請求到服務端
- 服務端更新二維碼狀態為已掃描
- 服務端通過WebSocket推送狀態變更到Web端(輪詢方式不需要)
- Web端更新UI顯示已掃描狀態
- 移動端顯示用戶選擇界面
- 用戶在移動端選擇要登錄的賬號并確認
登錄完成階段
- 移動端發送確認登錄請求到服務端
- 服務端驗證二維碼狀態,生成用戶令牌
- 服務端更新二維碼狀態為已確認,并附帶用戶信息
- 服務端通過WebSocket推送登錄成功信息到Web端(輪詢方式不需要)
- Web端接收到登錄成功消息,獲取用戶信息
- Web端完成登錄流程,顯示用戶信息
- 移動端顯示登錄成功界面
功能優化與擴展
安全增強
- 數據加密:對二維碼內容和傳輸的數據進行加密處理,防止信息泄露。
- 防重放攻擊:為每個請求添加時間戳和簽名,防止請求被截獲后重放。
- 訪問控制:限制對Redis數據的訪問權限,只允許授權的請求操作相關數據。
- IP限制:對頻繁請求的IP進行限制,防止惡意攻擊。
多設備支持
- 設備管理:記錄和管理用戶登錄的設備信息,支持查看和管理已登錄設備。
- 異地登錄提醒:當檢測到用戶在異地登錄時,發送提醒通知。
- 單點登錄:實現同一賬號在不同設備上的單點登錄功能。
責任編輯:武曉燕
來源:
一安未來