在介紹掃碼登入的原理之前,我們先聊聊服務端的身份認證機制。以普通的 帳號 密碼 登入方式為例,服務端收到使用者的登入要求後,先驗證帳號、密碼的合法性。如果驗證通過,那麼服務端會為使用者指派一個 token,該 token 與使用者的身分資訊相關聯,可作為使用者的登入憑證。之後 PC 端再次發送請求時,需要在請求的 Header 或 Query 參數中攜帶 token,服務端根據 token 便可識別出當前用戶。 token 的優點是更方便、更安全,它降低了帳號密碼被劫持的風險,而且使用者不需要重複輸入帳號和密碼。 PC 端透過帳號和密碼登入的過程如下:
掃碼登入本質上也是一種身分認證方式,帳號密碼 登入與掃碼登入的差別在於,前者是利用PC 端的帳號和密碼為PC 端申請一個token,後者是利用 手機端的token 裝置資訊 為PC 端申請一個token。這兩種登入方式的目的相同,都是為了使PC 端獲得服務端的"授權",在為PC 端申請token 之前,二者都需要向服務端證明自己的身份,也就是必須讓服務端知道當前使用者是誰,這樣服務端才能為其產生PC 端token。由於掃碼前手機端一定是處於已登入狀態的,因此手機端本身已經保存了一個 token,該 token 可用於服務端的識別。那為什麼手機端在驗證身分時還需要裝置資訊呢?實際上,手機端的身份認證和PC 端略有不同:
手機端在登入前也需要輸入帳號和密碼,但登入請求中除了帳號密碼外還包含著設備訊息,例如設備類型、設備id 等。
接收到登入要求後,服務端會驗證帳號和密碼,驗證通過後,將使用者資訊與裝置資訊關聯起來,也就是將它們儲存在一個資料結構structure 中。
服務端為手機端產生一個token,並將token 與使用者資訊、裝置資訊關聯起來,即以token 為key,structure 為value,將該鍵值對持久化儲存到本地,之後將token 返回給手機端。
手機端發送請求,攜帶token 和裝置訊息,服務端根據token 查詢出structure,並驗證structure 中的裝置資訊和手機端的裝置資訊是否相同,以此判斷用戶的有效性。
我們在PC 端登入成功後,可以短時間內正常瀏覽網頁,但之後造訪網站時就要重新登陸了,這是因為token 是有過期時間的,較長的有效時間會增大token 被劫持的風險。但是,手機端好像很少有這種問題,例如微信登入成功後可以一直使用,即使關閉微信或重新啟動手機。這是因為設備資訊具有唯一性,即使 token 被劫持了,由於設備資訊不同,攻擊者也無法向服務端證明自己的身份,這大大提高了安全係數,因此 token 可以長久使用。手機端透過帳號密碼登入的過程如下:
了解了服務端的身份認證機制後,我們再聊一聊掃碼登入的整個流程。以網頁版微信為例,我們在PC 端點擊二維碼登入後,瀏覽器頁面會彈出二維碼圖片,此時打開手機微信掃描二維碼,PC 端隨即顯示"正在掃碼",手機端點擊確認登入後,PC 端就會顯示"登陸成功" 了。
上述過程中,服務端可以根據手機端的操作來回應 PC 端,那麼服務端是如何將二者關聯起來的呢?答案就是透過 "二維碼",嚴格來說是透過二維碼中的內容。使用二維碼解碼器掃描網頁版微信的二維碼,可以得到如下內容:
由上圖我們得知,二維碼中包含的其實是一個網址,手機掃描二維碼後,會根據該網址向服務端發送請求。接著,我們開啟 PC 端瀏覽器的開發者工具:
可見,在顯示出二維碼之後,PC 端一直都沒有 "閒著",它透過輪詢的方式不斷向服務端發送請求,以獲知手機端操作的結果。這裡我們注意到,PC 端發送的 URL 中有一個參數 uuid,值為 "Adv-NP1FYw==",該 uuid 也存在於二維碼包含的網址中。由此我們可以推斷,服務端在產生二維碼之前會先生成一個二維碼 id,二維碼 id 與二維碼的狀態、過期時間等資訊綁定在一起,一同儲存在服務端。手機端可以根據二維碼 id 操作服務端二維碼的狀態,PC 端可以根據二維碼 id 向服務端詢問二維碼的狀態。
二維碼最初為"待掃描" 狀態,手機端掃碼後服務端將其狀態改為"待確認" 狀態,此時PC 端的輪詢請求到達,服務端向其返回"待確認" 的回應。手機端確認登入後,二維碼變成 "已確認" 狀態,服務端為 PC 端產生用於身分認證的 token,PC 端再次詢問時,就可以得到這個 token。整個掃碼登入的流程如下圖所示:
PC 端發送"掃碼登入" 請求,服務端產生二維碼id,並儲存二維碼的過期時間、狀態等資訊。
PC 端取得二維碼並顯示。
PC 端開始輪詢檢查二維碼的狀態,二維碼最初為 "待掃描" 狀態。
手機端掃描二維碼,取得二維碼 id。
手機端向服務端發送 "掃碼" 請求,請求中攜帶二維碼 id、手機端 token 以及裝置訊息。
服務端驗證手機端使用者的合法性,驗證透過後將二維碼狀態置為"待確認",並將使用者資訊與二維碼關聯在一起,之後為手機端產生一個一次性token,該token 用作確認登入的憑證。
PC 端輪詢時偵測到二維碼狀態為 "待確認"。
手機端向服務端發送 "確認登入" 請求,請求中攜帶著二維碼 id、一次性 token 以及裝置資訊。
服務端驗證一次 token,驗證透過後將二維碼狀態置為 "已確認",並為 PC 端產生 PC 端 token。
PC 端輪詢時偵測到二維碼狀態為 "已確認",並取得到了 PC 端 token,之後 PC 端不再輪詢。
PC 端透過 PC 端 token 存取服務端。
上述過程中,我們注意到,手機端掃碼後服務端會回傳一個一次性 token,該 token 也是一種身分憑證,但它只能使用一次。一次性 token 的功能是確保 "掃碼請求" 與 "確認登入" 請求由同一個手機端發出,也就是說,手機端使用者不能 "幫其他使用者確認登入"。
關於一次性token 的知識本人也不是很了解,但可以推測,在服務端的緩存中,一次性token 映射的value 應該包含"掃碼" 請求傳入的二維碼信息、設備資訊以及用戶資訊。
#JDK 1.8:專案使用 Java 語言編寫。
Maven:依賴管理。
Redis:Redis 既作為資料庫儲存使用者的識別資訊(為了簡化操作未使用 MySQL),也作為快取儲存二維碼資訊、token 資訊等。
SpringBoot:專案基本環境。
Hutool:開源工具類,其中的 QrCodeUtil 可用來產生二維碼圖片。
Thymeleaf:範本引擎,用於頁面渲染。
二維碼的產生以及二維碼狀態的保存邏輯如下:
@RequestMapping(path = "/getQrCodeImg", method = RequestMethod.GET) public String createQrCodeImg(Model model) { String uuid = loginService.createQrImg(); String qrCode = Base64.encodeBase64String(QrCodeUtil.generatePng("http://127.0.0.1:8080/login/uuid=" + uuid, 300, 300)); model.addAttribute("uuid", uuid); model.addAttribute("QrCode", qrCode); return "login"; }
PC 端存取"登入" 請求時,服務端呼叫createQrImg 方法,產生一個uuid 和一個LoginTicket 對象,LoginTicket 物件中封裝了使用者的userId 和二維碼的狀態。然後服務端將 uuid 作為 key,LoginTicket 物件作為 value 存入 Redis 伺服器中,並設定有效時間為 5 分鐘(二維碼的有效時間),createQrImg 方法的邏輯如下:
public String createQrImg() { // uuid String uuid = CommonUtil.generateUUID(); LoginTicket loginTicket = new LoginTicket(); // 二维码最初为 WAITING 状态 loginTicket.setStatus(QrCodeStatusEnum.WAITING.getStatus()); // 存入 redis String ticketKey = CommonUtil.buildTicketKey(uuid); cacheStore.put(ticketKey, loginTicket, LoginConstant.WAIT_EXPIRED_SECONDS, TimeUnit.SECONDS); return uuid; }
我们在前一节中提到,手机端的操作主要影响二维码的状态,PC 端轮询时也是查看二维码的状态,那么为什么还要在 LoginTicket 对象中封装 userId 呢?这样做是为了将二维码与用户进行关联,想象一下我们登录网页版微信的场景,手机端扫码后,PC 端就会显示用户的头像,虽然手机端并未确认登录,但 PC 端轮询时已经获取到了当前扫码的用户(仅头像信息)。因此手机端扫码后,需要将二维码与用户绑定在一起,使用 LoginTicket 对象只是一种实现方式。二维码生成后,我们将其状态置为 "待扫描" 状态,userId 不做处理,默认为 null。
手机端发送 "扫码" 请求时,Query 参数中携带着 uuid,服务端接收到请求后,调用 scanQrCodeImg 方法,根据 uuid 查询出二维码并将其状态置为 "待确认" 状态,操作完成后服务端向手机端返回 "扫码成功" 或 "二维码已失效" 的信息:
@RequestMapping(path = "/scan", method = RequestMethod.POST) @ResponseBody public Response scanQrCodeImg(@RequestParam String uuid) { JSONObject data = loginService.scanQrCodeImg(uuid); if (data.getBoolean("valid")) { return Response.createResponse("扫码成功", data); } return Response.createErrorResponse("二维码已失效"); }
scanQrCodeImg 方法的主要逻辑如下:
public JSONObject scanQrCodeImg(String uuid) { // 避免多个移动端同时扫描同一个二维码 lock.lock(); JSONObject data = new JSONObject(); try { String ticketKey = CommonUtil.buildTicketKey(uuid); LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey); // redis 中 key 过期后也可能不会立即删除 Long expired = cacheStore.getExpireForSeconds(ticketKey); boolean valid = loginTicket != null && QrCodeStatusEnum.parse(loginTicket.getStatus()) == QrCodeStatusEnum.WAITING && expired != null && expired >= 0; if (valid) { User user = hostHolder.getUser(); if (user == null) { throw new RuntimeException("用户未登录"); } // 修改扫码状态 loginTicket.setStatus(QrCodeStatusEnum.SCANNED.getStatus()); Condition condition = CONDITION_CONTAINER.get(uuid); if (condition != null) { condition.signal(); CONDITION_CONTAINER.remove(uuid); } // 将二维码与用户进行关联 loginTicket.setUserId(user.getUserId()); cacheStore.put(ticketKey, loginTicket, expired, TimeUnit.SECONDS); // 生成一次性 token, 用于之后的确认请求 String onceToken = CommonUtil.generateUUID(); cacheStore.put(CommonUtil.buildOnceTokenKey(onceToken), uuid, LoginConstant.ONCE_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS); data.put("once_token", onceToken); } data.put("valid", valid); return data; } finally { lock.unlock(); } }
1.首先根据 uuid 查询 Redis 中存储的 LoginTicket 对象,然后检查二维码的状态是否为 "待扫描" 状态,如果是,那么将二维码的状态改为 "待确认" 状态。如果不是,那么该二维码已被扫描过,服务端提示用户 "二维码已失效"。我们规定,只允许第一个手机端能够扫描成功,加锁的目的是为了保证 查询 + 修改 操作的原子性,避免两个手机端同时扫码,且同时检测到二维码的状态为 "待扫描"。
2.上一步操作成功后,服务端将 LoginTicket 对象中的 userId 置为当前用户(扫码用户)的 userId,也就是将二维码与用户信息绑定在一起。由于扫码请求是由手机端发送的,因此该请求一定来自于一个有效的用户,我们在项目中配置一个拦截器(也可以是过滤器),当拦截到 "扫码" 请求后,根据请求中的 token(手机端发送请求时一定会携带 token)查询出用户信息,并将其存储到 ThreadLocal 容器(hostHolder)中,之后绑定信息时就可以从 ThreadLocal 容器将用户信息提取出来。注意,这里的 token 指的手机端 token,实际中应该还有设备信息,但为了简化操作,我们忽略掉设备信息。
3.用户信息与二维码信息关联在一起后,服务端为手机端生成一个一次性 token,并存储到 Redis 服务器,其中 key 为一次性 token 的值,value 为 uuid。一次性 token 会返回给手机端,作为 "确认登录" 请求的凭证。
上述代码中,当二维码的状态被修改后,我们唤醒了在 condition 中阻塞的线程,这一步的目的是为了实现长轮询操作,下文中会介绍长轮询的设计思路。
手机端发送 "确认登录" 请求时,Query 参数中携带着 uuid,且 Header 中携带着一次性 token,服务端接收到请求后,首先验证一次性 token 的有效性,即检查一次性 token 对应的 uuid 与 Query 参数中的 uuid 是否相同,以确保扫码操作和确认操作来自于同一个手机端,该验证过程可在拦截器中配置。验证通过后,服务端调用 confirmLogin 方法,将二维码的状态置为 "已确认":
@RequestMapping(path = "/confirm", method = RequestMethod.POST) @ResponseBody public Response confirmLogin(@RequestParam String uuid) { boolean logged = loginService.confirmLogin(uuid); String msg = logged ? "登录成功!" : "二维码已失效!"; return Response.createResponse(msg, logged); }
confirmLogin 方法的主要逻辑如下:
public boolean confirmLogin(String uuid) { String ticketKey = CommonUtil.buildTicketKey(uuid); LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey); boolean logged = true; Long expired = cacheStore.getExpireForSeconds(ticketKey); if (loginTicket == null || expired == null || expired == 0) { logged = false; } else { lock.lock(); try { loginTicket.setStatus(QrCodeStatusEnum.CONFIRMED.getStatus()); Condition condition = CONDITION_CONTAINER.get(uuid); if (condition != null) { condition.signal(); CONDITION_CONTAINER.remove(uuid); } cacheStore.put(ticketKey, loginTicket, expired, TimeUnit.SECONDS); } finally { lock.unlock(); } } return logged; }
该方法会根据 uuid 查询二维码是否已经过期,如果未过期,那么就修改二维码的状态。
轮询操作指的是前端重复多次向后端发送相同的请求,以获知数据的变化。轮询分为长轮询和短轮询:
长轮询:服务端收到请求后,如果有数据,那么就立即返回,否则线程进入等待状态,直到有数据到达或超时,浏览器收到响应后立即重新发送相同的请求。
短轮询:服务端收到请求后无论是否有数据都立即返回,浏览器收到响应后间隔一段时间后重新发送相同的请求。
由于长轮询相比短轮询能够得到实时的响应,且更加节约资源,因此项目中我们考虑使用 ReentrantLock 来实现长轮询。轮询的目的是为了查看二维码状态的变化:
@RequestMapping(path = "/getQrCodeStatus", method = RequestMethod.GET) @ResponseBody public Response getQrCodeStatus(@RequestParam String uuid, @RequestParam int currentStatus) throws InterruptedException { JSONObject data = loginService.getQrCodeStatus(uuid, currentStatus); return Response.createResponse(null, data); }
getQrCodeStatus 方法的主要逻辑如下:
public JSONObject getQrCodeStatus(String uuid, int currentStatus) throws InterruptedException { lock.lock(); try { JSONObject data = new JSONObject(); String ticketKey = CommonUtil.buildTicketKey(uuid); LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey); QrCodeStatusEnum statusEnum = loginTicket == null || QrCodeStatusEnum.parse(loginTicket.getStatus()) == QrCodeStatusEnum.INVALID ? QrCodeStatusEnum.INVALID : QrCodeStatusEnum.parse(loginTicket.getStatus()); if (currentStatus == statusEnum.getStatus()) { Condition condition = CONDITION_CONTAINER.get(uuid); if (condition == null) { condition = lock.newCondition(); CONDITION_CONTAINER.put(uuid, condition); } condition.await(LoginConstant.POLL_WAIT_TIME, TimeUnit.SECONDS); } // 用户扫码后向 PC 端返回头像信息 if (statusEnum == QrCodeStatusEnum.SCANNED) { User user = userService.getCurrentUser(loginTicket.getUserId()); data.put("avatar", user.getAvatar()); } // 用户确认后为 PC 端生成 access_token if (statusEnum == QrCodeStatusEnum.CONFIRMED) { String accessToken = CommonUtil.generateUUID(); cacheStore.put(CommonUtil.buildAccessTokenKey(accessToken), loginTicket.getUserId(), LoginConstant.ACCESS_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS); data.put("access_token", accessToken); } data.put("status", statusEnum.getStatus()); data.put("message", statusEnum.getMessage()); return data; } finally { lock.unlock(); } }
该方法接收两个参数,即 uuid 和 currentStatus,其中 uuid 用于查询二维码,currentStatus 用于确认二维码状态是否发生了变化,如果是,那么需要立即向 PC 端反馈。我们规定 PC 端在轮询时,请求的参数中需要携带二维码当前的状态。
1.首先根据 uuid 查询出二维码的最新状态,并比较其是否与 currentStatus 相同。如果相同,那么当前线程进入阻塞状态,直到被唤醒或者超时。
2.如果二维码状态为 "待确认",那么服务端向 PC 端返回扫码用户的头像信息(处于 "待确认" 状态时,二维码已与用户信息绑定在一起,因此可以查询出用户的头像)。
3.如果二维码状态为 "已确认",那么服务端为 PC 端生成一个 token,在之后的请求中,PC 端可通过该 token 表明自己的身份。
上述代码中的加锁操作是为了能够令当前处理请求的线程进入阻塞状态,当二维码的状态发生变化时,我们再将其唤醒,因此上文中的扫码操作和确认登录操作完成后,还会有一个唤醒线程的过程。
实际上,加锁操作设计得不太合理,因为我们只设置了一把锁。因此对不同二维码的查询或修改操作都会抢占同一把锁。按理来说,不同二维码的操作之间应该是相互独立的,即使加锁,也应该是为每个二维码均配一把锁,但这样做代码会更加复杂,或许有其它更好的实现长轮询的方式?或者干脆直接短轮询。当然,也可以使用 WebSocket 实现长连接。
项目中配置了两个拦截器,一个用于确认用户的身份,即验证 token 是否有效:
@Component public class LoginInterceptor implements HandlerInterceptor { @Autowired private HostHolder hostHolder; @Autowired private CacheStore cacheStore; @Autowired private UserService userService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String accessToken = request.getHeader("access_token"); // access_token 存在 if (StringUtils.isNotEmpty(accessToken)) { String userId = (String) cacheStore.get(CommonUtil.buildAccessTokenKey(accessToken)); User user = userService.getCurrentUser(userId); hostHolder.setUser(user); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { hostHolder.clear(); } }
如果 token 有效,那么服务端根据 token 获取用户的信息,并将用户信息存储到 ThreadLocal 容器。手机端和 PC 端的请求都由该拦截器处理,如 PC 端的 "查询用户信息" 请求,手机端的 "扫码" 请求。由于我们忽略了手机端验证时所需要的的设备信息,因此 PC 端和手机端 token 可以使用同一套验证逻辑。
另一个拦截器用于拦截 "确认登录" 请求,即验证一次性 token 是否有效:
@Component public class ConfirmInterceptor implements HandlerInterceptor { @Autowired private CacheStore cacheStore; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String onceToken = request.getHeader("once_token"); if (StringUtils.isEmpty(onceToken)) { return false; } if (StringUtils.isNoneEmpty(onceToken)) { String onceTokenKey = CommonUtil.buildOnceTokenKey(onceToken); String uuidFromCache = (String) cacheStore.get(onceTokenKey); String uuidFromRequest = request.getParameter("uuid"); if (!StringUtils.equals(uuidFromCache, uuidFromRequest)) { throw new RuntimeException("非法的一次性 token"); } // 一次性 token 检查完成后将其删除 cacheStore.delete(onceTokenKey); } return true; } }
该拦截器主要拦截 "确认登录" 请求,需要注意的是,一次性 token 验证通过后要立即将其删除。
编码过程中,我们简化了许多操作,例如:1. 忽略掉了手机端的设备信息;2. 手机端确认登录后并没有直接为用户生成 PC 端 token,而是在轮询时生成。
浏览器:PC 端操作
Postman:模仿手机端操作。
由于我们没有实现真实的手机端扫码的功能,因此使用 Postman 模仿手机端向服务端发送请求。首先我们需要确保服务端存储着用户的信息,即在 Test 类中执行如下代码:
@Test void insertUser() { User user = new User(); user.setUserId("1"); user.setUserName("John同学"); user.setAvatar("/avatar.jpg"); cacheStore.put("user:1", user); }
手机端发送请求时需要携带手机端 token,这里我们为 useId 为 "1" 的用户生成一个 token(手机端 token):
@Test void loginByPhone() { String accessToken = CommonUtil.generateUUID(); System.out.println(accessToken); cacheStore.put(CommonUtil.buildAccessTokenKey(accessToken), "1"); }
手机端 token(accessToken)为 "aae466837d0246d486f644a3bcfaa9e1"(随机值),之后发送 "扫码" 请求时需要携带这个 token。
启动项目,访问 localhost:8080/index
:
点击登录,并在开发者工具中找到二维码 id(uuid):
打开 Postman,发送localhost:8080/login/scan
请求,Query 参数中携带 uuid,Header 中携带手机端 token:
上述请求返回 "扫码成功" 的响应,同时还返回了一次性 token。此时 PC 端显示出扫码用户的头像:
在 Postman 中发送 localhost:8080/login/confirm
请求,Query 参数中携带 uuid,Header 中携带一次性 token:
"确认登录" 请求发送完成后,PC 端随即获取到 PC 端 token,并成功查询用户信息:
以上是基於Java怎麼實作掃碼登入的詳細內容。更多資訊請關注PHP中文網其他相關文章!