【alova】现代认证架构:双 Token 机制的搭建

10 阅读6分钟

1. 核心引言

传统单一 JWT 模型下,令牌有效期与安全性存在矛盾:长有效期方便用户,但一旦泄露风险高;短有效期安全,但会频繁要求用户登录,影响体验。为解决这一问题,引入双 Token 机制,将职责拆分:Access Token 无状态、短生命周期,仅用于接口鉴权,泄露风险小;Refresh Token 有状态、长生命周期,由服务端集中管理,用于安全刷新 Access Token,并支持即时撤销。引入双token机制,可以快速检测用户重复登录,盗号等问题。

2. alova

Alova 是一个现代前端请求库,它把请求(Method)和状态(State)分开,让接口调用和组件状态天然绑定。你可以为每个请求单独配置 URL、参数、headers 和缓存策略,同时自动把响应更新到界面。它支持多种请求适配器(fetch、axios 等)、实例化配置 baseURL、超时和全局缓存,还能用 Hook 轻松管理状态。Alova 还考虑了安全性,支持 credentials、请求拦截器和 Token 注入,方便做双 Token 验证或自动刷新。简单来说,它不仅帮你发请求,更让状态管理、缓存和认证逻辑变得清晰、好用,开发起来更高效。 但是作者是个菜鸟,在引入Alova时,没有使用Hook方式进行封装,反而使用了类似Axios的方式来进行封装,所以下面的教程会让Alova看起来更像Axios,后续将会重新封装为Hook。

话不多说,开始吧。

3. alova封装

Alova封装采用createAlova的方式进行封装,采用拦截器来实现配置项的添加,下面是最基础的Alova的封装示例:

const instance = createAlova({
  requestAdapter: adapterFetch(),
  baseURL: BASE_URL,
  beforeRequest(method) {
    method.config.credentials = 'include'
  }
})

上面的代码创建了一个最简单Alova实例,请求发起如下所示:

GET = <T, R>(url: string, requestData?: IRequest<T>): Promise<IResponse<R>> => {
    return alovaInstance.Get<IResponse<R>>(url, {
        params: requestData?.params ?? {},
        debounce: requestData?.debounce ?? 0
    }).send()
}

上面是一个泛型封装的GET请求示例,使用刚刚的Alova发起GET请求。其余方式也类似封装,当然,如果嫌弃作者TS水平太菜也可以自行封装。

4.双token校验实现

对于前端来说,双token需要做的是登录正常获取token,然后当接口返回401时,请求后端接口续签获得新的access_token,其余事情都交给后端来完成,实际上是相对简单的,前端需要关注的就是如何重放当前请求的问题。

正常获取token

首先就是正常获取token,就按照以前的代码流程来进行交互,登录,前端存储token到缓存。类似代码如下:

const res = await login<typeof rest, any>({ data: rest })
const { access_token, user } = res.data || {}
setUser({ ...user, access_token })

缓存后正常请求后端。

续签access_token

当access_token过期时,后端会返回401。这时前端就需要请求相应的接口续签token,这时的难点实际上是如何发起新的请求和重放当前请求。 先来看发起新的请求,采用最简单也是较为合理的方式来进行。直接创建一个新的实例来进行请求。这里如果不使用新的实例会发生什么事,我们来盘一盘: 1.首先用同一个实例,会导致状态判定混乱,续签和正常请求token验证失败都是401,对写代码的我们来说是一个大问题。 2.这是最关键的一点,由于请求是在拦截器中断的,当前实例如何发起另一个请求?想解决他,你得去翻阅大量文档,还有不一定有结果(当然作者没有翻过,不敢妄下定论)。 总所上述,新开一个实例带来的成本要低一些,还好区分状态和维护。 要想完成这个工作第一步肯定是新建一个实例,只不过这个实例不必封装,直接只发起一次请求就可以:

const refreshToken = async (): Promise<Response> => {
  const tokenInstance = createAlova({
    requestAdapter: adapterFetch(),
    baseURL: BASE_URL,
    beforeRequest(method) {
      method.config.credentials = 'include'
    }
  })
  return tokenInstance.Post('/yourapi/refresh')
}

值得注意的是:

method.config.credentials = 'include'

这一行代码是由于作者目前是本地环境,采用了cookie的方式来存储refresh_token,所以后端跨域放行后需要带上这个,如果你直接存储redis或者其它方式存储可以不加。

好,现在新的实例已经创建,下面需要完成的就很简单,拦截主请求的401并通过这个实例续签,然后刷新refresh即可。只需要在响应拦截器编写如下代码:

const data = await response.json()
if (data.code === 401) {
  return await refreshTokenHandler(methodInstance)
}

这里有一个重要的地方,刷新续签refresh_token后需要重新发起当前请求,由于Alova目前较新,网上解决方式很少,如果你也找不到解决方案,可以参考作者的方法:

方法一

也就是作者的方法,我们来读一下响应拦截器的Success方法的源码类型:

onSuccess?: RespondedHandler<AG>;
export type RespondedHandler<AG extends AlovaGenerics> = (response: AG['Response'], methodInstance: Method<AG>) => any;

追溯下来不难看到它有第二个参数叫做methodInstance,也就是当前方法的实例,再来读一下它的类型:

/**
 * Request method type
 */
export declare class Method<AG extends AlovaGenerics = any> extends Promise<AG['Responded']> {
  baseURL: string;
  url: string;
  type: MethodType;
  config: MethodRequestConfig & AlovaMethodConfig<AG, AG['Responded'], AG['Transformed']>;
  data?: RequestBody;
  send(forceRequest?: boolean): Promise<AG['Responded']>;
}

类型很大,无用的信息作者已舍去,感兴趣可以自行阅读。不难看出,我们想要的信息都有,包括baseURL,url,send,config等重要信息,还记得吗?上面我们新增了一个Alova的实例,你愿意的话,可以利用这个实例来发起请求,不过相对麻烦,并不推荐。注意send本身就是发起请求的方法,我们此时只需要调用这个函数就可以很简单的重新发起这个请求了。示例如下所示:

const refreshTokenHandler = async (methodInstance: Method) => {
  const refreshResponse = await refreshToken()
  const refreshData = await refreshResponse.json()
  if (refreshData.code === 200) {
    const newToken = refreshData.data.access_token
    useUserStore.setState(state => ({
      user: {
        ...state.user,
        access_token: newToken
      }
    }))
    methodInstance.config.headers['Authorization'] = `Bearer ${newToken}`

    return methodInstance.send()
  }
  // 续签接口也报错401,说明完全过期,直接返回错误
  if (refreshData.code === 401) {
    // 强制登出
    useUserStore.setState(state => ({
      user: {
        ...state.user,
        access_token: ''
      }
    }))
  }
  throw new Error(refreshData.message || '刷新token失败')
}

需要注意就是带上新的续签的refresh_token即可。

方法二

方法二和上面大差不差,就是调用方式不一样,上面我们已经拿到了当前的示例,那么就可以调用我们的Alova实例,将方法实例传递进去即可,只有发起请求有变化,其余都一样:

alovaInstance.send(methodInstance)

方法二作者是浅尝辄止,当时测试正确就没进一步,或许会有记忆不清的地方,各位大佬多担待。

5 总结

到这里实际上双token验证就已经实现,只要后端是支持双token的,那么这样可以成功使用access_token请求并在其过期时请求接口获得新的refresh_token,从而完成双token验证。前端侧并不需要承担过多复杂逻辑,只需在登录阶段正确缓存 token,在接口返回 401 时通过独立实例请求续签接口,并在成功后利用 methodInstance.send() 重放原始请求即可。通过将续签逻辑与业务请求解耦,既避免了状态混乱,也让代码结构更加清晰可控。这种实现方式较为稳定、易理解,也适合在真实项目中长期维护和演进。