點燈坊

失くすものさえない今が強くなるチャンスよ

如何管理 JWT Token ?

Sam Xiao's Avatar 2020-05-02

管理 JWT Token 是前端不可避免的課題,若只透過 Vuex 或 Local Storage 都有其缺憾,唯有同時結合 Vuex 與 Local Storage 才是最完美方案。

Version

macOS Catalina 10.15.4
WebStorm 2020.1
Vue 2.6.11
Axios 0.19.2

Challenges

  • 雖然可用 local storage 儲存 token,當需要 token 時就存取 local storage,但這種方式並不優雅

  • 只使用 Vuex 存 token 雖然方便,但只要在 development mode 修改重新執行,token 就會不見

  • 沒有 token 或 token expired 只能存取 /login

  • 如何避免每個 API 都要傳入 token ?

  • 當 login 時,自動設定所有 API call 都有共用 token;當 logout 時,移除所有共用 token

Vuex

auth.js

let setToken = (state, { token }) => state.token = token

let isAuth = state => !!state.token

let login = ({ commit }, { token }) => {
  localStorage.setItem('token', token)
  commit('setToken', { token })
}

let logout = ({ commit }) => {
  localStorage.removeItem('token')
  commit('setToken', { token: '' })
}

export default {
  namespaced: true,
  state: {
    token: localStorage.getItem('token') || '',
  },
  mutations: {
    setToken,
  },
  getters: {
    isAuth
  },
  actions: {
    login,
    logout
  }
}

17 行

state: {
  token: localStorage.getItem('token') || '',
},

token state 骨子裡一樣使用 local storage,若 local storage 不存在則為 empty string。

Vuex 搭配 local storage 可避免 development mode 修改時重新執行 token 不見,此時會自動讀取 local storage 重建 token state

第 1 行

let setToken = (state, { token }) => state.token = token

實現 setToken() mutation。

第 5 行

let login = ({ commit }, { token }) => {
  localStorage.setItem('token', token)
  commit('setToken', { token })
}

實現 login action,當 login 成功取得 token 時呼叫,有兩個目的:

  • 寫入 local storage,屬於 side effect
  • 寫入 token state

第 10 行

let logout = ({ commit }) => {
  localStorage.removeItem('token')
  commit('setToken', { token: '' })
}

實現 logout action,當 logout 時呼叫,有兩個目的:

  • 清空 local storage,屬於 side effect
  • 將 empty string 寫入 token state

第 3 行

let isAuth = state => !!state.token

實現 isAuth token,判斷 token 是否存在,主要用於沒有 token 只能存取 /login

Login

let login = function() {
  pipe(
    loginAccount,
    andThen(x => x.data.access_token),
    andThen(x => {
      this.$store.dispatch('auth/login', { token: x })
      this.$store.dispatch('user/login', { username: this.username })
    }),
    andThen(_ => this.$router.push('/face/history')),
    otherwise(x => x && x.response && console.log(x.response.data))
  )({
    username: this.username,
    password: this.password
  })
}

第 3 行

loginAccount,

為 API function,負責傳入 username 與 password 取得 token。

第 6 行

this.$store.dispatch('auth/login', { token: x })

將 token 傳入 auth/login action 登入。

Logout

let logout = function() {
  swal({
    title: '你確定要登出嗎 ?',
    type: 'warning',
    showCancelButton: true,
    confirmButtonClass: 'btn btn-success btn-fill',
    cancelButtonClass: 'btn btn-danger btn-fill',
    confirmButtonText: '確定',
    buttonsStyling: false
  }).then((function() {
      this.$store.dispatch('auth/logout')
      this.$router.push('/')
    }).bind(this)
  ).catch(x => console.log(x))
}

11 行

this.$store.dispatch('auth/logout')

呼叫 auth/logout action 登出。

Auto-authentication

App.vue

let created = () => {
  axios.interceptors.request.use(x => {
    let token = store.state.auth.token
    token && (x.headers.Authorization = 'Bearer ' + token)
    return x
  }, e => Promise.reject(e))

  axios.interceptors.response.use(undefined, function (err) {
    return new Promise(function () {
      if (err.status === 401 && err.config && !err.config.__isRetryRequest) {
        this.$store.dispatch('auth/logout')
        this.$router.push('/')
      }
      throw err
    })
  })
}

第 2 行

axios.interceptors.request.use(x => {
  let token = store.state.auth.token
  token && (x.headers.Authorization = 'Bearer ' + token)
  return x
}, e => Promise.reject(e))

當每次使用 Axios 時,會自動從 Vuex 取得 token,不必自行準備 token 給 API function。

第 8 行

axios.interceptors.response.use(undefined, function (err) {
  return new Promise(function () {
    if (err.status === 401 && err.config && !err.config.__isRetryRequest) {
      this.$store.dispatch('auth/logout')
      this.$router.push('/')
    }
    throw err
  })
})

當 token expired 時,會自動呼叫 auth/logout action 登出。

Authenticated Routes

routes.js

let auth = (to, from, next) => {
  if (store.getters['auth/isAuth']) {
    next()
    return
  }

  next('/login')
}

let routes = [
  {
    path: '/',
    name: 'Root',
    redirect: '/login'
  },
  {
    path: '/login',
    name: 'Login',
    component: Login
  },
  {
    path: '/face',    
    beforeEnter: auth,
    component: () => import('src/views/face/RecognizeFace.vue')
   }
]

第 1 行

let auth = (to, from, next) => {
  if (store.getters['auth/isAuth']) {
    next()
    return
  }

  next('/login')
}

auth/isAuth getter 判斷是否已經通過驗證,若通過驗證則維持原來 route,否則自動導到 /login

21 行

{
  path: '/face',    
  beforeEnter: auth,
  component: () => import('src/views/face/RecognizeFace.vue')
}

再要驗證的 route 加上 beforeEnter property,並將 auth() 指定給它,如此只有通過驗證才能進入該 route。

Conclusion

  • Vuex 在實務上或許沒那麼常用,但以 Vuex 管理 JWT token 則非常方便優雅

Reference

Thibaud, Authentication Best Practices for Vue
Chris Nwamba, Handling Authentication in Vue Using Vuex
蘇靚軒 Jenson, Vue 專案中的 API 管理及封裝 - JWT 身份驗證篇