构建Vue项目-身份验证

211 阅读9分钟

通常,在开始使用新框架或新语言工作时,我会尝试查找尽可能多的最佳实践,而我更喜欢从一个易于理解,维护和升级的良好结构开始。 在这篇文章中,我将尝试解释自己的想法,并将过去几年中获得的所有知识与最新,最好的Web开发实践结合起来。

我们将共同构建一个简单的项目,该项目处理身份验证并准备在构建应用程序其余部分时要使用的基本脚手架。

我们将使用:

  • Vue.js 2.5 和 Vue-CLI
  • Vuex 3.0
  • Axios 0.18
  • Vue Router3.0

这是最终项目结构。 假设您已经阅读了Vue,Vuex和Vue Router文档,并且了解了其中的基础知识。

└── src
    ├── App.vue
    ├── assets
    │   └── logo.png
    ├── components
    │   └── HelloWorld.vue
    ├── main.js
    ├── router.js
    ├── services
    │   ├── api.service.js
    │   ├── storage.service.js
    │   └── user.service.js
    ├── store
    │   ├── auth.module.js
    │   └── index.js
    └── views
        ├── About.vue
        ├── Home.vue
        └── LoginView.vue

受保护的页面 首先,让我们保护某些URL,使其仅登录用户才能访问。 为此,我们需要编辑router.js。 我采用的方法是所有页面都是私有的,除了我们直接标记为公共的页面之外。将可见性默认设置为私有,并通过显式地公开要公开的路由。

在下面的代码中,我们会使用Vue Router中的meta参数。 登录授权之后,将重定向到他们登录之前尝试访问的页面。 对于登录视图,它仅在用户未登录时才可访问,因此我们添加了一个名为onlyWhenLoggedOut的元字段,设置为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,  // 未登录时,允许访问
        onlyWhenLoggedOut: true
      }
    },
    {
      path: '/about',
      name: 'about',
      // 路由级别代码分割
      // 这个会生成一个单独的chunk (about.[hash].js)
      // 当路由访问时候,进行懒加载
      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}  // 存储访问路径,登陆后重定向使用
    });
  }

  // 不允许用户访问登录注册页面,如果未登录
  if (loggedIn && onlyWhenLoggedOut) {
    return next('/')
  }

  next();
})


export default router;

您会注意到,我们导入了TokenService,该服务会返回token。 TokenService在services / storage.service.js文件中,它负责封装和处理localStorage本地存储,访问,检索令牌的逻辑。

这样,我们就可以安全地从localStorage迁移到Cookie,而不必担心会破坏其他直接访问本地存储的服务或组件。 这是一个很好的做法,可以避免将来出现麻烦。 storage.service.js中的代码如下所示:

const TOKEN_KEY = 'access_token'
const REFRESH_TOKEN_KEY = 'refresh_token'

/**
 * 管理访问令牌存储和获取,从本地存储中
 *
 * 当前存储实现是使用localStorage. Local Storage可以被这个实例一直访问到
**/
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 }

发出API请求 关于API交互,我们可以使用与TokenService中相同的逻辑。 提供一个基本服务,它将与网络进行所有交互,以便我们将来可以轻松地更改或升级内容。 这正是我们使用api.service.js所要实现的目标—封装Axios库,以便在不可避免地出现新业务逻辑时,我们可以只对该单一服务进行升级,而不必重构整个应用程序。 任何其他需要与API交互的服务都只需导入ApiService并通过我们已实现的方法发出请求。

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) {
        return axios.delete(resource)
    },

    /**
     * 执行自定义Axios request.
     *
     * 属性参数:
     *  - method
     *  - url
     *  - data ... request payload
     *  - auth (optional)
     *    - username
     *    - password
    **/
    customRequest(data) {
        return axios(data)
    }
}

export default ApiService

您可能已经注意到那里有一个init和setHeader函数。 我们将在main.js中初始化ApiService,以确保如果用户刷新页面后,重新设置header,并设置baseURL属性。

为了在development,stageing和production环境中动态更改URL,我使用了Vue CLI环境变量。

在main.js文件中,导入相关服务模块之后,然后执行以下几行:

// 设置API base URL
ApiService.init(process.env.VUE_APP_ROOT_API)

// 如果token存在,那就设置header
if (TokenService.getToken()) {
  ApiService.setHeader()
}

到现在为止,我们知道了如何将用户重定向到登录页面,并且已经完成了一些基本的样板代码,这些代码可以帮助我们保持整洁且可维护的项目。 让我们开始研究user.service.js,这样我们就可以真正发出请求,并了解如何使用我们刚创建的ApiService。

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 = {
    /**
     * 用户登录,保存访问令牌到TokenService
     * 
     * @returns access_token
     * @throws AuthenticationError 
    **/
    login: async function(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()
            
            // 后边会讲到
            ApiService.mount401Interceptor();

            return response.data.access_token
        } catch (error) {
            throw new AuthenticationError(error.response.status, error.response.data.detail)
        }
    },

    /**
     * 刷新访问令牌
    **/
    refreshToken: async function() {
        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)

            // 刷新ApiService中的header
            ApiService.setHeader()

            return response.data.access_token
        } catch (error) {
            throw new AuthenticationError(error.response.status, error.response.data.detail)
        }

    },

    /**
     * 通过删除token,登出当前用户
     * 
     * 也会删除`Authorization Bearer <token>` header
    **/
    logout() {
        // 删除token, 并且删除Api Service中的Authorization header
        TokenService.removeToken()
        TokenService.removeRefreshToken()
        ApiService.removeHeader()
        
        // 后边讲到
        ApiService.unmount401Interceptor()
    }
}

export default UserService

export { UserService, AuthenticationError }

我们实现了具有3种方法的UserService: login - 准备请求并通过API服务从API获取令牌 logout - 从浏览器存储中清除用户资料 refresh token - 从API服务获取刷新令牌

如果您注意到了,您会发现那里有一个神秘的401拦截器逻辑-我们稍后将解决。

我应该将其放在Vuex Store 或 Component中吗? 将尽可能多的逻辑放入Vuex存储中似乎是一个好习惯。首先,这很好,因为您可以在不同的组件中重用状态和业务逻辑。

例如,假设允许用户在应用的多个位置登录或注册,比如通过在线商店结帐时(如果是在线商店)登录或注册。您可能会对该UI元素使用其他Vue组件。通过将状态和逻辑放置在Vuex存储中,您将能够重用状态和逻辑,并只需在Component中编写一些简短的import语句,如下所示:

<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() {
          // 进行email 和 password 校验
          if (this.email != '' && this.password != '') {
            this.login({email: this.email, password: this.password})
            this.password = ""
          }
      }
  }
};
</script>

在Vue组件中,您将从Vuex Store导入逻辑,并将状态或获取方法映射到您的计算属性,并将操作映射到您的方法。 您可以在此处阅读有关映射的更多信息。

在Vuex store auth.module.js代码中使用user.service.js,如下所示:

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', token)

            // 重定向用户到之前尝试访问的页面,或者首页
            router.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
}

这几乎涵盖了设置项目所需的一切,希望可以帮助您保持项目的干净和可维护。

现在,从API提取更多数据应该很容易-只需在服务内部创建一个新的 .service.js,编写辅助方法并通过我们制作的ApiService访问API。要显示此数据,创建一个Vuex Store, 并使用state存储API响应—通过mapState和mapActions在组件中使用它。这样,如果您需要在其他组件中显示或操作相同的数据,将来便可以重用逻辑。

补充:如何刷新过期的访问令牌?

关于身份验证,要处理令牌刷新或401错误(token失效)比较困难,因此被许多教程所忽略。在某些情况下,最好是在发生401错误时简单地注销用户,但是让我们看看如何在不中断用户体验的情况下刷新访问令牌。这是上面提到的代码示例中的401拦截器。

在我们的ApiService中,我们将添加以下代码来安装Axios响应拦截器。

...

import { store } from '../store'

const ApiService = {

    // 保存401拦截器,之后可以用来注销
    _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/')) {
                        // 刷新令牌失败,用户登出
                        store.dispatch('auth/logout')
                        throw error
                    } else {
                        // 刷新token令牌
                        try{
                            await store.dispatch('auth/refreshToken')
                            // 重新尝试发起请求
                            return this.customRequest({
                                method: error.config.method,
                                url: error.config.url,
                                data: error.config.data
                            })
                        } catch (e) {
                            // 刷新失败 - 拒绝请求,抛出异常
                            throw error
                        }
                    }
                }

                // 非401错误,直接抛出错误
                throw error
            }
        )
    },

    unmount401Interceptor() {
        // 注销401拦截器
        axios.interceptors.response.eject(this._401interceptor)
    }
}

上面的代码要做的是拦截每个API响应,并检查响应的状态是否为401。如果是,则我们正在检查401是否在令牌刷新调用本身上发生(我们不想陷入循环中) 永久刷新令牌!)。 然后,代码将刷新令牌并重试失败的请求,并将响应返回给调用方。

我们正在向此处的Vuex Store发送呼叫,以执行令牌刷新。 我们需要添加到auth.module.js中的代码是:

const state =  {
    ...

    refreshTokenPromise: null  // 保存刷新token的promise
}


const actions = {
  
    ...
  
    refreshToken({ commit, state }) {
        // 如果是第一次调用,发起请求
        // 如果不是,返回保存的这个refreshTokenPromise,不再发起请求
        if(!state.refreshTokenPromise) {
            const p = UserService.refreshToken()
            commit('refreshTokenPromise', p)

            // 等待UserService.refreshToken()这个promise执行完。如果成功,就设置token,清除refreshTokenPromise。
            // 如果失败,也清除refreshTokenPromise
            p.then(
                response => {
                    commit('refreshTokenPromise', null)
                    commit('loginSuccess', response)
                },
                error => {
                    commit('refreshTokenPromise', null)
                }
            )
        }

        return state.refreshTokenPromise
    }
}

const mutations = {
    
    ...

    refreshTokenPromise(state, promise) {
        state.refreshTokenPromise = promise
    }
}

您的应用可能会执行几个API请求,以获取需要显示的数据。 如果访问令牌到期,所有请求将失败,并因此触发401拦截器中的令牌刷新。 从长远来看,这将刷新每个请求的令牌,这样不太好。

有一些解决方案可以在401发生时将请求排入队列并在队列中处理它们,但是至少对于我来说,上面的代码提供了一种更为优雅的解决方案。 通过保存刷新令牌promise,并向每个刷新令牌请求返回相同的promise,我们可以确保令牌仅刷新一次。

您还需要在设置请求header之后立即在main.js中安装401拦截器。

PS:您可以简单地检查页面加载的到期时间,然后也刷新令牌,但这不适用于用户根本不刷新页面的长期会话。

更多文章:zhaima.tech