一、前言
在生活中,經常有需要用到掃碼的地方,例如掃碼付款,掃碼乘車,掃碼登錄等,就拿掃碼登錄來說就用很多平台用到了,例如微信PC端、淘寶、京東、pdd等一些電商平台,二維碼似乎已與人們的生活息息相關,今天我就來描述一些如何基於 SpringBoot + Redis 實現掃碼登錄功能
二、應用場景
要實現一個功能首先等了解其需求,掃碼登錄一般適用在存在移動端、Web端、PC端等。其目的是為了讓用戶在使用他們的Web端或PC端時登錄更加方便和安全,使用手機掃一掃就可以登錄的服務,就顯得自然而然了。
三、原理及流程設計
首先,我們先嘗試用一句話定義一下掃碼登錄的本質:掃碼登錄本質上是請求登錄方請求已登錄方將登錄憑證寫入特定媒介的過程。這里的請求登錄方為 Web 端,已登錄方為 APP 端,登錄憑證可以是用戶信息,也可以是換取用戶信息的憑證,而特定媒介是某一張二維碼。
具體的掃碼登錄流程大致如下:
- 打開登錄頁面,展示一個二維碼,同時輪詢二維碼狀態(web)
- 打開APP掃描該二維碼后,APP顯示確認、取消按鈕(app)
- 登錄頁面顯示已掃碼或掃描用戶的頭像等信息(web)
- 用戶在APP上點擊確認登錄(app)
- 登錄頁面從輪詢二維碼狀態得知用戶已確認登錄,並獲取到登錄憑證(web)
- 頁面登錄成功,並進入主應用程序頁面(web)
在整個流程里,二維碼顯然起到了至關重要的東西,而二維碼的本質就是一段文本信息,我們可以將二維碼唯一標識、過期時間等信息寫入,而通過App掃碼即可識別出內容再作相應的處理
這里再來詳解一下,二維碼總共有哪些狀態
- NOT_SCAN(未掃描)
- SCANNED(已掃描,待確認)
- CONFIRMED(已確認)
- CANCELED(已取消)
- EXPIRED(已過期)
那么根據以上狀態我們可以得出,實現掃碼登錄功能共需要以下接口
Web端:
- 二維碼生成接口
- 二維碼狀態查詢接口
App端:
- 標記掃描接口
- 確認登錄接口
- 取消登錄接口
四、步驟實現
二維碼生成接口(/qrcode/gen)
上面說到,二維碼本質是一段文本信息,所以我們需要將一段特定信息寫入二維碼中,常見的有兩種方法
- 直接將二維碼唯一標識、過期時間等相關信息寫入,這樣客戶端可以通過唯一標識進行輪詢二維碼狀態接口
- 寫入一個包含ticket的url,在url后的參數中可以拼上所需參數,這樣有個好處是,假設第三方app掃描了此二維碼,就會直接訪問此url,這時可以用一道重定向跳轉到例如下載頁引導用戶下載
/**
* 生成二維碼
*
* @return
*/
@GetMapping("/qrcode/gen")
public Result genQrCode() {
String url = "http://www.xxxxxx.com?qrcodeToken=%s";
// 這里用了hutool工具類生成uuid做二維碼唯一標識
// 客戶端根據二維碼標識輪詢狀態接口
String qrcodeToken = IdUtil.fastSimpleUUID();
// 前端根據url生成二維碼
String intactUrl = String.format(url, qrcodeToken);
redisRepository.setExpire(QrcodeConstants.STATUS_PREFIX + qrcodeToken, QrcodeConstants.NOT_SCAN, 60);
Map<String, String> map = Maps.newHashMap();
map.put("qrcodeToken", qrcodeToken);
map.put("url", intactUrl);
return Result.ok(map);
}
二維碼狀態查詢(/qrcode/status)
這里是最重要的接口,里面包含着各種狀態所需要處理的操作
- 若redis中取出的掃描狀態為空(代表已過期)或為已取消,則重新生成二維碼內容。此時客戶端可以判斷,若是返回狀態為已過期或已取消,則直接用newIntactUrl字段重新生成二維碼,這樣有個好處是可以動態刷新二維碼,節省二維碼過期,用戶還得點刷新按鈕,但這樣也有個壞處,就是客戶端會一直輪詢二維碼狀態查詢接口,有利有弊吧
- 若redis中取出的掃描狀態為已確定,則將token返回出去,客戶端可以攜帶token訪問需要登錄的接口,但可能有小伙伴要問了,為啥這里的token是從redis中取呢,下面馬上解釋
/**
* 狀態查詢
* 前端輪詢接口 3s一次 根據狀態常量操作
*
* @param qrcodeToken二維碼唯一標識
* @return
*/
@GetMapping("/qrcode/status")
public Result status(@RequestParam String qrcodeToken) {
String scanStatus = (String) redisRepository.get(QrcodeConstants.STATUS_PREFIX + qrcodeToken);
Map<String, Object> resultMap = Maps.newHashMap();
if (StrUtil.equals(QrcodeConstants.CANCELED, scanStatus) || StrUtil.isBlank(scanStatus)) {
String newQrcodeToken = IdUtil.fastSimpleUUID();
String newIntactUrl = String.format(url, newQrcodeToken);
redisRepository.setExpire(QrcodeConstants.STATUS_PREFIX + newQrcodeToken, QrcodeConstants.NOT_SCAN, 60);
resultMap.put("newQrcodeToken", newQrcodeToken);
resultMap.put("newUrl", newIntactUrl);
} else if (StrUtil.equals(QrcodeConstants.CONFIRMED, scanStatus)) {
String token = (String) redisRepository.get(QrcodeConstants.TOKEN_PREFIX + qrcodeToken);
resultMap.put("token", token);
}
resultMap.put("status", StrUtil.isNotBlank(scanStatus) ? scanStatus : QrcodeConstants.EXPIRED);
return Result.ok(resultMap);
}
掃描二維碼接口(/qrcode/scanned)
這里要注意的是有兩種處理方法
- 服務端處理
若是服務端處理,此時需要返回視圖,而上文中的url則為此接口地址,將授權頁(H5頁面)放入項目資源目錄下,然后app掃描直接跳轉,而服務端則判斷若已登錄則去授權頁,若未登錄則重定向到下載頁,引導用戶下載
- app端處理
app端掃描二維碼時根據二維碼內容(例如包含qrcodeToke關鍵字)跳轉並調用此接口標識已掃描(若登錄則跳轉授權頁,未登錄則去登錄)
/**
* 掃描接口
*
* @param qrcodeToken 二維碼唯一標識
* @return
*/
@GetMapping("/qrcode/scanned")
public Result scanned(@RequestParam String qrcodeToken) {
String scanStatus = (String) redisRepository.get(QrcodeConstants.STATUS_PREFIX + qrcodeToken);
if (StrUtil.isBlank(scanStatus) || !QrcodeConstants.NOT_SCAN.equals(scanStatus)) {
return Result.failed("請刷新二維碼后重試");
}
redisRepository.setExpire(QrcodeConstants.STATUS_PREFIX + qrcodeToken, QrcodeConstants.SCANNED, 60);
return Result.ok("掃描成功");
}
確認登錄接口(/qrcode/confirm)
app掃描成功后,二維碼狀態變成SCANNED,此時點擊確認登錄則需要服務端根據當前登錄用戶生成一個新的客戶端token,這里有人問為啥不用之前已經存在的app端token呢,因為我這里用到是 Security + Oauth2 的認證授權模式,即可以根據不同客戶端生成不同token,從而從token有效期及有效范圍等多方面進行控制
/**
* 確認授權
*
* @param qrcodeToken 二維碼唯一標識
* @return
*/
@PutMapping("/confirm")
public Result confirm(@RequestParam String qrcodeToken) {
// 這里模擬獲取當前登錄用戶
User user = UserUtils.getUser();
// 掃描狀態
String scanStatus = (String) redisRepository.get(QrcodeConstants.STATUS_PREFIX + qrcodeToken);
if (StrUtil.isBlank(scanStatus) || !QrcodeConstants.SCANNED.equals(scanStatus)) {
return Result.failed("請刷新二維碼后重試");
}
try {
// 模擬win二維碼登錄,將token存入redis並改變二維碼狀態
String token = loginService.qrcodeLogin(user.getUsername);
redisRepository.setExpire(QrcodeConstants.STATUS_PREFIX + qrcodeToken, QrcodeConstants.CONFIRMED, 60);
redisRepository.setExpire(QrcodeConstants.TOKEN_PREFIX + qrcodeToken, token , 60);
} catch (Exception e) {
log.error("Http調用win二維碼登錄異常:{}", e.getMessage());
return Result.failed("確認異常");
}
return Result.ok("確認成功");
}
取消登錄接口(/qrcode/cancel)
app掃描成功后,可以選擇確認登錄或取消登錄,若取消登錄則調用此接口,並將狀態由SCANNED變為CANCELED
/**
* 取消登錄
*
* @param qrcodeToken 二維碼唯一標識
* @return
*/
@PutMapping("/cancel")
public Result cancel(@RequestParam String qrcodeToken) {
// 掃描狀態
String scanStatus = (String) redisRepository.get(QrcodeConstants.STATUS_PREFIX + qrcodeToken);
if (StrUtil.isBlank(scanStatus) || !QrcodeConstants.SCANNED.equals(scanStatus)) {
return Result.failed("請刷新二維碼后重試");
}
redisRepository.setExpire(QrcodeConstants.STATUS_PREFIX + qrcodeToken, QrcodeConstants.CANCELED, 60);
return Result.ok("取消成功");
}
常量類
/**
* 二維碼相關常量
*
* @author Brave
* @version V1.0
* @date 2021/6/30
*/
public interface QrcodeConstants {
/**
* redis前綴 - 掃描狀態
*/
String STATUS_PREFIX = "qrcode:login:status:";
/**
* redis前綴 - token
*/
String TOKEN_PREFIX = "qrcode:login:token:";
/**
* 未掃描
*/
String NOT_SCAN = "NOT_SCAN";
/**
* 已掃描,等待用戶確認
*/
String SCANNED = "SCANNED";
/**
* 已掃描,用戶同意授權
*/
String CONFIRMED = "CONFIRMED";
/**
* 已掃描,用戶取消授權
*/
String CANCELED = "CANCELED";
/**
* 已過期
*/
String EXPIRED = "EXPIRED";
}
五、小結
掃碼登錄已經介紹完啦,但是我相信還有很多更好的實現思路,如果老哥有好的想法也歡迎在評論區交流~