Usually, WHEN starting to work with a new framework or language, I try to find as many best practices as possible, and I prefer to start with a good structure that is easy to understand, maintain, and upgrade. In this article, I’ll try to explain my thinking and combine all the knowledge I’ve gained over the years with the latest and best Web development practices.

Together, we’ll build a simple project that handles authentication and prepares the basic scaffolding to use when building the rest of the application.

We will use:

  • Vue. Js 2.5 and Vue – CLI
  • Vuex 3.0
  • Axios 0.18
  • Vue Router3.0

This is the final project structure. It is assumed that you have read the Vue, Vuex, and Vue Router documentation and understand the basics.

├── trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash ├─ trash │ ├ ─ ─ API. Service. Js │ ├ ─ ─ storage. Service. Js │ └ ─ ─ the user. The service. The js ├ ─ ─ store │ ├ ─ ─ auth. The module. The js │ └ ─ ─ index. The js └ ─ ─ Views ├─ About. Vue ├─ Home. Vue ├─ vueCopy the code

Protected pages First, let’s protect some urls so that only logged-in users can access them. To do this, we need to edit router.js. The approach I took was to keep all pages private, except those we marked as public directly. Set visibility to private by default and explicitly expose the route to be exposed.

In the following code, we use the meta parameter from the Vue Router. After login authorization, redirects to the page they tried to access before logging in. For the login view, it is only accessible when the user is not logged in, so we added a meta-field called onlyWhenLoggedOut, set to true.

import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import LoginView from './views/LoginView.vue'
import { TokenService } from './services/storage.service'

Vue.use(Router)

const router =  new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/login',
      name: 'login',
      component: LoginView,
      meta: {
        public: true// Access onlyWhenLoggedOut is allowed when you are not logged in:true
      }
    },
    {
      path: '/about',
      name: 'about', // Route level code split // this generates a separate chunk (about.[hash].js) // Lazy load Component: () => import(/* webpackChunkName:"about"* /'./views/About.vue') }, ] }) router.beforeEach((to, from, next) => { const isPublic = to.matched.some(record => record.meta.public) const onlyWhenLoggedOut = to.matched.some(record => record.meta.onlyWhenLoggedOut) const loggedIn = !! TokenService.getToken();if(! isPublic && ! loggedIn) {return next({
      path:'/login', query: {redirect: to.fullPath} // Store access path, redirect after login}); } // Do not allow users to access the login registration page if they are not logged inif (loggedIn && onlyWhenLoggedOut) {
    return next('/')
  }

  next();
})


export default router;
Copy the code

You’ll notice that we imported the TokenService, which returns the token. TokenService is in the services/storage.service.js file. It encapsulates and handles the logic for storing, accessing, and retrieving tokens locally in localStorage.

This way, we can safely migrate from localStorage to cookies without worrying about breaking other services or components that directly access localStorage. This is a good practice to avoid future trouble. The code in storage.service.js looks like this:

const TOKEN_KEY = 'access_token'
const REFRESH_TOKEN_KEY = 'refresh_token'/** * Manages access to token storage and gets from local storage ** the current storage implementation is usedlocal**/ const TokenService = {getToken() {
        return localStorage.getItem(TOKEN_KEY)
    },

    saveToken(accessToken) {
        localStorage.setItem(TOKEN_KEY, accessToken)
    },

    removeToken() {
        localStorage.removeItem(TOKEN_KEY)
    },

    getRefreshToken() {
        return localStorage.getItem(REFRESH_TOKEN_KEY)
    },

    saveRefreshToken(refreshToken) {
        localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
    },

    removeRefreshToken() {
        localStorage.removeItem(REFRESH_TOKEN_KEY)
    }

}

export { TokenService }
Copy the code

For API interactions, we can use the same logic as in TokenService. Provide a basic service that will do all the interaction with the network so that we can easily change or upgrade content in the future. That’s exactly what we’re trying to accomplish with api.service.js — encapsulating the Axios library so that when new business logic inevitably emerges, we can upgrade that single service without refactoring the entire application. Any other service that needs to interact with the API simply imports the ApiService and makes the request through the methods we’ve implemented.

import axios from 'axios'
import { TokenService } from '.. /services/storage.service'

const ApiService = {

    init(baseURL) {
        axios.defaults.baseURL = baseURL;
    },

    setHeader() {
        axios.defaults.headers.common["Authorization"] = `Bearer ${TokenService.getToken()}`},removeHeader() {
        axios.defaults.headers.common = {}
    },

    get(resource) {
        return axios.get(resource)
    },

    post(resource, data) {
        return axios.post(resource, data)
    },

    put(resource, data) {
        return axios.put(resource, data)
    },

    delete(resource) {
        returnAxios.delete (resource)}, /** * Execute custom AXIos request. ** Attribute parameters: * -method * -url * -data... request payload * - auth (optional) * - username * - password **/ customRequest(data) {return axios(data)
    }
}

export default ApiService
Copy the code

You may have noticed that there is an init and setHeader function there. We’ll initialize ApiService in main.js to ensure that if the user refreshes the page, the header is reset and the baseURL property is set.

To dynamically change urls in the Development, Stageing, and Production environments, I used Vue CLI environment variables.

In the main.js file, after importing the relevant service modules, execute the following lines:

// Set API base URL ApiService. Init (process.env.vue_app_root_api) // If token exists, set headerif (TokenService.getToken()) {
  ApiService.setHeader()
}
Copy the code

So far, we know how to redirect users to the login page, and we’ve completed some basic boilerplate code to help keep our projects clean and maintainable. Let’s start exploring user.service.js so we can actually make requests and learn how to use the ApiService we just created.

import ApiService from './api.service'
import { TokenService } from './storage.service'class AuthenticationError extends Error { constructor(errorCode, message) { super(message) this.name = this.constructor.name this.message = message this.errorCode = errorCode } } const UserService = {/** * Saves the access token to TokenService ** @returns access_token * @throws AuthenticationError **/ login: asyncfunction(email, password) {
        const requestData = {
            method: 'post',
            url: "/o/token/",
            data: {
                grant_type: 'password', username: email, password: password }, auth: { username: process.env.VUE_APP_CLIENT_ID, password: process.env.VUE_APP_CLIENT_SECRET } } try { const response = await ApiService.customRequest(requestData) TokenService.saveToken(response.data.access_token) TokenService.saveRefreshToken(response.data.refresh_token) ApiService. SetHeader ()/behind/talk about ApiService. Mount401Interceptor ();returnresponse.data.access_token } catch (error) { throw new AuthenticationError(error.response.status, The error, the response data. The detail)}}, / * * * * * / refreshToken: refresh the access token asyncfunction() {
        const refreshToken = TokenService.getRefreshToken()

        const requestData = {
            method: 'post',
            url: "/o/token/",
            data: {
                grant_type: 'refresh_token', refresh_token: refreshToken }, auth: { username: process.env.VUE_APP_CLIENT_ID, password: process.env.VUE_APP_CLIENT_SECRET } } try { const response = await ApiService.customRequest(requestData) TokenService.saveToken(response.data.access_token) TokenService.saveRefreshToken(response.data.refresh_token) // Refresh header ApiService. SetHeader ()returnresponse.data.access_token } catch (error) { throw new AuthenticationError(error.response.status, Error, the response data. The detail)}}, / * * * by removing token, * * will delete ` logout the current user Authorization Bearer < token > ` header * * /logout() {// delete token, And delete the Api Service Authorization header TokenService. RemoveToken () TokenService. RemoveRefreshToken () ApiService. RemoveHeader ()/behind/when it comes to ApiService unmount401Interceptor ()}}export default UserService

export { UserService, AuthenticationError }
Copy the code

We implemented a UserService with three methods: login – prepare the request and retrieve the token logout from the API service – clear user information logout from the browser store refresh token – retrieve the refresh token from the API service

If you notice, you’ll notice there’s a mysterious 401 interceptor logic there – we’ll address that later.

Should I put it in the Vuex Store or Component? It seems like a good habit to put as much logic into the Vuex store as possible. First, this is good because you can reuse state and business logic in different components.

For example, suppose that users are allowed to log in or register at multiple locations in the application, such as while checking out from an online store (if it’s an online store). You may use other Vue components for this UI element. By placing the state and logic in the Vuex store, you can reuse the state and logic by writing a few short import statements in the Component, as follows:

<script>
import { mapGetters, mapActions } from "vuex";


export default {
  name: "login".data() {
    return {
      email: "",
      password: ""}; }, computed: { ... mapGetters('auth'['authenticating'.'authenticationError'.'authenticationErrorCode']) }, methods: { ... mapActions('auth'['login'
      ]),

      handleSubmit() {// Verify email and passwordif(this.email ! =' '&& this.password ! =' ') {
            this.login({email: this.email, password: this.password})
            this.password = ""}}}}; </script>Copy the code

In the Vue component, you will import logic from the Vuex Store and map state or fetch methods to your calculated properties and actions to your methods. You can read more about mapping here.

Use user.service.js in the Vuex store auth.module.js code as follows:

import { UserService, AuthenticationError } from '.. /services/user.service'
import { TokenService } from '.. /services/storage.service'
import router from '.. /router'


const state =  {
    authenticating: false,
    accessToken: TokenService.getToken(),
    authenticationErrorCode: 0,
    authenticationError: ' '
}

const getters = {
    loggedIn: (state) => {
        return state.accessToken ? true : false
    },

    authenticationErrorCode: (state) => {
        return state.authenticationErrorCode
    },

    authenticationError: (state) => {
        return state.authenticationError
    },

    authenticating: (state) => {
        return state.authenticating
    }
}

const actions = {
    async login({ commit }, {email, password}) {
        commit('loginRequest');

        try {
            const token = await UserService.login(email, password);
            commit('loginSuccess'Before, token) / / to redirect the user to try to access the page, the router or the front page. Push (router. History. Current. Query. Redirect | |'/');

            return true
        } catch (e) {
            if (e instanceof AuthenticationError) {
                commit('loginError', {errorCode: e.errorCode, errorMessage: e.message})
            }

            return false}},logout({ commit }) {
        UserService.logout()
        commit('logoutSuccess')
        router.push('/login')
    }
}

const mutations = {
    loginRequest(state) {
        state.authenticating = true;
        state.authenticationError = ' '
        state.authenticationErrorCode = 0
    },

    loginSuccess(state, accessToken) {
        state.accessToken = accessToken
        state.authenticating = false;
    },

    loginError(state, {errorCode, errorMessage}) {
        state.authenticating = false
        state.authenticationErrorCode = errorCode
        state.authenticationError = errorMessage
    },

    logoutSuccess(state) {
        state.accessToken = ' '}}export const auth = {
    namespaced: true,
    state,
    getters,
    actions,
    mutations
}
Copy the code

This covers almost everything you need to set up your project and hopefully will help you keep it clean and maintainable.

Now, extracting more data from the API should be easy – just create a new.service.js inside the service, write helper methods, and access the API through the ApiService we made. To display this data, create a Vuex Store and respond using the State Store API – using it in the component through mapState and mapActions. This allows you to reuse logic in the future if you need to display or manipulate the same data in other components.

Added: How do I refresh expired access tokens?

Regarding authentication, it is difficult to handle token refreshes or 401 errors (token invalidation) and is therefore ignored by many tutorials. In some cases, it might be better to simply log out of the user when a 401 error occurs, but let’s look at how to refresh the access token without disrupting the user experience. This is the 401 interceptor in the code example mentioned above.

In our ApiService, we will add the following code to install the Axios response interceptor.

. import { store } from'.. /store'Const ApiService = {// Save 401interceptor, which can later be used to cancel _401Interceptor: null,...mount401Interceptor() {
        this._401interceptor = axios.interceptors.response.use(
            (response) => {
                return response
            },
            async (error) => {
                if (error.request.status == 401) {
                    if (error.config.url.includes('/o/token/'// Failed to refresh the token, user logs out of store.dispatch('auth/logout')
                        throw error
                    } else{// Refresh token try{await store.dispatch('auth/refreshToken') // Retry the requestreturnthis.customRequest({ method: error.config.method, url: error.config.url, data: Error. Config. data})} catch (e) {throw error}}}unmount401Interceptor() {/ / cancellation of 401 blocker axios. Interceptors. Response. Eject (enclosing _401interceptor)}}Copy the code

What the above code does is intercept each API response and check if the status of the response is 401. If so, we are checking to see if 401 occurs on the token refresh call itself (we don’t want to get caught in a loop) permanently refresh the token!) . The code then refreshes the token and retries the failed request, returning the response to the caller.

We are sending a call to the Vuex Store here to perform a token refresh. The code we need to add to auth.module.js is:

const state = { ... RefreshTokenPromise: null // save refreshTokenPromise} const actions = {... RefreshToken ({commit, state}) {// If it is the first call, make the request. // If it is not, return the saved refreshTokenPromise and do not make the request againif(! state.refreshTokenPromise) { const p = UserService.refreshToken() commit('refreshTokenPromise', p) // Wait for the UserService.refreshToken() promise to complete. If successful, the token is set and refreshTokenPromise is cleared. // Also clear refreshTokenPromise p.hen (response => {commit('refreshTokenPromise', null)
                    commit('loginSuccess', response)
                },
                error => {
                    commit('refreshTokenPromise', null)
                }
            )
        }

        return state.refreshTokenPromise
    }
}

const mutations = {
    
    ...

    refreshTokenPromise(state, promise) {
        state.refreshTokenPromise = promise
    }
}
Copy the code

Your application may perform several API requests to get the data that needs to be displayed. If the access token expires, all requests fail and therefore trigger a token refresh in the 401 interceptor. In the long run, this will refresh each request token, which is not good.

There are solutions for queuing requests and processing them in a queue when 401 occurs, but for me at least, the code above provides a more elegant solution. By saving the refresh token Promise and returning the same promise to each refresh token request, we can ensure that the token is refreshed only once.

You also need to install the 401 interceptor in main.js immediately after setting the request header.

PS: You can simply check the expiration of the page load and then refresh the token as well, but this is not appropriate for a long session where the user does not refresh the page at all.

More articles: Zhaima.tech