登錄驗證明踐——Token & Refresh Token

背景

最近在作管理系統項目的時候,涉及到登錄驗證的問題。因爲該系統的實時性要求並不高,排除了session + cookie的傳統方案,糾結再三,最終選擇了使用token進行驗證。如此,服務端無需存儲會話信息,將信息加密保存在token中,只要密鑰沒有泄露,安全性仍是能夠獲得保障的。javascript

Github 倉庫前端

技術棧vue

  • 前端:vue全家桶 + ant-design-vue
  • 後端:MongoDB + TypeScript + express

歡迎有志之士交流探討。轉載請附原文連接。 java

Token

token認證的基本流程是:客戶端登錄成功,服務端返回token,客戶端存儲token。之後的每次請求,都將攜帶token,通常會放在請求頭中,隨請求發送:Authorization: tokenios

token的特色是沒法廢除,即一個token只要頒發了,在有效期內始終是有效的,即便頒發了新的token也不影響原有的token使用。出於安全性考慮,token的有效時間應該設置的短一些,一般設置爲30min ~ 1hgit

這樣一來每隔這麼久就要重置一次token,能夠經過refresh token的方式來更新token。es6

Refresh Token

refresh token與token同樣,都是一段加密字符串,不一樣的是,refresh token是用來獲取新的token的。github

在使用成熟的網站、社區時,經常發現很長一段時間咱們都不須要從新登錄,這貌似有悖於「token的有效時間應該設置的短一些」。實際上使用refresh token後,登陸權限驗證的流程變成了下面這樣:web

登錄成功後服務端返回token和refresh token,客戶端存儲這兩個信息。平時發送請求時攜帶token,當token過時時服務端會拒絕響應,此時發送一個攜帶refresh token的新請求,服務端驗證後返回新的token,客戶端替換舊的token後從新發起請求便可。若refresh token過時,則從新登錄。vuex

能夠看出refresh token做用於長期,僅用於獲取新的token,同時也控制着用戶登陸的最長時間,通常會設置的長一些,例如7天,14天,過時以後必須從新登陸;token做用於短時間,用於獲取數據。

問題

基本的流程瞭解清楚後,能夠引出如下幾個問題:

  • token裏面放什麼?
  • 從新請求會不會打斷用戶的操做?(用戶體驗差)
  • 併發請求如何處理?(會反覆調用API刷新,效率低下)

token裏面放什麼

token裏啥均可以放,只要你願意。通常會包含簡短的用戶信息和過時時間,因爲token內放的數據是能夠解密的,因此千萬不要放敏感信息如密碼等。過時時間用來驗證token是否過時,簡短的用戶信息用於可能的數據庫操做(若是有的話)。

用戶體驗

試想若是因爲token過時致使用戶好不容易填完的表單數據丟失,用戶必定會暴跳如雷吧?刷新token必定要考慮用戶體驗問題。

一般咱們會設置全局的攔截(以axios爲例)。設置全局響應攔截,經過約定好的信息(如狀態碼)判斷屬於哪一種狀況,最後根據狀況採起不一樣的操做(是刷新token?仍是從新登錄?),以後再從新發送以前的請求。

提高用戶體驗的關鍵在於,不能中斷當前請求,而是使用新的請求替換原來的請求。這一點在axios中能夠輕鬆實現,下文會示例。

併發請求處理

併發請求時,若剛好token過時,則最終會發起多個刷新token的請求,多餘的請求除了增長服務器的壓力,沒有任何作用。

瀏覽器中,發出請求時候會開啓一條線程。請求完成以後,將對應的回調函數添加到任務隊列中,等待 JS 引擎處理。而咱們須要整合這個過程,將併發請求攔截彙總,最終只發出一次刷新請求。這便涉及線程同步的問題,咱們能夠經過加鎖,和緩衝來解決。

  • 加鎖:簡單來講就是在模塊內部設置一個全局變量,用來標誌當前的狀態,防止衝突。

  • 緩衝:就是設置一個空間,將當時發生但來不及處理的內容存儲起來,在合適的時機再處理。

幹說很差理解,下面上代碼。

實現

約定:403從新登錄,401須要刷新

前端

使用vue + axios實現,首先封裝一下全局的axios API

/** * axios.js */
import { message } from 'ant-design-vue'
import axios from 'axios'
import store from '../store'
import router from '../router'
import handle401 from './handle401'

axios.defaults.baseURL = '/api'

// 請求攜帶token
axios.interceptors.request.use(config => {
  // 判斷是爲了防止自定義header被覆蓋
  if (!config.headers.authorization && store.state.token) {
    config.headers.authorization = store.state.token
  }
  return config
})

// 若因401而拒絕,則刷新token,若403則跳轉登陸
// 返回的內容將會替換當前請求(Promise鏈式調用)
axios.interceptors.response.use(null, error => {
  const { status, config } = error.response
  if (status === 401) {
    return handle401(config)
  } else if (status === 403) {
    message.warn('身份憑證過時,請從新登陸')
    router.replace({ name: 'login' }).catch(e => e)
  }
  return Promise.reject(error)
})

export default axios // 導出axios對象,全部請求都使用這個對象

複製代碼

而後是對於401狀態的處理,細心的小夥伴能夠發現,這裏存在handle404.jsaxios.js的循環引用問題,感興趣的能夠戳 阮一峯 —— ES6模塊加載,因爲不會影響代碼邏輯的正常執行,這裏不作展開。

/** * handle404.js */
import store from '../store'
import axios from './axios'
import { REFRESH_TOKEN } from '../store/mutation-types'

let lock = false // 鎖
const originRequest = [] // 緩衝

/** * 處理401——刷新token並處理以前的請求,目的在於實現用戶無感知刷新 * @param config 以前的請求的配置 * @returns {Promise<unknown>} */
export default function (config) {
  if (!lock) {
    lock = true
    store.dispatch(REFRESH_TOKEN).then(newToken => {
      // 使用新的token替換舊的token,並構造新的請求
      const requests = originRequest.map(callback => callback(newToken))
      // 從新發送請求
      return axios.all(requests)
    }).finally(() => {
      // 重置
      lock = false
      originRequest.splice(0)
    })
  }
  // 關鍵代碼,返回Promise替換當前的請求
  return new Promise(resolve => {
    // 收集舊的請求,以便刷新後構造新的請求,同時因爲Promise鏈式調用的效果,
    // axios(config)的結果就是最終的請求結果
    originRequest.push(newToken => {
      config.headers.authorization = newToken
      resolve(axios(config))
    })
  })
}
複製代碼

這是接口:

/** * index.js */
import axios from './axios'

export const login = data => axios.post('/auth/login', data)
export const refreshToken = originToken => {
  return axios.get('/auth/refresh', {
    headers: {
      authorization: originToken
    }
  })
}
複製代碼

而後是vuex的相關代碼:

import { message } from 'ant-design-vue'
import { LOGIN, REFRESH_TOKEN } from './mutation-types'
import { login, refreshToken } from '../api/index.js'

export default {
  [LOGIN] ({ commit, state }, info) {
    ···
  },
  [REFRESH_TOKEN] ({ commit, state }) {
    // 使用Promise包裝便於控制流程
    return new Promise((resolve, reject) => {
      refreshToken(state.refreshToken).then(({ data: newToken }) => {
        commit(REFRESH_TOKEN, newToken)
        resolve(newToken)
      }).catch(reject)
    })
  }
}

複製代碼

後端

使用express + jsonwebtoken實現

爲了便於演示,token過時時間設置爲10s,refresh token過時時間設置爲20s。

/** * token.ts */
import dayjs from 'dayjs'
import { sign } from 'jsonwebtoken'
import secretKey from '../config/tokenKey'

// 控制普通token,客戶端過時後無需再次登陸
export const getToken = function () {
  return sign({
    exp: dayjs().add(10, 's').valueOf()
  }, secretKey)
}

// 控制客戶端最長登錄時間,超時從新登陸
export const getRefreshToken = function (payload: any) {
  return sign({
    user: payload, // 這裏放入一點用戶信息,刷新的時候用來查數據庫,簡單的驗證一下。
    exp: dayjs().add(20, 's').valueOf()
  }, secretKey)
}

複製代碼

登陸路由部分代碼:

/** * login.ts */
import { Router } from 'express'
import { getRefreshToken, getToken } from '../../utils/token'

const router = Router().
router.post('/auth/login', function (req, res) {
    ...
    res.json({
        code: 0,
        msg: '登錄成功',
        data: {
            user: { identity, ...user },
            token: getToken(),
            refreshToken: getRefreshToken({ identity, account })
        }
    })
    ...
})

export default router
複製代碼

resfresh token路由

/** * refresh.ts */
import { Router } from 'express'
import dayjs from 'dayjs'
import { verify } from 'jsonwebtoken'
import { find } from '../../db/dao'
import { USER } from '../../db/model'
import secretKey from '../../config/tokenKey'
import { getToken } from '../../utils/token'

const router = Router()

router.get('/auth/refresh', function (req, res) {
  const refreshToken = req.headers.authorization
  if (!refreshToken) {
    return res.status(403).end()
  }
  verify(refreshToken, secretKey, function (err, payload: any) {
    // token 解析失敗,從新登陸
    if (err) {
      return res.status(403).end()
    }
    const { exp, user } = payload
    // refreshToken過時,從新登陸
    if (dayjs().isAfter(exp)) {
      return res.status(403).end()
    }
    // 不然刷新token
    find(USER, user).then(users => {
      if (users.length === 0) {
        res.status(403).end()
      } else {
        res.status(200).send(getToken())
      }
    }).catch(e => {
      res.status(500).end(e.message)
    })
  })
})

export default router
複製代碼

登錄驗證中間件:

/** * loginChecker.ts */
import dayjs from 'dayjs'
import { Request, Response, NextFunction } from 'express'
import { verify } from 'jsonwebtoken'
import secretKey from '../config/tokenKey'

export default function (req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization
  if (!token) {
    return res.status(403).end()
  }
  verify(token, secretKey, function (err, payload: any) {
    if (err) {
      return res.status(403).end(err.message)
    }
    const { exp } = payload
    console.log(dayjs(exp).format('YYYY-MM-DD HH:mm:ss'))
    if (dayjs().isAfter(exp)) {
      res.status(401).end('Unauthorized') // 過時,401提示客戶端刷新token
    } else {
      next() // 不然經過驗證
    }
  })
}
複製代碼

效果圖

token過時

前兩個action發出了兩個請求,而且都失敗了,進入401處理邏輯,能夠看到緊接着執行了refresh token的操做,而後等refresh token結束後,繼續以前的請求,進而完成以前的請求,觸發mutation,完成整個操做。整個過程用戶沒有感知。

token請求

refresh token過時

refresh token流程

能夠看到,refresh token 失敗後,觸發403的邏輯,而後跳轉到登陸界面。達到了想要的效果。

總結

這個登錄驗證的流程中,最值得細細品味的,當屬併發請求處理的部分,即handle404.js文件中的內容。它涉及併發問題,而Vue中也有相似的問題, 如視圖更新:

vue中數據變化觸發的視圖更新是異步的,這使得短期內數據的屢次變化能夠整合到一塊兒,避免渲染無心義的中間態。其內部也是使用一個標誌量和一個緩衝區來實現的。

文章若有紕漏,歡迎批評指正。轉載請附原文連接

參考

請求時token過時自動刷新token

相關文章
相關標籤/搜索