无感刷新 Token 
JWT 已经在 HTTP 鉴权中被广泛使用,但普通方案中 Token 到期后用户就需要重新登录,为了安全性也不能将 Token 有效期设置的过长。
为了解决这个问题,可以使用的方案包括:
- 后端额外返回 Token 过期时间,前端开启定时任务或在请求前判断是否临期,并重新获取 Token。
- 后端返回 AccessToken+RefreshToken,前端使用 AccessToken 鉴权,鉴权失败后使用 RefreshToken 重新获取两个 Token。
- 后端通过响应头返回 Token,前端只要拦截到响应头存在 Token 就重新保存。后端在 Token 临近过期(或过期不久)时返回新的 Token。
第一种方案依赖于用户系统时间,不建议使用。
第二种方案是最常用的方案,需要特别注意刷新 Token 期间其他请求和响应的处理。
第三种方案是我个人使用过的方案,同样需要注意处理刷新 Token 期间收到的请求,与第二种方案不同在于处理是放在服务端的。后端判断 Token 过期时间也可以改为双 Token 的方式,后端判断 AccessToken 过期但 RefreshToken 未过期。
下文主要介绍第二种主流方案的实现方式,其他方案处理类似。
生成 Token 
以 NestJS 为例,使用 @nestjs/jwt 库可以很方便的生成 Token。
安装后通常将 JwtModule 注册为全局模块,便于其他模块中使用:
import { JwtModule } from "@nestjs/jwt";
@Module({
  imports: [
    JwtModule.register({
      global: true, // 开启后会将写在这里的配置应用于全局
      secret: process.env.JWT_SECRET, // 设置加密密钥
    }),
  ],
})
export class AppModule {}2
3
4
5
6
7
8
9
10
11
使用时注入 JwtService 便能够通过 sign 方法生成 Token,和 verify 方法验证 Token。在登录接口中生成并返回 Token:
import { Controller, Post, Body, HttpCode } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
@Controller("user")
export class UserController {
  // 注入 JWT 服务
  constructor(private readonly jwtService: JwtService) {}
  // 登录接口
  @Post("login")
  @HttpCode(200) // Post 默认响应 201
  async login(@Body() user: LoginDto) {
    // 1. 根据登录信息查询用户信息 - user
    // 2. 判断密码是否正确
    // 3. 生成 AccessToken,设置较短的过期时长,签名设置为用户 ID
    const accessToken = await this.jwtService.signAsync(
      { id: user.id },
      {
        expiresIn: "1h",
      }
    );
    // 4. 生成 RefreshToken,设置较长的过期时长
    const refreshToken = await this.jwtService.signAsync(
      { id: user.id },
      { expiresIn: "7d" }
    );
    // 返回信息
    return { ...user, accessToken, refreshToken };
  }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
前端接收到登录响应后,需要本地存储 AccessToken 和 RefreshToken。并通过请求拦截器,在请求前统一添加 Token 请求头即可保持登录状态,相关代码这里不再赘述。
后端鉴权 
前端通过 Token 传递身份信息,在后端可以创建一个登录守卫(Guard),在需要鉴权的接口前判断是否登录。
创建 login.guard.ts 文件:
import {
  CanActivate,
  ExecutionContext,
  Inject,
  Injectable,
  UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request, Response } from "express";
import { Observable } from "rxjs";
import { PrismaService } from "../global/prisma.service";
@Injectable()
export class LoginGuard implements CanActivate {
  // 注入 JWT 服务
  @Inject(JwtService)
  private readonly jwtService: JwtService;
  canActivate(
    context: ExecutionContext
  ): boolean | Promise<boolean> | Observable<boolean> {
    // 获取请求信息
    const http = context.switchToHttp();
    const request: Request = http.getRequest();
    const response: Response = http.getResponse();
    // 从请求头获取 Token
    const token = request.header("Token");
    // 没有 Token 抛出 401 错误
    if (!token) {
      throw new UnauthorizedException();
    }
    // 校验 Token
    return this.jwtService.verifyAsync(token).then(async ({ id }) => {
      // 这里省略获取用户信息 - user
      // 签名内容错误,抛出 401 错误
      if (!user) {
        throw new UnauthorizedException("登录已失效,请重新登录!");
      }
      // 否则通过校验
      return true;
    });
  }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
定义登录守卫后在需要鉴权的接口或模块中应用守卫即可:
import { LoginGuard } from "./login.guard";
@Controller("any-controller")
@UseGuards(LoginGuard) // 整个模块使用,也可以只定义在单个接口上
export class AnyController {
  // ...
}2
3
4
5
6
7
刷新 Token 
当前端收到 401 响应时,需要进行判断是未登录(不存在 Token)还是 AccessToken 过期,如果存在 Token,尝试调用刷新 Token 接口。
以 Axios 拦截器为例,关键代码为:
axios.interceptors.response.use(
  (response) => {
    // 正常响应处理
  },
  (error) => {
    if (error.response.status === 401) {
      const refreshToken = localStorage.getItem("RefreshToken");
      if (!refreshToken) {
        // 如果没有 Token,直接跳转登录
      }
      // 否则请求刷新 Token 接口
      return axios
        .post("/user/refresh", { refreshToken })
        .then((response) => {
          // 刷新成功后重置 Token 本地存储和请求头
          // axios.defaults.headers.common.Authorization = ``;
          // 重发请求
          return axios(error.config);
        })
        .catch(() => {
          // 刷新失败,跳转登录
        });
    }
    // 其他处理...
  }
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
后端刷新接口也比较简单:
import { Controller, Post, Body, HttpCode } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
@Controller("user")
export class UserController {
  // 注入 JWT 服务
  constructor(private readonly jwtService: JwtService) {}
  @Post("refresh")
  @HttpCode(200) // Post 默认响应 201
  async refersh(@Body() { refreshToken }: RefreshDto) {
    if (!refreshToken) {
      throw new UnauthorizedException();
    }
    // 校验 Token
    return this.jwtService
      .verifyAsync(accessToken)
      .then(async ({ id }) => {
        // RefreshToken 未过期
        // 还需要判断签名 id 是否正确
        // 判断通过后生成新 Token 返回
        const accessToken = await this.jwtService.signAsync(
          { id: user.id },
          {
            expiresIn: "1h",
          }
        );
        const refreshToken = await this.jwtService.signAsync(
          { id: user.id },
          { expiresIn: "7d" }
        );
        return { accessToken, refreshToken };
      })
      .catch(() => {
        // 校验失败,抛出错误
        throw new UnauthorizedException();
      });
  }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
连续请求处理 
在刷新 Token 期间,如果有新的请求,应该让这些请求持续 Pending 状态,等刷新之后再重发或者失败。
如果多个连续请求,第一个响应 401 并尝试刷新 Token 后,后续的 401 响应不应该再触发刷新。
如果只考虑新的请求,在请求拦截中判断是否正在刷新可以避免发出多余的请求。但因为第二种连续请求的情况,所以全部在响应拦截中处理会更加简单:
// 刷新标记
let isRefreshing = false;
// 等待队列,Token 刷新后再处理
const requests = [];
axios.interceptors.response.use(
  (response) => {
    // 正常响应处理
  },
  (error) => {
    if (error.response.status === 401) {
      // 如果正在刷新,
      if (isRefreshing) {
        // 返回 Pending 状态的 Promise,等刷新后再处理
        return new Promise((resolve) => {
          // 将任务加入等待队列,刷新完成后取出重新执行
          requests.push(() => {
            resolve(axios(response.config));
          });
        });
      } else {
        // 未开始刷新
        const refreshToken = localStorage.getItem("RefreshToken");
        if (!refreshToken) {
          // 如果没有 Token,直接跳转登录
        }
        // 否则请求刷新 Token 接口
        isRefreshing = true;
        return axios
          .post("/user/refresh", { refreshToken })
          .then((response) => {
            // 刷新成功后重置 Token 本地存储和请求头
            // axios.defaults.headers.common.Authorization = ``;
            // 重新执行等待队列中的请求
            for (req of requests) {
              req();
            }
            // 重发请求
            return axios(error.config);
          })
          .catch(() => {
            // 刷新失败,跳转登录
          })
          .finally(() => {
            // 刷新后清空队列已经关闭刷新标记
            requests = p[];
            isRefreshing = false;
          });
      }
    }
    // 其他处理...
  }
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55