Token 刷新并发处理解决方案

3,289 阅读3分钟

对 Token 进行刷新续期,我们要解决并发请求导致重复刷新 Token 的问题,这也是设计刷新 Token 的难点。这里我会分别介绍前端和后端各自的处理方案。

后端方案:利用 Redis 缓存

当同时发起多个请求时,第一个接口刷新了 Token,后面的请求仍然能通过请求,且不造成 Token 重复刷新。那么,后端在用户第一次登录时,需要将生成的 Token 数据(token 和 createTime)缓存一份到 Redis 中。

当 Token 过期时,重新生成新的 Token 数据并更新 Redis 缓存,同时在 Redis 中设置一条 Token 过渡数据并设置一个很短的过期时间(比如 30s)。如果后面的请求发现 Token 已经被刷新了,就判断 Redis 中是否存在 Token 过渡数据,存在就放行,这样同一时间的请求都可以通过。

源码地址:github.com/yifanzheng/…

Token 刷新流程图

在这里插入图片描述

前端方案:请求拦截

由于前端请求都是异步的,只有一个请求的时候,刷新 Token 是比较好处理的,但并发请求下刷新 Token 处理起来有点麻烦。我们需要考虑在多个请求几乎同时发起并且 Token 都失效的情况,当第一个请求进入 Token 刷新流程时,其他请求必须等待第一个请求完成 Token 刷新后再使用新 Token 进行重试。 简单地讲,就是同一时间有多个请求且 Token 都失效,在第一个请求进行 Token 刷新时,其他请求必须处于等待状态,直到 Token 刷新完成,才能携带新 Token 进行重试。

下面,我使用了 Angular 的请求拦截器,利用 BehaviorSubject 进行 Token 刷新状态的监听,当 Token 刷新成功,放行后面的请求进行重试。

除此之外,前端还可以利用 Promise,将请求存进队列中后,同时返回一个 Promise,让这个 Promise 一直处于 Pending 状态(即不调用 resolve),此时这个请求就会一直等待,只要我们不执行 resolve,这个请求就会一直在等待。当刷新 Token 的请求完成后 ,我们再调用 resolve,逐个重试。

Github 地址:github.com/yifanzheng/…

Angular 代码示列

import { Injectable } from "@angular/core";
import {
  HttpEvent,
  HttpInterceptor,
  HttpHandler,
  HttpRequest,
  HttpErrorResponse
} from "@angular/common/http";
import { throwError, Observable, BehaviorSubject, of } from "rxjs";
import { catchError, filter, finalize, take, switchMap, mergeMap } from "rxjs/operators";

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  private refreshTokenInProgress = false;
  private refreshTokenSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (!req.headers.has("Content-Type")) {
      req = req.clone({
        headers: req.headers.set("Content-Type", "application/json")
      });
    }
    // 统一加上服务端前缀
    let url = req.url;
    if (!url.startsWith('https://') && !url.startsWith('http://')) {
      url = "./" + url;
    }
    req = req.clone({ url });
    req = this.setAuthenticationToken(req);

    return next.handle(req).pipe(
      mergeMap((event: any) => {
        // 若一切都正常,则后续操作
        return of(event);
      }),
      catchError((error: HttpErrorResponse) => {
        // 当是 401 错误时,表示 Token 已经过期,需要进行 Token 刷新
        if (error && error.status === 401) {
          if (this.refreshTokenInProgress) {
            // 如果 refreshTokenInProgress 为 true,我们将等到 refreshTokenSubject 是 true 时,才可以再次重试该请求
            // 这表示刷新 Token 动作已完成,新 Token 已准备就绪
            return this.refreshTokenSubject.pipe(
              filter(result => result),
              take(1),
              switchMap(() => next.handle(this.setAuthenticationToken(req)))
            );
          } else {
            this.refreshTokenInProgress = true;
            // 将 refreshTokenSubject 设置为 false,以便后面的请求调用时将处于等待状态,直到检索到新 Token 为止
            this.refreshTokenSubject.next(false);
            return this.refreshToken().pipe(
              switchMap((newToken: string) => {
                this.refreshTokenSubject.next(true);
                // 重新设置新的 Token
                localStorage.setItem("token", newToken);
                return next.handle(this.setAuthenticationToken(req));
              }),
              // 当刷新 Token 请求完成后,需要将 refreshTokenInProgress 设置为 false,用于下次刷新 Token
              finalize(() => (this.refreshTokenInProgress = false))
            );
          }
        } else {
          return throwError(error);
        }
      })
    );
  }

  private refreshToken(): Observable<any> {
    // 这里需要换成实际的 Token 刷新接口
    return of("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdGFyIiwicm9sZSI6WyJST0xFX1VTRVIiXSwiaXNzIjoic2VjdXJpdHkiLCJpYXQiOjE2MDY4MjczMDAsImF1ZCI6InNlY3VyaXR5LWFsbCIsImV4cCI6MTYwNjgzNDUwMH0.Hiq2DsH6j4XFd_v87lDWGlYembTLck7DjMLRLWdyvOo");
  }

  private setAuthenticationToken(request: HttpRequest<any>): HttpRequest<any> {
    return request.clone({
      headers: request.headers.set("Authorization", "Bearer " + localStorage.getItem("token"))
    });
  }
}