1

功能概述

项目使用前后的分离的开发模式,后端使用Spring Security实现基于Jwt的用户认证模式,数据交互使用Json格式。前端使用Nuxt框架实现服务端渲染(SSR)功能,使用Vuex实现登录状态存储,使用@nuxtjs/axios插件加载数据。用户登录后就会一直处于登录状态,除非用户主动登出或连续7天未访问网站才会要求重新登录。

后端大体流程

  1. 用户通过浏览器输入账号密码进行登录
  2. 后台java程序认证成功后通过Http Header 返回 accessTokenrefreshTokentokenRefreshed(boolean)
  3. accessToken 有效期2小时,主要用于访问需要认证授权的接口(如果有效期太长,有期限其用户权限等信息发生变化后无法及时反映到token中)
  4. refreshToken 有效期7天,其唯一的作用就是再accessToken过期后,用户可以在不用重新登录的情况下换取新的accessToken
  5. tookenRefreshed 告知客户端Token是否已刷新,如果为true客户端必须存储新的Token
  6. 后台每次收到Jwt的认证请求时都会判断accessToken是否快过期(此时的accessToken还未过期,还是有效的,如果过期了就会发送认证失败的Response给客户端)。如果快过期了,将自动创建新的accessTokenrefreshToken放入Http Header中,随本次请求的结果一起返回给客户端

前端方案

在介绍前端方案前,先简单说下用户访问需要权限认证的两种不同情况:

1、用户直接在浏览器地址栏中输入链接或点击一个普通 <a> 标签的链接。
2、用户点击<nuxt-link>方式构建的链接

第一种情况的 Http 请求由浏览器自动构建,首先发送到部署Nuxt的Node服务器上(SSR的Server端),然后再Server端构建Nuxt及Vuex相关对象,此时是获取不到保存再客户端(浏览器)中Token信息的。浏览器在未收到响应之前,浏览器中没有任何Nuxt或Vuex相关的实例对象(不能进行任何JS操作)。此时如果想携带保存在客户端的Token信息,只能通过Cookie实现(浏览器在发送Http请求时会自动带上客户端的Cookie信息)

第二种情况的路由跳转是在客户端进行的,真正发送HTTP请求一般都是在程序中通过Axios构建,然后再发送到部署Nuxt的Node服务端。因此,在发送请求前可以方便获取到VuexLocalstorageCookie等任何位置保存的Token信息,然后添加到Request中发送到Server端。

这两种情况的主要区别在于,如何携带认证所需的Token。这两种情况是下面两种方案都要考虑的,由于第二种情况限制少,主要考虑第一种情况中的限制。

方案一 使用 Nuxt 提供的middleware功能实现

中间件(middleware)允许定义一个自定义函数运行在一个页面或一组页面渲染之前,因此,可以在每次访问页面前都先判断accessToken是否已过期,如果已过期,则刷新token。 middleware 的具体用法可参考官方文档。

  1. 用户在浏览器中执行登录认证后,通过Axios的Response拦截器将获取到的 accessTokenrefreshToken, 存储在Vuex中。
  2. 使用vuex-persistedstate将Vuex中的Token信息持久化到Cookie中,且只能存在Cookie中。否则无法解决上面第一种情况中的限制。
  3. 创建一个refreshToken.js的 middleware 配置在需要Token认证的页面(可以全局配置,也可以单独配置某些页面)
  4. 在Nuxt的页面组件中提供的syncDatafetch方法中执行加载数据的请求

核心代码如下:

第一步:创建refreshToken中间件,并配置

// refreshToken.js

import { decode } from 'js-base64';
import {isEmpty} from "@/plugins/common-util";

// 距离token过期时间提前2分钟刷新token,防止客户端与服务端时间差
const DISTANCE_EXP_TIME = 2 * 60;

export default async function ({store, app, req}){

    //1、获取cookie或vuex中的accessToken
    let accessToken = '';
    if(process.server){ //这种就是直接再浏览器中输入url的,再服务端进行刷新token的情况
        if(isEmpty(req.headers.Authorization)){
            let cookie = req.headers.cookie
            if(cookie != null && cookie !== '' && cookie){
                cookie = cookie.split('=')
                if(cookie.length === 2){
                    let cookieValue = JSON.parse(decodeURIComponent(cookie[1]))
                    accessToken = cookieValue.user.accessTokenStr;
                }
            }
        } 
   }else { //这种客户端渲染的情况浏览器中有完整的VUE VUEX之类的js对象,可以直接获取
      accessToken = store.state.user.accessTokenStr
   }
   
   //2、判断是否需要刷新token
   if(needRefreshToken(accessToken)){
      
      //3、刷新token
      let bundle = await app.$userSecurity.refreshToken()
      // 此处和axios插件中任选一个地方更新token即可
      //store.commit('user/setToken', bundle);
   }else {
      console.log('--->> 不需要刷新token')
   }
}

// 判断accessToken是否需要刷新
function needRefreshToken(accessToken){
    if(accessToken){
        let payload = accessToken.split('.')[1]
        payload = decode(payload)
        payload = JSON.parse(payload)
        let exp = payload.exp
        let time = Math.round(new Date().getTime()/1000)
        if((exp - time) <= DISTANCE_EXP_TIME){
            return true
        }
    } 
    return false
}
// nuxt.config.js中全局配置 refreshToke 中间件,
// 全局配置后每个页面组件渲染前都会执行 refreshToken中间件
router: {
    middleware: 'refreshToken'
}

第二步:再@nuxtjs/axios插件的Response拦截器中处理HttpResponse中携带的新token

import {isEmpty} from "./common-util";
export default function ({ app, $axios, store, req, redirect, route }) {
     // 基本配置
     $axios.baseUrl = process.env.apiBasePath;
     $axios.defaults.timeout = 3000000
     $axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded'
     $axios.defaults.headers.ClientType = 'PC'
     
     // 请求回调
     $axios.onRequest(config => {
         console.log('send request to: ', config.url)
         setToken(req, $axios, store, config)
     })
     
     // 返回回调
     $axios.onResponse(response => {
         updateToken(response, store)
     })
     
     // 请求失败时的默认操作
     $axios.onError(error => {

     })
}

/**
 * 给每个请求头中加入token
 */
 const setToken = function (req, $axios, store, config) {
     // SSR的Server端执行设置token
     if(process.server){

         let accessToken = store.state.user.accessTokenStr;
         if(accessToken && config.url !== 'refresh/token'){
         
            // 如果进入此处,应该是Server端执已经执行了刷新Token的操作(refreshToken中间件中执行的)
            // Server端的Vuex中已经有新的Token值,再Server端渲染完成后,
            // Nuxt会将Vuex中新的Token值随着Response一起传递到客户端(浏览器中)
            config.headers.Authorization = accessToken
            
         }else if(isEmpty(config.headers.Authorization)){
             
             // 如果进入此处,应该就是在浏览器中输入直接输入了URL,浏览器构建了普通的HttpRequest
             // 直接发送到了Nuxt部署的NodeServer中,此时从Vuex中是获取不到Token数据的
             // 因此Request Header中的Authorization 是空的,
             // 只有原始HttpRequest携带的Cookie中有Token数据
             let cookie = req.headers.cookie
             if(cookie != null && cookie !== '' && cookie){
                 cookie = cookie.split('=')
                 if(cookie.length === 2){
                     let cookieObj = JSON.parse(decodeURIComponent(cookie[1]))
                     // 如果是刷新token的请求,使用refreshToken,其他的请求使用accessToken
                     let token = config.url === 'refresh/token' ? cookieObj.user.refreshTokenStr : cookieObj.user.accessTokenStr;
                     if(token){
                        config.headers.Authorization = token
                     }
                 } 
             } 
         } 
     }else { //SSR的客户端设置token
     
         //console.log('----->> client 设置header', store.state.user.momentTokenStr)
         let token = config.url === 'refresh/token' ? store.state.user.refreshTokenStr : store.state.user.accessTokenStr
         if(token){
            config.headers.Authorization = token
         }
    }
}

/**
 * Request拦截器中执行
 * 将HttpResponse Header中携带的Token信息保存到Vuex中
 */
const updateToken = function (response, store) {

    let isRefreshed = response.headers.tokenrefreshed
    let accessToken = response.headers.authorization
    let refreshToken = response.headers.refreshtoken

    if(isRefreshed === "true" && !isEmpty(accessToken) && !isEmpty(refreshToken)){
        store.commit('user/setAccessToken', accessToken);
        store.commit('user/setRefreshToken', refreshToken);
    }else {
        console.log("---->>> 没有重置token")
    }
}

第三步:持久化Vuex中的Token值到Cookie,此处使用vuex-persistedstate插件

// vuex-persist.js
import createPersistedState from "vuex-persistedstate";
import * as Cookies from "js-cookie";
import {isEmpty} from "@/plugins/common-util";

const KEY = 'youselfKey';

export default ({store}) => {
 
    // 由于Server端相关操作导致Vuex中状态发生变化后,nuxt会通过window.__NUXT__返回给浏览器(客户端)
    // 因此在客户端是能取到Vuex中变化后的值(此时的值是在内存中),
    // 先另存Server端中修改的过的值,否则在createPersistedState执行后会被覆盖
    let serverSideAccessTokenStr = store.state.user.accessTokenStr
    let serverSideRefreshTokenStr = store.state.user.refreshTokenStr
    let serverSideMomentTokenStr = store.state.user.momentTokenStr

    // vuex-persistedstate插件的原理应该是监听store的Commit操作,且由于vuex-persistedstate插件只支持在客户端运行
    // 因此,如果是在Server端进行刷新Token保存在Vuex中的操作,vuex-persistedstate是监听不到的,
    // 即更新后的Token值不会被持久化到Cookie中,解决方法就是在客户端重新Commit一下
    createPersistedState({
        key: KEY,
        paths: [
            'user.accessTokenStr',  // 前面加 user. 是因为accessTokenStr存在user模块下
            'user.refreshTokenStr'
        ],
        storage: {
            getItem: (key) => Cookies.get(key),
            // secure: true 表示只有在https情况下才会发送cookie,不要随意加
            setItem: (key, value) => Cookies.set(key, value, { expires: 7/*, secure: true*/}),
            removeItem: (key) => Cookies.remove(key),
        }
    })(store)
    
    // 重新将Server中更新的Token值Commit一下,让插件监听到值的变化后进行自动保存
    if(!isEmpty(serverSideAccessTokenStr)){
        store.commit('user/setAccessToken', serverSideAccessTokenStr)
    } if(!isEmpty(serverSideRefreshTokenStr)){
        store.commit('user/setRefreshToken', serverSideRefreshTokenStr)
    } if(!isEmpty(serverSideMomentTokenStr)){
        store.commit('user/setRefreshToken', serverSideMomentTokenStr)
    }
}
// nuxt.config.js中配置 vuex-persist 插件,一定要配置成客户端模式
plugins: [
    // ssr: false 是指定该插件只在客户端运行
    {src: '~/plugins/vuex-persist', ssr: false},
    // 新的写法
    //{src: '~/plugins/vuex-persist', mode: "client"}
],

第四步:页面中使用Nuxt提供的生命周期函数asyncData或fetch中加载数据

<!-- 文章详情页,加载文章数据 -->
<template>
    ...
</template>
<script>
export default {
    async asyncData({app, params}) {

        // 此处是将所有获取数据的接口分钟到了Api模块中
        // 原生写法 this.$axios.$get(`articles/${id}`, {params: { extra: true }})
        let article = await app.$article.getArticleDetail(aid, true)
        return {
            article
        }
    }
}
</script>

方案一小结:由于Nuxt的 middleware 只在Server端执行,因此,方案一只能在Server端出现Token过期时自动刷新Token。如果是在客户端(浏览器)中获取数据时发生Token过期则不会自动刷新。所有,方案一不够完美,没有彻底解决问题。

方案二 使用 Axios 的拦截器实现

方案二目前只是个简单思路,由于对@nuxtjs/axios 以及ES6的异步功能理解的不是很透彻,暂时未能实现。

大概思路就是通过 axios 的 Request 和 Response 拦截器来实现。由于跟后端(java)接口交互获取数据,都是通过axios插件完成的,因此每次发送请求时都可以拦截并执行响应逻辑。

用Request拦截器实现,其实就是将方案一种在 middleware 中判断token过期的操作移到 Request 拦截器中,每次发送获取数据的请求都先判断Token是否过期,如果过期就行先刷新Token,然后再继续本次的请求。根据目前的尝试结果,无法保证刷新token的请求执行完成后再执行本次请求。

用Response拦截器实现,是再拦截到后端响应的Token过期的错误后,先不返回,直接再拦截器中刷新token,重新执行本次请求后,将最新的Respon结果返回。根据目前尝试的结果,只实现到刷新Token后重新执行本次请求,无法将最新的请求结果返回给页面中的调用处。


flan89
10 声望1 粉丝