基於JWT token 及 AUTH2.0 refresh_token的前後端分離驗證模式

前後端分離的登錄驗證

我們的程序一般是通過微信掃碼來進行登錄的,但是在接進前後端分離之後,發現登錄驗證過程不是很友好,於是查了一些資料。比較推薦用JWT來做一個token的驗證實現登錄,但是有些文章提到,JWT token會有token失效時間過短造成要重新登錄的問題。考慮到這個,參考一些文章在jwt的基礎上添加了auth2.0中的refresh token的機制。

關於代碼

我們的前後端架構是flask + npm + iview。

驗證流程圖

爲方便理解整個過程的邏輯,特畫了下面這個圖。
驗證流程圖

實現代碼

JWT token 生成模塊

jwt不在這裏詳解,可以查閱相關資料,構造爲 header,payload和Signature。我的代碼中是通過itsdangerous模塊的TimedJSONWebSignatureSerializer這個JWT生成器來生成jwt token的

access_token,用於登錄驗證的

from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
def genAccessToken(workId,expires=86400):
    s = Serializer(
        secret_key=current_app.config['SECRET_KEY'],
        expires_in=expires
    )
    return s.dumps({
        'workId': workId,
        'iat': time.time()
    })

secret_key 是生成 Signature的加密字符串 ,用於簽證
expires_in 是這個token的過期時間,這裏設置爲86400秒,即一天
dumps函數中的字典結構是jwt的payload部分,也是我們的有效信息載體部分

refresh_token,用於刷新access_token

鑑於access_token有超時時間,而且爲了安全,access_token的超時時間不能過於太長,所以參照auth2.0 的 refresh token機制,這裏也添加一個refresh token來講access_token進行刷新,超時時間爲5天

def genRefreshToken(workId,expires=432000):
    s = Serializer(
        secret_key=current_app.config['SECRET_KEY'],
        expires_in=expires
    )
    return s.dumps({
        'workId': workId,
        'iat': time.time()
    })

超時時間設置爲5天,也就是說,在refresh token生成後的5天內,access_token一旦超時,那麼將會重新生成一個新的access token用於驗證

前端的登錄信息存儲

我們用vuex這個前端的狀態管理 來存儲驗證後的用戶信息和驗證信息
store/module/user.js:

  state: {
    userName: '',
    firstName: '',
    workId: '',
    access_token: getAccessToken(),
    refresh_token: getRefreshToken(),
    access: ['super_admin'],
    hasGetInfo: false
  },

爲避免關閉頁面後,token被銷燬,我們把access token 和 refresh token存放在瀏覽器的緩存中
libs/util.js:

export const TOKEN_KEY = 'access_token'
export const REFRESH_TOKEN_KEY = 'refresh_token'
export const setAccessToken = (token) => {
  Cookies.set(TOKEN_KEY, token, {expires: config.cookieExpires || 1})
}
export const getAccessToken = () => {
  const token = Cookies.get(TOKEN_KEY)
  if (token) return token
  else return false
}
export const setRefreshToken = (token) => {
  Cookies.set(REFRESH_TOKEN_KEY, token, {expires: config.cookieExpires || 5})
}
export const getRefreshToken = () => {
  const token = Cookies.get(REFRESH_TOKEN_KEY)
  if (token) return token
  else return false
}

前端的登錄,路由鉤子beforeEach

在路由鉤子beforeEach中,判斷是否有登錄信息
router/index.js:

router.beforeEach((to, from, next) => {
  iView.LoadingBar.start()
  const token = getAccessToken()
  if (!token && to.name !== LOGIN_PAGE_NAME) {
    // 未登錄且要跳轉的頁面不是登錄頁
    next({
      name: LOGIN_PAGE_NAME // 跳轉到登錄頁
    })
  } else if (!token && to.name === LOGIN_PAGE_NAME) {
    // 未登陸且要跳轉的頁面是登錄頁
    next() // 跳轉
  } else if (token && to.name === LOGIN_PAGE_NAME) {
    // 已登錄且要跳轉的頁面是登錄頁
    next({
      name: homeName // 跳轉到homeName頁
    })
  } else {
    if (store.state.user.hasGetInfo) {
      turnTo(to, store.state.user.access, next)
    } else {
      store.dispatch('getUserInfo')
        .then(user => {
          // 拉取用戶信息,通過用戶權限和跳轉的頁面的name來判斷是否有權限訪問;access必須是一個數組,如:['super_admin'] ['super_admin', 'admin']
          turnTo(to, store.state.user.access, next)
        })
        .catch(() => {
          setAccessToken('')
          next({
            name: 'login'
          })
        })
    }
  }
})

當在狀態管理中沒有找到登錄信息後,跳到/login到flask進行登錄驗證

後端flask login接口

login接口在處理一下信息後生成兩個token返回給前端

@blueprint.route('/login', methods=['GET'])
def login():
    resp_data = json.dumps({
        'token': genAccessToken(workId).decode("utf-8"),
        'refresh_token': genRefreshToken(workId).decode("utf-8")
    })
    return Response(response=resp_data, status=200, mimetype="application/json")

前端收到token, 存放到緩存中

handlePolarLogin({ commit },info) {
  return new Promise((resolve, reject) => {
    polarLogin(info).then(res => {
      const data = res.data
      commit('setAccessToken',data.token)
      commit('setRefreshToken',data.refresh_token)
      resolve(res)
    }).catch(err => {
      reject(err)
    })
  })
}

使用access token獲取數據過程分析

前端在拿到access token之後,後續前端獲取數據的請求中都要帶上access token。後端的接口則需要判斷access token是否超時,payload中的信息是否正確。

前端請求攜帶access token

爲了讓每個請求都帶上access token,需要在前端的請求攔截器中將access token放到請求的header中

class HttpRequest {
  ...
  getInsideConfig () {
    const config = {
      baseURL: this.baseUrl,
      headers: {
        'Authorization': store.state.user.access_token
      }
    }
    return config
  }

後端驗證access token

後端收到請求後,需要驗證access token,因爲大部分接口都需要驗證,所以我們可以將這個驗證過程寫成一個裝飾器

def accessTokenAuth(func):
    @wraps(func)
    def wrapper(*args,**kwargs):
        token = request.headers.get('Authorization')
        if not token:
            return jsonify(u'access token 不存在驗證信息!'), 251
        s = Serializer(
            secret_key=current_app.config['SECRET_KEY']
        )
        try:
            data = s.loads(token)
        except SignatureExpired:
            return jsonify(u'access token超時!'), 253
        except BadSignature as e:
            encoded_payload = e.payload
            if encoded_payload is not None:
                try:
                    s.load_payload(encoded_payload)
                except BadData:
                    return jsonify(u'access token被篡改!'), 251
            return jsonify(u'access token錯誤的驗證信息!'), 251
        except:
            return jsonify(u'access token驗證失敗,未知的錯誤!'), 251
        if ('workId' not in data):
            return jsonify(u'access token錯誤的信息載體!'), 251
        if func.__name__ == 'getUserInfo':
            return func(int(data['workId']))
        else:
            return func(*args,**kwargs)
    return wrapper

從request的頭部信息中獲取access token, 通過secret_key解密獲取信息進行驗證。
這裏我們根據不同的驗證結果定義了251和253的狀態碼,方便標識。

前端請求攔截器處理response

class HttpRequest {
    ...
    interceptors (instance, url, options) {
    ...
        // 響應攔截
        instance.interceptors.response.use(res => {
          let { data, status } = res
          // 檢查flask後臺的接口狀態
          if (status && status === 253) {
            // access_token 超時
            this.refresh = true
            store.dispatch('handleCheckRefreshToken').then(res => {
              // 重新刷新當前頁面
              this.request(options)
              history.go(0)
              return Promise.reject(new Error("token超時刷新"))
            },error => {
              return Promise.reject(error)
            })
          }
          this.destroy(url)
          if (status && [250,251,252].includes(status)) {
            // 登出 登錄
            store.commit('setAccessToken','')
            store.commit('setRefreshToken','')
            router.push({name: 'login'})
            return Promise.reject(data)
          }
          ...

檢測返回的狀態碼,如果爲 253,說明access token超時,需要刷新access token;如果爲251,說明驗證不通過,則需要重置token,重新登錄

刷新 access token

當狀態碼爲253時,前端需要觸發進行access token刷新,這個時候需要用到refresh token

handleCheckRefreshToken({ state, commit }) {
  return new Promise((resolve, reject) => {
    checkRefreshToken(state.refresh_token).then(res => {
      const data = res.data
      commit('setAccessToken',data.token)
      resolve(res)
    }).catch(err => {
      reject(err)
    })
  })
},

後端驗證refresh token

@blueprint.route('/refreshTokenAuth', methods=['POST'])
def checkRefreshToken():
    data = json.loads(request.data)
    code,info = refreshTokenAuth(data['token'])
    if code:
        resp_data = json.dumps({
            'token': genAccessToken(int(info)).decode("utf-8")
        })
        return Response(response=resp_data, status=200, mimetype="application/json")
    else:
        return jsonify(info), 252
def refreshTokenAuth(token):
    if not token:
        return False,u'refresh token不存在驗證信息!'
    s = Serializer(
        secret_key=current_app.config['SECRET_KEY']
    )
    try:
        data = s.loads(token)
    except SignatureExpired:
        return False,u'refresh token超時!'
    except BadSignature as e:
        encoded_payload = e.payload
        if encoded_payload is not None:
            try:
                s.load_payload(encoded_payload)
            except BadData:
                return False,u'refresh token被篡改!'
        return False,u'refresh token錯誤的驗證信息!'
    except:
        return False,u'refresh token驗證失敗,未知的錯誤!'
    if ('workId' not in data):
        return False,u'refresh token錯誤的信息載體!'
    return True,data['workId']

refresh在驗證通過之後,會生成新的access token返回給前端;如果沒通過或者超時,則會返回252狀態碼

前端保存新的token

前端收到新的token之後,會把之前的請求重新發送一次,確保之前的請求成功,然後刷新頁面:

if (status && status === 253) {
// access_token 超時
this.refresh = true
store.dispatch('handleCheckRefreshToken').then(res => {
  // 重新刷新當前頁面
  this.request(options)
  history.go(0)
  return Promise.reject(new Error("token超時刷新"))
},error => {
  return Promise.reject(error)
})
}

整個過程就結束了

結束語

這種方式的驗證並不是說絕對安全的,只是有效降低了風險。

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