开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第29天,点击查看活动详情
一、为什么要用token代替传统的登录认证方案
要讨论这个问题,要先从一般网页开发的认证方案说起,传统登录认证的流程一般如下:
- 用户输入账号密码向服务器发起登陆
- 服务器接收到登陆请求后,校验账号密码是否正确
- 由于http协议是无状态的,用户登陆成功,为了知道下次请求用户有没有登陆,服务端会往客户端写入一个标识,这个标识可以用session-id来表示,而写入的地方一般是session或cookie
- 登陆请求完成后,客户端的每次请求都会存在session-id,而服务端在需要判断用户是否登陆的地方,拿到session-id,去库里(或缓存)查询一下这个用户信息和登陆状态,如果session有效,则正常响应,否则需要重新登陆
以上的方案有一个缺陷,那就是每一个鉴权接口都需要去查库(或缓存),以获取用户的信息以及登陆状态,这个属于重复且成本并不低的操作。
为了避免每个鉴权请求都去查一次用户信息,token方案就登场了
二、为什么token是有效的?
token的意思是令牌,我们可以比喻成宿舍的门禁卡,在你入住宿舍的时候(登录),给你发了一个门禁卡(token),你离开宿舍不需要刷门禁,进入才需要,你不管从哪里回来、是不是你本人回来(请求来源),只要你有门禁卡(token校验通过),就可以进来,而这个门禁卡是加密的,密钥只有发放门禁卡的人才知道,所以只要密钥不泄露,门禁卡就是可信任的。
完成上面的几步后,还存在几个问题:
- 如果不小心门禁卡丢了呢?那我宿舍岂不是随便被别人进了?
- 服务端签发的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鉴权逻辑就变成了这样:
- 用户通过账号密码登录,服务器验证通过后,返回给客户端 access_token 和 refresh_token,并且服务器 refresh_token 写入库内 (给你发了一张手机卡顺便附带了验证码,并把手机号记录下来)
- 客户端保存 access_token 和 refresh_token ,并且在所有请求头里都携带上access_token (回宿舍请输入验证码)
- 服务端处理请求时,先校验access_token是否存在?是不是我们签发的?过期了没有?如果有一项没有通过校验,就通知客户端你这个 acces_token 没用,重新拿一个
- 客户端收到了服务端的 access_token过期的消息后,用refresh_token去重新获取access_token
- 服务端收到客户端的refresh_token后,去库里查一下,这个refresh_token是否存在?状态是否异常?是不是被拉黑了等等,如果没问题就给客户端发送一个新的access_token
- 客户端重新获取到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值包含了两个值:
而我们对afeterFetch的核心需求是什么?
- 判断当前状态码(可以做到)
- 刷新token后,重新发起一个一模一样的请求(做不到,入参?请求类型?请求的配置?都不知道,同时也得不到内部的 execute)
- 新的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内部做了复制的判断:
而我们自己定义的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支持以下几种使用方法:
- .then
useFetch(url).then(() => {})
- await
await useFetch(url)
- 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(签名)
-
Header
描述了JWT的元数据,通常为以下数据经过base64转换得到的字符串:
{ "alg": "HS256", // jwt签名的算法 "typ": "JWT" // token的类型 } -
Payload
JWT携带的透明数据,一般来说这部分信息不涉及敏感信息,比如密码等。
JWT官方有规定了一些官方字段,比如:exp 表示过期时间,但实际上我们可以携带任意数据,不过要注意数据的容量,数据越多,JWT字符长度就越长,而token是需要在每个请求中都携带的,过长的token也会造成性能上的负面影响
-
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…