SpringBoot 掃碼登錄全流程:UUID 生成、狀態(tài)輪詢、授權(quán)回調(diào)詳解
作者:一安
用戶使用手機(jī)端應(yīng)用掃描二維碼,手機(jī)端應(yīng)用攜帶掃碼信息請求后端,后端驗(yàn)證掃碼信息并標(biāo)記二維碼為已掃描狀態(tài),同時返回授權(quán)頁面或直接進(jìn)行授權(quán)操作。
前言
在移動互聯(lián)網(wǎng)時代,掃碼登錄以其便捷性和安全性,成為眾多應(yīng)用首選的登錄方式。
技術(shù)原理
掃碼登錄的核心流程基于WebSocket、Redis等技術(shù),主要包含以下幾個關(guān)鍵步驟:
- 生成二維碼:用戶點(diǎn)擊掃碼登錄后,后端生成一個唯一的UUID作為標(biāo)識,將該標(biāo)識與用戶設(shè)備信息等關(guān)聯(lián),存儲到Redis中,并生成包含此UUID的二維碼返回給前端展示。
- 前端輪詢或WebSocket監(jiān)聽:前端通過輪詢接口或使用WebSocket長連接,不斷向后端查詢二維碼對應(yīng)的登錄狀態(tài)。
- 掃碼與授權(quán):用戶使用手機(jī)端應(yīng)用掃描二維碼,手機(jī)端應(yīng)用攜帶掃碼信息請求后端,后端驗(yàn)證掃碼信息并標(biāo)記二維碼為已掃描狀態(tài),同時返回授權(quán)頁面或直接進(jìn)行授權(quán)操作。
- 完成登錄:授權(quán)通過后,后端更新Redis中二維碼對應(yīng)的登錄狀態(tài)為已登錄,前端監(jiān)聽到登錄狀態(tài)變化后,完成登錄流程,跳轉(zhuǎn)至相應(yīng)頁面。
實(shí)現(xiàn)
演示效果
輪詢方式
圖片
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";
/**
* 生成二維碼字節(jié)數(shù)組
* @param content 二維碼內(nèi)容
* @return 二維碼圖片字節(jié)數(shù)組
*/
public static byte[] generateQRCode(String content) throws WriterException, IOException {
Map<EncodeHintType, Object> hints = new HashMap<>();
// 設(shè)置字符編碼
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
// 設(shè)置容錯級別
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
// 設(shè)置邊距
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);
}
}定義常量和實(shí)體類
/**
* 常量類
*/
public class Constants {
// Redis 中二維碼狀態(tài)的前綴
public static final String QR_CODE_PREFIX = "qr:code:";
// 二維碼過期時間(秒)
public static final long QR_CODE_EXPIRE = 5 * 60;
}
/**
* 二維碼狀態(tài)枚舉
*/
public enum QRCodeStatus {
WAITING("waiting", "等待掃描"),
SCANNED("scanned", "已掃描"),
CONFIRMED("confirmed", "已確認(rèn)"),
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消息實(shí)體
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class WebSocketMessage {
private String uuid;
private QRCodeStatus status;
private String message;
private Object data;
}
/**
* 用戶信息實(shí)體
*/
@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的消息會被代理轉(zhuǎn)發(fā)到訂閱了相應(yīng)主題的客戶端
config.enableSimpleBroker("/topic");
// 客戶端發(fā)送消息的前綴
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 注冊STOMP端點(diǎn),客戶端通過此端點(diǎn)連接到WebSocket服務(wù)器
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(8192);
container.setMaxBinaryMessageBufferSize(8192);
return container;
}
}二維碼服務(wù)實(shí)現(xiàn)
/**
* 二維碼服務(wù)
*/
@Service
public class QRCodeService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 創(chuàng)建新的二維碼
* @return 二維碼UUID
*/
public String createQRCode() {
String uuid = java.util.UUID.randomUUID().toString();
String redisKey = Constants.QR_CODE_PREFIX + uuid;
// 存儲二維碼狀態(tài)到Redis
redisTemplate.opsForHash().put(redisKey, "status", QRCodeStatus.WAITING.getCode());
redisTemplate.expire(redisKey, Constants.QR_CODE_EXPIRE, TimeUnit.SECONDS);
return uuid;
}
/**
* 更新二維碼狀態(tài)
* @param uuid 二維碼UUID
* @param status 新狀態(tài)
*/
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);
}
/**
* 更新二維碼狀態(tài)并關(guān)聯(lián)用戶信息
* @param uuid 二維碼UUID
* @param status 新狀態(tài)
* @param userInfo 用戶信息
*/
public void updateQRCodeStatusWithUser(String uuid, QRCodeStatus status, UserInfo userInfo) {
String redisKey = Constants.QR_CODE_PREFIX + uuid;
// 使用Hash結(jié)構(gòu)存儲狀態(tài)和用戶信息
redisTemplate.opsForHash().put(redisKey, "status", status.getCode());
redisTemplate.opsForHash().put(redisKey, "userInfo", userInfo);
redisTemplate.expire(redisKey, getRemainingTime(uuid), TimeUnit.SECONDS);
}
/**
* 獲取二維碼狀態(tài)
* @param uuid 二維碼UUID
* @return 狀態(tài)
*/
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;
}
/**
* 獲取二維碼關(guān)聯(lián)的用戶信息
* @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);
}
}后端控制器實(shí)現(xiàn)
/**
* 登錄控制器
*/
@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;
}
// 獲取用戶信息(這里應(yīng)該從數(shù)據(jù)庫查詢,簡化示例)
UserInfo userInfo = new UserInfo(userId, "一安", "一安未來", "https://picsum.photos/200/200");
// 更新二維碼狀態(tài)為已掃描
qrCodeService.updateQRCodeStatusWithUser(uuid, QRCodeStatus.SCANNED, userInfo);
// 通過WebSocket通知前端二維碼已被掃描
WebSocketMessage message = new WebSocketMessage(
uuid,
QRCodeStatus.SCANNED,
"二維碼已被掃描,請確認(rèn)登錄",
userInfo
);
messagingTemplate.convertAndSend("/topic/qr/" + uuid, message);
result.put("success", true);
result.put("message", "掃碼成功,請在PC端確認(rèn)登錄");
return result;
}
/**
* 處理授權(quán)請求
*/
@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", "二維碼狀態(tài)無效");
return result;
}
if (confirm) {
// 獲取用戶信息
UserInfo userInfo = qrCodeService.getUserInfo(uuid);
// 更新二維碼狀態(tài)為已確認(rèn)
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", "授權(quán)成功");
result.put("userInfo", userInfo);
} else {
// 更新二維碼狀態(tài)為已取消
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;
}
/**
* 檢查二維碼狀態(tài)(輪詢方式)
*/
@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 {
/**
* 處理客戶端訂閱二維碼狀態(tài)的消息
*/
@MessageMapping("/subscribeQr")
@SendTo("/topic/qr/{uuid}")
public WebSocketMessage subscribeQr(@PathVariable String uuid) {
// 這里可以根據(jù)需要返回初始狀態(tài)
return new WebSocketMessage(
uuid,
QRCodeStatus.WAITING,
"等待掃描",
null
);
}
}總結(jié)
二維碼生成階段
- 用戶打開Web登錄頁面
- 前端請求后端生成唯一的二維碼ID
- 后端生成二維碼ID,初始狀態(tài)為等待掃描
- 后端將二維碼ID及狀態(tài)存儲到Redis
- 后端生成包含二維碼ID的二維碼圖片并返回給前端
- 前端建立WebSocket連接,準(zhǔn)備接收狀態(tài)更新(輪詢方式不需要)
掃描確認(rèn)階段
- 用戶通過移動端App掃描二維碼,獲取二維碼ID
- 移動端發(fā)送掃描請求到服務(wù)端
- 服務(wù)端更新二維碼狀態(tài)為已掃描
- 服務(wù)端通過WebSocket推送狀態(tài)變更到Web端(輪詢方式不需要)
- Web端更新UI顯示已掃描狀態(tài)
- 移動端顯示用戶選擇界面
- 用戶在移動端選擇要登錄的賬號并確認(rèn)
登錄完成階段
- 移動端發(fā)送確認(rèn)登錄請求到服務(wù)端
- 服務(wù)端驗(yàn)證二維碼狀態(tài),生成用戶令牌
- 服務(wù)端更新二維碼狀態(tài)為已確認(rèn),并附帶用戶信息
- 服務(wù)端通過WebSocket推送登錄成功信息到Web端(輪詢方式不需要)
- Web端接收到登錄成功消息,獲取用戶信息
- Web端完成登錄流程,顯示用戶信息
- 移動端顯示登錄成功界面
功能優(yōu)化與擴(kuò)展
安全增強(qiáng)
- 數(shù)據(jù)加密:對二維碼內(nèi)容和傳輸?shù)臄?shù)據(jù)進(jìn)行加密處理,防止信息泄露。
- 防重放攻擊:為每個請求添加時間戳和簽名,防止請求被截獲后重放。
- 訪問控制:限制對Redis數(shù)據(jù)的訪問權(quán)限,只允許授權(quán)的請求操作相關(guān)數(shù)據(jù)。
- IP限制:對頻繁請求的IP進(jìn)行限制,防止惡意攻擊。
多設(shè)備支持
- 設(shè)備管理:記錄和管理用戶登錄的設(shè)備信息,支持查看和管理已登錄設(shè)備。
- 異地登錄提醒:當(dāng)檢測到用戶在異地登錄時,發(fā)送提醒通知。
- 單點(diǎn)登錄:實(shí)現(xiàn)同一賬號在不同設(shè)備上的單點(diǎn)登錄功能。
責(zé)任編輯:武曉燕
來源:
一安未來































