双axios双token无感刷新技术方案与实现(nestjs后端部分)

389 阅读7分钟

在上个月我收到了一份意外的礼物

在拿到书的第一天,我在形策课上便读到了170多页,至今已经全部阅读完成,让我对前后端交互,AOP、IOC思想,模块化等的理解又进了一步!!!特别感谢元兮大大,能看中我这个爱玩技术的小屁孩!

在阅读了全文后,我仔细复盘了我之前写的双token无感刷新方案与大文件切片上传方案后,我进行了详细的重构。而本文将带来使用nestjs完成双token无感刷新的后端部分!

什么是jwt,什么是token,《Nestjs全站开发解析》第171页有详细解释

双token的无感刷新的业务逻辑如下:

现在我们来完成代码部分的实现~

双token无感刷新本质上也是一种权限管理,因此在实现上需要实现如下功能:

  1. 权限守卫
  2. 登录接口无需校验权限(实现校验放行标识)
  3. 刷新短token接口

我们一步步来实现,先实现创建权限模块,使用如下命令创建模块

// 创建项目
nest new project-name
// 创建auth模块
nest g resource auth

JWT配置

安装jwt第三方包

npm install --save @nestjs/jwt

/auth.module.ts

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';
// jwt
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { AuthGuard } from './auth.guard';
import { APP_GUARD } from '@nestjs/core';
@Module({
  imports: [UserModule,
    JwtModule.register({
      // 全局配置,可以直接注入并使用@Inject(JwtService)来获取JwtService实例
      global: true,
      // 签名密钥(secret)
      secret: jwtConstants.secret,
      // 签发出去的jwt的过期时间,时间单位可以是秒(s)、分钟(m)、小时(h)、天(d)等
      signOptions: { expiresIn: '60s' },
    }),],
  controllers: [AuthController],
  providers: [AuthService,
    {
      // 告诉Nest你要注册的是一个全局守卫
      provide: APP_GUARD,
      // 指定实际要使用的守卫类是AuthGuard
      useClass: AuthGuard,
    },
  ],
})
export class AuthModule { }

/constants.ts

// jwt密钥
export const jwtConstants = {
  // 随便写
  secret: '&kl-*/***-7s%%sa#j@@|d/*-*/*-kl/*-*|/ajs@%@kl,,dd+*/..fsdf/5@6&sa1|&fh5@|6/*+/*fd439%#0[]]asjd+6',
};

权限守卫

/auth.guard.ts

import {
  CanActivate, // 用于定义守卫
  ExecutionContext,// 执行上下文
  Injectable, // 未授权异常
  UnauthorizedException, // 标记服务为可注入
} from '@nestjs/common';
// 处理JWT的生成和验证
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from './auth.decorators'
import { Reflector } from '@nestjs/core';

/**
 * 定义 AuthGuard 类,实现 CanActivate 接口以充当 Nest.js 应用程序中的认证守卫。
 * 负责验证传入请求中的 JWT 令牌,并处理未授权情况。
 */
@Injectable()
export class AuthGuard implements CanActivate {
  /**
   * 构造函数初始化 JWT 服务和反射器,这两个都是认证过程中必需的依赖。
   *
   * @param jwtService 用于 JWT 令牌的生成与验证服务。
   * @param reflector 用于读取控制器和方法上的自定义装饰器元数据。
   */
  constructor(private jwtService: JwtService, private reflector: Reflector) { }

  /**
   * canActivate 方法决定请求是否可以继续执行。
   * 
   * @param context 提供执行上下文,从中可以获取到请求和响应对象。
   * @returns 返回一个 Promise,解析为布尔值,指示是否允许请求继续。
   */
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 检查控制器或方法是否标记为公开,如果是,则无需认证直接放行。
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    // 携带了@Public()装饰器,说明可以放行
    if (isPublic) {
      return true;
    }

    // 从 HTTP 请求中提取 JWT 令牌。
    const request: Request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    // 如果没有找到有效的 JWT 令牌,则说明没有登录
    if (!token) {
      throw new UnauthorizedException('用户未登录');
    }

    try {
      // 验证 JWT 令牌,并将有效载荷存储在请求对象中,便于后续使用。
      const payload = await this.jwtService.verifyAsync(token, {
        secret: jwtConstants.secret,
      });
      request['user'] = payload;
    } catch {
      // 验证失败时,同样抛出未授权异常。
      throw new UnauthorizedException('token 失效,请重新登录');
    }
    // 验证通过,允许请求继续。
    return true;
  }

  /**
   * 从请求的 Authorization 头中提取 JWT 令牌。
   * 
   * @param request Express 请求对象。
   * @returns 返回提取到的 JWT 令牌,如果不存在或格式错误则返回 undefined。
   */
  private extractTokenFromHeader(request: Request): string | undefined {
    // 分割 Authorization 头,预期格式为 'Bearer {token}'。
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    // 确保类型为 'Bearer' 且存在 token 时才返回 token。
    return type === 'Bearer' ? token : undefined;
  }
}

校验放行标识

需要实现一个装饰器来作为放行标识

什么是装饰器,有哪些内置的装饰器《Nestjs全站开发解析》第51页有详细解释

src\auth\auth.decorators.ts 没有这个文件自己创建一个~

// 自定义装饰器
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
// 可以使用@Public()装饰器装饰任何方法,使当前被装饰的接口无需进行任何的权限验证
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

接口实现

连接数据库

安装依赖

npm install --save @nestjs/typeorm typeorm mysql2

根据模块化思想,需要查询用户信息是否存在于数据库中,因此需要新开辟一个模块

nest g resource user

配置数据库

没安装mysql的要安装mysql数据库哟

src\app.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';// 操作数据库的包
import { AppController } from './app.controller';
import { AppService } from './app.service';

import { UserModule } from './user/user.module';
import { FileModule } from './file/file.module';
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "mysql", // 数据库类型
      username: "root", //你自己的mysql账号
      password: "123456", // 你自己的密码
      host: "localhost", // host
      port: 3306,
      database: "financial_system", // 库名
      synchronize: true, // synchronize字段代表是否自动将实体类同步到数据库
      retryDelay: 500, // 重试连接数据库间隔
      retryAttempts: 0,// 重试连接数据库的次数
      autoLoadEntities: true, // 如果为true,将自动加载实体 forFeature()方法注册的每个实体都将自动添加到配置对象的实体数组中
      logging: false, // 是否打印日志
    }),
    UserModule, FileModule, AuthModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule { }

配置user模块中的service类

src\user\user.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/User.entity';

@Injectable()
export class UserService {
  constructor(@InjectRepository(User) private readonly userRepository: Repository<User>) { }
  // 根据用户名称查找用户
  async getUserByUsername(username: string): Promise<User | null> {
    const res = await this.userRepository.findOne({ where: { username } });
    return res
  }

  // 根据id查询一个用户
  async getUserById(id: number): Promise<User | null> {
    const res = await this.userRepository.findOne({ where: { id } }) // 根据id查询单个
    return res
  }
}

src\user\entities\User.entity.ts 对应的user类

import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm";

@Index("uuid", ["uuid"], { unique: true }) //设置唯一索引
@Entity("user", { schema: "financial-system" })
export class User {
  @PrimaryGeneratedColumn({ type: "int", name: "id", comment: "主键id" })
  id: number;

  @Column("varchar", {
    name: "uuid",
    unique: true,
    comment: "uuid主键",
    length: 150,
  })
  uuid: string;

  @Column("varchar", {
    name: "username",
    comment: "姓名",
    length: 100,
  })
  username: string;

  @Column("varchar", { name: "password", comment: "密码", length: 255 })
  password: string;

  @Column("varchar", {
    name: "email",
    nullable: true,
    comment: "邮箱",
    length: 100,
  })
  email: string | null;

  @Column("varchar", {
    name: "mobile",
    nullable: true,
    comment: "手机号码",
    length: 11,
  })
  mobile: string | null;

  @Column("tinyint", {
    name: "gender",
    nullable: true,
    comment: "性别",
    default: () => "'0'",
  })
  gender: number | null;

  @Column("timestamp", {
    name: "create_at",
    comment: "创建时间",
    default: () => "CURRENT_TIMESTAMP",
  })
  createAt: Date;

  @Column("timestamp", {
    name: "update_at",
    comment: "更新时间",
    default: () => "CURRENT_TIMESTAMP",
  })
  updateAt: Date;
}

上述user模块中的用户类中有两个方法,我们的登录注册,短token刷新接口都将依赖这上个方法

回到我们的auth模块中

auth.service层 业务逻辑层

该service层会调用user模块中的service类中的两个方法

src\auth\auth.controller.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserService } from '../user/user.service';
// jwt生成器
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(private readonly userService: UserService, private jwtService: JwtService) { }

  // 判断用户是否存在后发放jwt版本的token
  async signIn(username: string, pw: string): Promise<any> {
    const user = await this.userService.getUserByUsername(username);

    if (!user) throw new UnauthorizedException("用户不存在")

    if (user?.password != pw) throw new UnauthorizedException("密码错误")

    const access_token: string = await this.jwtService.signAsync({ sub: user.id, username: user.username }, { expiresIn: '10s' });// 短token
    const refresh_token: string = await this.jwtService.signAsync({ sub: user.id, }, { expiresIn: '30h' });// 长token
    return {
      access_token,
      refresh_token
    };
  }

  // 刷新双token的方法
  async refresh(refreshToken: string) {
    const token = this.extractTokenFromHeader(refreshToken)
    // 从长token中获取当前用户id
    const data = this.jwtService.verify(token);
    // 根据id查询一个用户
    const user = await this.userService.getUserById(data.userId);
    // 全新的长短token
    const access_token: string = await this.jwtService.signAsync({ sub: user.id, username: user.username }, { expiresIn: '10s' });
    const refresh_token: string = await this.jwtService.signAsync({ sub: user.id, }, { expiresIn: '10m' });
    return {
      access_token,
      refresh_token
    }
  }

  /**
   * 从请求的 Authorization 头中提取 JWT 令牌。
   * 
   * @param request Express 请求对象。
   * @returns 返回提取到的 JWT 令牌,如果不存在或格式错误则返回 undefined。
   */
  private extractTokenFromHeader(request: string): string | undefined {
    // 分割 Authorization 头,预期格式为 'Bearer {token}'。
    const [type, token] = request?.split(' ') ?? [];
    // 确保类型为 'Bearer' 且存在 token 时才返回 token。
    return type === 'Bearer' ? token : undefined;
  }
}   

auth.Controller层 接口层

调用service类中封装好了的业务逻辑

src\auth\auth.controller.ts

import {
  Body,
  Controller,
  Get,
  Headers,
  HttpCode,
  HttpStatus,
  Post,
  UnauthorizedException,
  UseGuards
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthGuard } from './auth.guard';
import { Public } from './auth.decorators';
import { ApiOperation, ApiTags } from '@nestjs/swagger';

@Controller('auth')
@ApiTags('守卫接口(\\auth)')
export class AuthController {
  constructor(private readonly authService: AuthService) { }

  @ApiOperation({ summary: '登录', description: '登录接口' })
  @HttpCode(HttpStatus.OK)
  @Post('login')
  @Public()// 公共方法,无需授权
  signIn(@Body() signInDto: Record<string, any>) {
    return this.authService.signIn(signInDto.username, signInDto.password);
  }

  @ApiOperation({ summary: '刷新token', description: '刷新token接口' })
  @Get('refresh')
  async refresh(@Headers('Authorization') refreshToken: string,) {
    try {
      return await new Promise((resolve) => {
        setTimeout(() => {
          resolve(this.authService.refresh(refreshToken))
        }, 5000)
      })
    } catch (e) {
      throw new UnauthorizedException('刷新token 已失效,请重新登录');
    }
  }

  @ApiOperation({ summary: 'token授权成功接口', description: 'token授权成功测试接口' })
  @UseGuards(AuthGuard)
  @Get('profile')
  getProfile() {
    console.log("验证通过")
    return {
      msg: "验证通过"
    };
  }
}

至此全部实现!若想CR我的代码,请点击如下链接:

FengBuPi/Dual-token-silent-refresh: 双axios双token无感刷新技术方案demo (github.com)

特别感谢元兮大大的大力支持