網頁掃碼請求登錄的邏輯原理與實現

引言

現實中經常會需要我們需要掃碼授權登陸,有的時候是藉助微信授權登陸,有的時候商戶需要登陸某個特定的app,在該app中掃碼登陸。那麼我們今天就來分析一下掃碼登陸,這背後究竟發生了怎麼樣的請求交互,以及是怎麼實現的。
下面我們以微信爲例,調了微信商戶登陸平臺這個頁面進行分析:
https://pay.weixin.qq.com/index.php/core/home/login?return_url=%2F

針對微信網頁進行分析

首先如圖1,一進入頁面之後會請求生成一個二維碼。

針對一個請求,前臺會多次有間隔地輪詢,如圖2,如圖3。

請求的響應結果有 "wait scan" 和 “二維碼過期” 兩種情況,如圖4,圖5所示。

在二維碼過期後,點擊刷新二維碼,之後便會重新請求獲取到二維碼,再次的輪詢請求後臺結果,如圖6所示。

仿照設計與實現

設計

考慮的點:

  1. 二維碼生成與展示。

這裏我們採用前端生成隨機串,以便前端後期不斷的輪詢。具體隨機串藏在二維碼中生成接口可以參考我之前的博文——java生成QR二維碼

  1. 輪詢間隔,後端對應的過期與超時等返回。

這裏新版的微信登陸採用的是前端sleep,頻繁請求後端。在之前沒改版的時候採用的是長連接,一次請求由後端自行輪詢。本文采用後端輪詢的形式。

  1. APP掃碼登陸。

APP掃碼識別出了二維碼中的隨機串,應該告訴服務器驗證成功,待web下一次輪詢服務器的時候要返回相應的token和登陸成功等其他信息。

將這幾個點結合在一起就有了圖7。

那麼經過分析,我們得知後端至少要3個接口。分別生成二維碼,給WEB輪詢,和給APP請求。生成二維碼的參考博主之前的博文,目前就不在這裏重複。下面給出其他兩個接口的實現。

實現

  1. 給WEB輪詢接口
    採用遞歸的形式實現輪詢。利用redis存儲了前端生成的隨機串,設置0爲默認值

CONNECT_TIME_OUT("連接超時",2001),
  private static String DEAFULT_ID = "0";
 /**
     * 獲取token
     *
     * @param webLoginDTO
     * @return
     */
    @PostMapping(value = "webLoginCode/ask")
    @ResponseBody
    public RestResponse<Map<String, Object>> askWebLoginCode(@JsonParam WebLoginDTO webLoginDTO) {
        String webLoginCode = webLoginDTO.getWebLoginCode();
        Assert.notNull(webLoginCode, "傳入的隨機串爲空");
        return RestResponse.ok(askWebLoginCodePolling(webLoginCode, MAX_RETRY));
    }

    /**
     * 輪詢獲取token
     *
     * @param webLoginCode
     * @param retry
     * @return
     */
    @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
    public Map<String, Object> askWebLoginCodePolling(String webLoginCode, int retry) {
        if (retry == 0) {
            throw new FantuanRuntimeException(FantuanErrors.CONNECT_TIME_OUT.getMessage(),
                    FantuanErrors.CONNECT_TIME_OUT.getCode());
        }
        Map<String, Object> resultMaps = new HashMap<>();
        String varR = varPool.getVar(getWebLoginVarPool(webLoginCode));
        if (StringUtils.isBlank(varR)) {
            throw new FantuanRuntimeException("已經過期,請重新刷新二維碼");
        }

        if (!DEAFULT_ID.equals(varR)) {
            Map<String, Object> maps = JsonUtil.stringToMap(varR);
            String varResult = MapUtils.getString(maps, "uid");
            User user = userService.selectById(Long.valueOf(varResult));
            String webAuthKey = user.getWebAuthKey();
            if (StringUtils.isBlank(webAuthKey)) {
                webAuthKey = RandomNumberUtil.creatUUID32();
                user.setWebAuthKey(webAuthKey);
                userService.updateById(user);
            }
            resultMaps.put("userName", user.getUsername());
            resultMaps.put("uid", varResult);
            resultMaps.put("token", webAuthKey);
            resultMaps.put("extraInfo", MapUtils.getObject(maps, "extraInfo"));
            return resultMaps;
        } else {
            //這裏需要hold住鏈接
            try {
                Thread.sleep(1000);
                return askWebLoginCodePolling(webLoginCode, retry - 1);
            } catch (InterruptedException e) {
                log.error("", e);
            }
        }
        return resultMaps;
    }

2.給APP請求接口
APP掃碼登陸後,會把一些有用的信息給傳遞過來。這裏後端做成了一個map extraInfo去接收,到時候整個extraInfo會返回給WEB端。
這樣子的好處,後端就是成了一個驗證平臺而已,需要的信息只要由APP和WEB端定義好即可。

 /**
     * app掃碼登陸
     *
     * @param webLoginDTO
     */
    @PostMapping(value = "webLoginCode/check")
    @ResponseBody
    public RestResponse<String> checkWebLoginCode(@JsonParam WebLoginDTO webLoginDTO) {
        Long uid = SecurityUtils.getLoginAccountId();
        if (uid <= 0) {
            throw new FantuanRuntimeException("請登陸");
        }

        Assert.notNull(webLoginDTO, "傳入的對象不能爲空");
        String webLoginCode = webLoginDTO.getWebLoginCode();
        Assert.notNull(webLoginCode, "傳入的二維串隨機碼不能爲空");
        Map<String, String> result = new HashMap<>();
        if (StringUtils.isBlank(varPool.getVar(getWebLoginVarPool(webLoginCode)))){
            throw new FantuanRuntimeException("該二維碼已經過期");
        }
        Map<String, Object> maps = new HashMap<>();
        maps.put("uid", uid);
        maps.put("extraInfo", webLoginDTO.getExtraInfo());
        varPool.setVar(getWebLoginVarPool(webLoginCode), JsonUtil.mapToJson(maps), 60, TimeUnit.SECONDS);
        return RestResponse.ok("執行完成了");
    }
  1. 另外給出部分的WEB端代碼
<template>
  <div class="page">
    <top-nav :buttons="false" />
    <div class="page-main">
      <div class="qr-code">
        <img class="qr-code-image" v-if="qrcode" :src="$apiDomain + '/jv/anonymous/login/webLoginCode/' + qrcode" alt="登錄二維碼" @load="imageLoaded" />
        <div v-if="expired" class="qr-code-expired" @click.stop="refresh">
          <i class="iconfont icon-shuaxin"></i>
          <div class="expired-tip">二維碼已失效,請點擊刷新</div>
        </div>
      </div>
      <div class="scan-tip">掃描二維碼</div>
      <div class="scan-sub-tip">在電腦端進行活動編輯</div>
    </div>
    <us :onlyCopyright="true" />
  </div>
</template>

<script>
import TopNav from '@/components/TopNav'
import Us from '@/components/Us'
export default {
  data () {
    return {
      qrcode: '',
      expired: false
    }
  },
  components: { TopNav, Us },
  methods: {
    getQrcode (length) {
      this.expired = false
      let rString = ''
      let timeStr = new Date().getTime().toString()
      timeStr = timeStr.substring(timeStr.length - length)
      let rendomStr = this.getRandom(length)
      for (let i = 0; i < length; i++) {
        rString += (rendomStr[i] + timeStr[i])
      }
      return rString
    },
    getRandom (length) {
      if (length > 0) {
        let data = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']
        let nums = ''
        for (let i = 0; i < length; i++) {
          let r = parseInt(Math.random() * 61)
          nums += data[r]
        }
        return nums
      } else {
        return false
      }
    },
    getUserInfo (qrcode) {
      if (!qrcode) {
        return false
      }
      sessionStorage.clear()
      let rData = {
        webLoginCode: qrcode
      }
      this.$ajax('/webLoginCode/ask', {data: rData, dontToast: true}).then(res => {
        console.log('userInfo_res', res)
        if (res && res.data && !res.error) { // 獲取用戶信息成功
          sessionStorage.setItem('token', res.data.token)
          sessionStorage.setItem('userId', res.data.uid)
          sessionStorage.setItem('userName', res.data.userName)
          this.$router.replace({name: 'ActivityEdit'})
        }
      }).catch(err => {
        if (err && err.data && err.data.error) {
          console.log('userInfo_err', err)
          if (err.data.error.toString() === '2001') {
            // 重新獲取
            console.log('鏈接超時')
            this.getUserInfo(qrcode)
          } else {
            console.log('獲取信息出錯')
            this.expired = true
          }
        }
      })
    },
    refresh () {
      this.qrcode = this.getQrcode(8)
    },
    imageLoaded () {
      console.log('imageLoaded')
      this.getUserInfo(this.qrcode)
    }
  },
  mounted () {
    this.refresh()
  }
}
</script>

最終web端的頁面如圖8所示。這裏採用的是後端長連接的形式,所以不像是新版微信那樣請求是斷續的。當然要做成那樣,只要後端的輪詢上限改成1,前端加上一個短暫的sleep即可。

總結:

其實無論是掃碼登陸,還是網頁的掃碼支付,其實本質上都是藏着一個長連接/長輪詢去監聽服務器的狀態變化。畢竟回call或者掃碼識別等都是通過服務器來校驗的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章