使用vueuse/useFetch封装基于refresh_token的鉴权方案

1,669 阅读12分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第29天,点击查看活动详情

一、为什么要用token代替传统的登录认证方案

要讨论这个问题,要先从一般网页开发的认证方案说起,传统登录认证的流程一般如下:

  1. 用户输入账号密码向服务器发起登陆
  2. 服务器接收到登陆请求后,校验账号密码是否正确
  3. 由于http协议是无状态的,用户登陆成功,为了知道下次请求用户有没有登陆,服务端会往客户端写入一个标识,这个标识可以用session-id来表示,而写入的地方一般是session或cookie
  4. 登陆请求完成后,客户端的每次请求都会存在session-id,而服务端在需要判断用户是否登陆的地方,拿到session-id,去库里(或缓存)查询一下这个用户信息和登陆状态,如果session有效,则正常响应,否则需要重新登陆

以上的方案有一个缺陷,那就是每一个鉴权接口都需要去查库(或缓存),以获取用户的信息以及登陆状态,这个属于重复且成本并不低的操作。

为了避免每个鉴权请求都去查一次用户信息,token方案就登场了

二、为什么token是有效的?

token的意思是令牌,我们可以比喻成宿舍的门禁卡,在你入住宿舍的时候(登录),给你发了一个门禁卡(token),你离开宿舍不需要刷门禁,进入才需要,你不管从哪里回来、是不是你本人回来(请求来源),只要你有门禁卡(token校验通过),就可以进来,而这个门禁卡是加密的,密钥只有发放门禁卡的人才知道,所以只要密钥不泄露,门禁卡就是可信任的。

完成上面的几步后,还存在几个问题:

  1. 如果不小心门禁卡丢了呢?那我宿舍岂不是随便被别人进了?
  2. 服务端签发的token并没有任何记录,也就意味着一旦token签发,在限定期限内一直有效,那学校如果开除了一个学生(让该账号在客户端退出登录),除了让学生自己交出门禁卡(客户端自觉销毁本地token)外,学校就没别的办法让这个门禁卡失效了。

为了解决上面的问题,引入 access_token + refresh_token 双token方案来解决这个问题。

三、结合refresh_token的鉴权方案

access_token是无状态的,只要拿到这个 access_token ,不管持有者是人是鬼都可以通过验证,那如果我的access_token从签发后永久有效的门禁卡,升级成十分钟后就过期的短信验证码形式呢?是不是就大大降低了access_token被盗用的风险了。

access_token只能用十分钟,过期之后,就需要重新获取新的access_token进行接口验证,那access_token也不是随便就能重新获取的,短信验证码也要有手机号才能证明你是有权限的人吖,这个的手机号就是我们的 refresh_token,所以整个token鉴权逻辑就变成了这样:

  1. 用户通过账号密码登录,服务器验证通过后,返回给客户端 access_token 和 refresh_token,并且服务器 refresh_token 写入库内 (给你发了一张手机卡顺便附带了验证码,并把手机号记录下来)
  2. 客户端保存 access_token 和 refresh_token ,并且在所有请求头里都携带上access_token (回宿舍请输入验证码)
  3. 服务端处理请求时,先校验access_token是否存在?是不是我们签发的?过期了没有?如果有一项没有通过校验,就通知客户端你这个 acces_token 没用,重新拿一个
  4. 客户端收到了服务端的 access_token过期的消息后,用refresh_token去重新获取access_token
  5. 服务端收到客户端的refresh_token后,去库里查一下,这个refresh_token是否存在?状态是否异常?是不是被拉黑了等等,如果没问题就给客户端发送一个新的access_token
  6. 客户端重新获取到access_token后,代替原有的access_token继续请求

第二个问题也可以解决了,access_token虽然无状态,但refresh_token有状态,如果一个用户被拉黑禁止登录,那最晚十分钟后,也会被强制下线(所以有些企业为了安全性更高,access_token的有效市场会设置的更短,比如一分钟)。

四、useFetch的封装

想要将refresh_token鉴权方案封装进useFetch,其实很简单,其核心的逻辑就在于:在响应拦截中,如果响应码返回401,则挂起当前的请求,通过接口刷新token后,再重新发起一个和当前请求一模一样的新的请求,将新响应体返回。

1. createFetch

用过axios都知道,axios有个create方法,用于创建一个带有统一配置的axios实例:axios.create

配置一般包含api域名前缀baseURL、超时时间timeout、请求头header等,而useFetch也存在相似的方法:createFetch

首页封装的第一步是构建这样的实例对象:

const myFetch = createFetch({ 
    options: { timeout: 15000, }, 
    fetchOptions: { 
        mode: 'cors', // 允許跨域請求 
        credentials: 'omit', // 不發送cookie 
    }, 
})

通过createFetch的参数类型知道,构建时createFetch,可以传入 afterFetch 配置,也就是前面说的响应拦截。

按照一般的理解,既然createFetch中可以传入 afterFetch 配置,那我们是不是直接在createFetch配置中就把代码写完,将myFetch导出去使用就好了,毕竟createFetch原本的用意就是这样的:构建统一且特有的配置的Fetch对象进行使用。

export default createFetch({ 
    options: { 
        timeout: 15000, 
        afterFetch() { 
            // ... 响应拦截 
        }
    }, 
    fetchOptions: { 
        mode: 'cors', // 允許跨域請求 
        credentials: 'omit', // 不發送cookie 
    },
})

然而我并没有把createFetch创建的实例导出,而是导出了另外一个函数,不是不想导出Fetch实例,而是createFetch中的afterFetch满足不了我们的需求。

2.afterFetch

还是看文档中的参数类型,afterFetch函数中可以得到的参数为 AfterFetchContext,而AfterFetchContext值包含了两个值:

image.png

而我们对afeterFetch的核心需求是什么?

  1. 判断当前状态码(可以做到)
  2. 刷新token后,重新发起一个一模一样的请求(做不到,入参?请求类型?请求的配置?都不知道,同时也得不到内部的 execute)
  3. 新的useFetch

直接导出createFetch走不通后,我导出了一个新的 useFetch 函数,这个函数的api尽量和vueuse中的一致,但存在差异:

vueuse中的入参是这样的:

export function useFetch<T>(url: MaybeRef<string>): UseFetchReturn<T> & PromiseLike<UseFetchReturn<T>> 

export function useFetch<T>(url: MaybeRef<string>, useFetchOptions: UseFetchOptions): UseFetchReturn<T> & PromiseLike<UseFetchReturn<T>> 

export function useFetch<T>(url: MaybeRef<string>, options: RequestInit, useFetchOptions?: UseFetchOptions): UseFetchReturn<T> & PromiseLike<UseFetchReturn<T>>

可以传递 options请求的配置、useFetchOptions,而options是可以不传,直接第二个参数传useFetchOptions的,可以这么做的原因是useFetch内部做了复制的判断:

image.png

而我们自己定义的useFeth并没有搞这么复杂,原因一是:定义Ts类型很复杂;二是vueuse是通过内部函数 isFetchOptions 判断是不是给useFetchOptions的参数,这个函数是没被导出的。

无论是定义类型还是实现一个 isFetchOptions 方法,只会增加和vueuse/useFetch内部重复的代码导致封装更臃肿,所有我们的封装,参数顺序是固定的

五、代码的编写

export const useFetch = (
    url: MaybeRef<string>,
    options: RequestInit = {}, 
    useFetchOptions?: UseFetchOptions,
): UseFetchReturnGet & UseFetchReturn<any> & PromiseLike<UseFetchReturn<any>> => { 
    
    const isFinished = ref(false) // 1. 调用构建的fetch实例来执行请求 
    const fetchReturn = myFetch(
        url, options, 
        Object.assign({
            // 2. afterFetch处理接口响应的状态 
            afterFetch(ctx: AfterFetchContext<any>): Promise<Partial<AfterFetchContext>> | Partial<AfterFetchContext> { 
                return afterFetch(ctx) 
            }, // 7. 错误处理中处理token失效,之前是在afterFetch中处理的,后面app也要接入这套token鉴权方案,于是调整了响应状态 
            onFetchError: (
                data: { data: FetchResponseType; response: Response | null; error: any }
            ) => { 
                // 8. 401表示发生了用户登录状态相关的异常 
                if (data.response?.status === 401) { 
                        
                    // 9. code=loginInvalid表示登录已经失效,同样清除token并退出登录 
                    if (data.data?.code === 'loginInvalid') { 
                        isFinished.value = true 
                        removeAccessToken() 
                        removeRefreshToken() 
                        window.location.href = '/login' 
                     } else { 
                         catchTokenInvalid() 
                     }
                 } 
             }, 
     }, useFetchOptions)) 
     
     // 10. 处理token过期 
     const catchTokenInvalid = () => { 
     
         // 11. 监听 isRefreshing 变量(外部定义,初始为false),当 isRefreshing = RefreshStatus.Success时,表示刷新token成功 
         const stopAfterWatch = watch(isRefreshing, () => { 
             if (unref(isRefreshing) === RefreshStatus.Refreshing) { 
                 // 如果正在刷新,繼續等待 
                 return 
             } 

             if (unref(isRefreshing) === RefreshStatus.Fail) { 
                 // 刷新失敗,取消監聽 
                 stopAfterWatch() 
                 return 
             } 

             // 14. 刷新成功,停止监听,并重新发起请求 
             stopAfterWatch() 
             fetchReturn.execute() 
         })
     
     
         // 12. 如果沒有在刷新token,则刷新 
         if (unref(isRefreshing) !== RefreshStatus.Refreshing) {
            isRefreshing.value = RefreshStatus.Refreshing 
            refreshToken() 
         } 
     } 
 
 
     const afterFetch = ( 
         ctx: AfterFetchContext<any>, 
     ): Promise<Partial<AfterFetchContext>> | Partial<AfterFetchContext> => { 
     
         // 3. 将data转换成json格式,因为用的时候只有调用 .json() 方法,响应数据才会被转换成json,避免外部使用过程中没有调用.json而导致判断失效。 
         // ctx.data响应体规范为: {status: 1,msg:'',data:''} 

         let data: Record<string, unknown> = ctx.data 

         try { 
             data = JSON.parse(ctx.data) 
          } catch { } 

          // 4. afterFetch可以返回同步或异步,源码会使用await等待,因为我们需要重新请求,所以一定是异步的 
          return new Promise((resolve) => { 
          
              // 5-6. 其他状态码,如果status是字符串,将它转成数字,一些旧接口不规范导致的兼容处理 
                   isFinished.value = true 
                   const status = ctx.data.status 
                   if (isString(status)) ctx.data.status = +status 
                   resolve(ctx) 
           }) 
    } 
    
    function waitUntilFinished() { 
        return new Promise((resolve) => { 
            watch(isFinished, (value) => { 
                if (value) { 
                    resolve(fetchReturn)
                }
            })
         })
    } 
    
    // 17. 将请求的结果返回 
    return { 
        ...fetchReturn, 
        then(onFulfilled: any, onRejected: any) { 
            return waitUntilFinished().then(onFulfilled, onRejected) 
        },
     }
} 


export const refreshToken = () => { 
    return myFetch(base.refreshToken, { 
        headers: { 'refresh-token': getRefreshToken(), 
         },
     }).json().then(({ data }) => { 
         const { status } = unref(data) 
         const { access_token } = unref(data).data // 13. 请求刷新token成功,更新本地access_token,并且触发监听 
         
         if (status === 1 && access_token) { 
             setAccessToken(access_token) 
             isRefreshing.value = RefreshStatus.Success 
         } else { 
             // 15. 刷新请求失败(可能是 refresh_token过期或网络、服务器错误),移除本地的所有token,并且触发登录检查,因为 存在refreshToken已過期, 
             // 但是phpsession重新登錄得到最新的情況(举例:用户无操作两小时后登录实现,新开窗口进行了登录并进入到了旧框架,此时回到上一个窗口未刷新就进行了点击操作,此时phpsession登录而本地token已过期), 
             // 所以在refreshToken過期后,嘗試重新獲取新的refreshToken 
             
             removeRefreshToken() 
             removeAccessToken() 
             
             checkLogin().then((isLogin) => { 
                 isRefreshing.value = isLogin ? RefreshStatus.Success : RefreshStatus.Fail 
             })
          } 
      }) 
} 

// 16. 判断本地是否有token,如果没有,通过接口获取access_token和refresh_token,如果获取成功,存到本地,否则跳转到登录页面 

export const checkLogin = (): Promise<boolean> => { 
    return new Promise((resolve) => { 
        if (getAccessToken()) return resolve(true) 
        
        const { data, onFetchResponse, onFetchError } = myFetch(base.getToken, { 
                headers: { token: getPHPSESSID(), 
            }, 
        }).json() 
        
        onFetchResponse(() => { 
            
            /** * status = 0 表示用戶未登錄 * status = 1 表示用戶已登錄,但可能只登錄了管理員,未登錄用戶帳號 * 如果返回的 access_token 中 user_id 為 0,則不寫入 token,跳轉至登錄頁 */ 
            if (unref(data).status === 0 || getAccessTokenInfo(unref(data).data.access_token)?.user_id === 0) { 
                resolve(false) 
                removeAccessToken() 
                removeRefreshToken() 
                return window.location.href = `${HOSTNAME.www}/user-login.html?redirect=${window.location.href}`
            }
            
            setAccessToken(unref(data).data.access_token)
            setRefreshToken(unref(data).data.refresh_token) 
            resolve(true) 
            
        }) 
            
        onFetchError((err) => { 
            console.error(err, 'err') 
            resolve(false) 
        }) 
    }) 
}

六、问题和疑虑

return值中为什么要返回then

这需要从useFetch的api和源码说起……

首先useFetch支持以下几种使用方法:

  1. .then
useFetch(url).then(() => {})
  1. await
await useFetch(url)
  1. onFetchResponse(回调)
const {data,onFetchResponse } = useFetch(url) onFetchResponse(() => {})

其次,useFetch是支持链式调用的,如:

useFetch(url).json().post()

在集成鉴权,实际运用之后,出现了很多问题,如:

如果函数里直接返回了fetchReturn,其实整个函数执行到底是没有异步的,使用await的时候是没有意义的,而且我们封装的函数不能使用 await myFetch(...),因为要兼容其他的用法(then或者回调)

解决办法:参考vueuse/useFetch的源码,在返回的对象中实现增加 then 属性

return { 
    ...fetchReturn, 
    then(onFulfilled: any, onRejected: any) { 
        return waitUntilFinished().then(onFulfilled, onRejected) 
    },
}

这里又遇到一个问题,then里的异步代码应该怎么去写?

then里应该在接口完成请求后执行,怎么定义接口请求完成?在vueuse/useFetch源码里是监听 isFinished 的变化实现,我们也可以模仿源码的写法,但是我们可以监听 myFetch 返回的 isFinished 吗?

不能,举个例子:access_token过期了,接口报错,此时 isFinished 已经会变为true了,但是实际上结束了吗?没有,如果此时就结束了,那业务接口请求里就只能拿到token过期的响应体了,而access_token过期要去刷新token并重新发起请求,当新请求完成后这个请求才算结束,这样才能让业务请求拿到正确的数据。

所以我们要重新定义 一个 isFinished,并且在适当的时间改变 isFinished 的值,并在接口正常响应时结束:

// 监听自定义的 isFinished,并将 fetchReturn 返回 
function waitUntilFinished() { 
    return new Promise((resolve) => { 
        watch(isFinished, (value) => { 
            if (value) { 
                resolve(fetchReturn)
            }
        }) 
    }) 
}

七、什么是JWT?

JWT全称 JSON Web Token,是 为了在网络应用环境间传递声明而执行 接口数据鉴权 的一种基于 JSON 的开放标准。

一个规范的jwt是由 以 . 分开的三部分组成的字符串,如:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

JWT的三个部分依次是:

Header(头部).Payload(负载).Signature(签名)
  1. Header

    描述了JWT的元数据,通常为以下数据经过base64转换得到的字符串:

    { "alg": "HS256", // jwt签名的算法 "typ": "JWT" // token的类型 }

  2. Payload

    JWT携带的透明数据,一般来说这部分信息不涉及敏感信息,比如密码等。

    JWT官方有规定了一些官方字段,比如:exp 表示过期时间,但实际上我们可以携带任意数据,不过要注意数据的容量,数据越多,JWT字符长度就越长,而token是需要在每个请求中都携带的,过长的token也会造成性能上的负面影响

  3. Signature

    进行加密的签名,用于服务端验证token的有效性,因为签名的密钥只有服务端知道,所以不泄露私钥的情况下token是安全的。

参考资料:

[JSON Web Token 维基百科]:en.wikipedia.org/wiki/JSON_W…

[JSON Web Token 入门教程 - 阮一峰]:www.ruanyifeng.com/blog/2018/0…

[jwt官网]:jwt.io/

[理解OAuth 2.0 - 阮一峰]:www.ruanyifeng.com/blog/2014/0…

[oAuth 2.0对于refresh_token的讨论]:auth0.com/blog/refres…

一文教你搞定所有前端鉴权与后端鉴权方案,让你不再迷惘 - 掘金 (juejin.cn)