NestJS 项目实战-权限管理系统开发(四)

423 阅读14分钟

本系列教程将教你使用 NestJS 构建一个生产级别的 REST API 风格的权限管理后台服务【代码仓库地址】。

在线预览地址】账号:test,密码:d.12345

本章节内容: 1. 登录接口,包含单点登录、jwt 守卫、jwt 身份认证策略、class-validator 参数验证;2. 全局异常处理器,包含统一返回数据格式、错误日志记录;3. 登出接口,使用黑名单机制; 4. token 刷新接口。

1. 创建 User 模块

在项目根目录下打开终端窗口,输入 nest generate res user 命令,回车,选择 REST API,然后选择不生成 CRUD 模版代码,至此,就成功创建了 User 模块(含 module、controller、service 及两个测试文件)。

现在打开 /src/user/user.service.ts 文件,添加 findUser 方法:

import { Injectable } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';

@Injectable()
export class UserService {
  constructor(private readonly prismaService: PrismaService) {}

  async findUser(userName: string): Promise<{
    id: string;
    userName: string;
    password: string;
    disabled: boolean;
  } | null> {
    const user = await this.prismaService.user.findUnique({
      where: { userName, deleted: false },
      select: { id: true, userName: true, password: true, disabled: true },
    });

    return user;
  }
}

该方法里使用了 PrismaService 来访问数据库的 user 表,然后调用了 findUnique 方法并指定了用户名、删除状态与禁用状态,来查询用户表中符合条件的唯一记录,然后通过 select 属性指定查询成功时返回的字段。

提示:如果你无法通过 this.prismaService 访问 user 表,或者表的字段不对等问题,可以运行 npx prisma generate 命令重新生成 prisma client 类型。

最后我们需要在 user.module.ts 中导出 UserService 服务,稍后才能在 auth 模块中使用。

import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';

@Module({
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService], // 导出服务
})
export class UserModule {}

单元测试代码请查看代码仓库

2. 添加用来生成 token 的密钥文件

使用 @nestjs/jwt 库生成 token 时,我们需要设置用来加解密的公钥与私钥,当然也可以直接使用一个字符串来加密 token。为了安全性考虑,这里采用密钥文件来加密 token。

在根目录下创建 key 文件夹,然后打开终端窗口运行以下命令:

# 进入 key 目录
cd ./key

# 生成私钥并指定使用 RSA 算法
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048

# 生成公钥
openssl rsa -pubout -in private_key.pem -out public_key.pem

打开 .gitignore 文件,添加 *.pem 配置,让 git 忽略密钥文件,避免上传到远程仓库。

3. 添加 JWT 相关环境变量

打开 .env.development 文件,添加 jwt 相关配置:

# jwt
JWT_ALGORITHM="RS256"
JWT_PUBLIC_KEY_PATH="./key/public_key.pem"
JWT_PRIVATE_KEY_PATH="./key/private_key.pem"
JWT_EXPIRES_IN=1800
JWT_REFRESH_TOKEN_EXPIRES_IN="7d"

打开 /src/common/config/index.ts 文件,在 getBaseConfig 方法的返回值中添加 jwt 配置:

import type { JwtSignOptions } from '@nestjs/jwt';
...

jwt: {
    algorithm: configService.get('JWT_ALGORITHM') as JwtSignOptions['algorithm'],
    publicKeyPath: configService.get<string>('JWT_PUBLIC_KEY_PATH'),
    privateKeyPath: configService.get<string>('JWT_PRIVATE_KEY_PATH'),
    expiresIn: +configService.get<number>('JWT_EXPIRES_IN'),
    refreshTokenIn: configService.get<string>('JWT_REFRESH_TOKEN_EXPIRES_IN'),
},

4. 登录功能

4.1 注册 JwtModule 模块

首先运行 pnpm add @nestjs/jwt 命令安装库,然后在 auth.module.ts 中导入:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from 'src/user/user.module';
import { JwtModule, type JwtModuleOptions } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { getBaseConfig } from 'src/common/config';
import { join } from 'path';
import { readFileSync } from 'fs';

@Module({
  imports: [
    UserModule,
    JwtModule.registerAsync({
      useFactory: (configService: ConfigService) => {
        const config = getBaseConfig(configService);
        const staticPath = join(__dirname, '../../../');
        const privateKey = readFileSync(
          join(staticPath, config.jwt.privateKeyPath),
        );
        const publicKey = readFileSync(
          join(staticPath, config.jwt.publicKeyPath),
        );

        const options: JwtModuleOptions = {
          privateKey,
          publicKey,
        };

        return options;
      },
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

这里使用了 JwtModule.registerAsync 方法来异步注册 JWT 模块的配置,并使用了 useFactory 工厂函数动态生成配置,还使用了 injectConfigService 注入到工厂函数中。在这个工厂函数中,我们通过注入的 ConfigService 拿到密钥文件路径,然后从文件系统中读取密钥信息,并将其返回,用于配置 JWT 模块的签名和验证功能。

这里还导入了 UserModule 模块,因为后续要使用 UserServicefindUser 方法,所以也需要导入用户模块。

4.2 使用 class-validator 验证参数

在此应用中,将使用 class-validator 来验证接口参数,运行以下命令安装相关库:

pnpm add class-validator class-transformer

打开 main.ts 文件,添加以下代码开启自动验证:

...
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
...
  app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
...
}

whitelist 设置为 true 后可以过滤掉接口不接受的额外参数。

现在删除 /src/auth/dto 文件夹下的两个 dto 文件,然后创建一个 auth.dto.ts 文件,并添加以下代码:

import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, Matches } from 'class-validator';

export class LoginDto {
  @Matches(/^[a-zA-Z][a-zA-Z0-9]{2,10}$/, { message: '用户名格式错误' })
  @IsNotEmpty({ message: '用户名不能为空' })
  @ApiProperty({ description: '用户名' })
  userName: string;

  @Matches(/^[a-zA-Z](?=.*[.?!&_])(?=.*\d)[a-zA-Z\d.?!&_]{5,15}$/, {
    message: '密码格式错误',
  })
  @IsNotEmpty({ message: '密码不能为空' })
  @ApiProperty({ description: '密码' })
  password: string;

  @Matches(/^[a-zA-Z0-9]{4}$/, { message: '验证码格式错误' })
  @IsNotEmpty({ message: '验证码不能为空' })
  @ApiProperty({ description: '验证码' })
  captcha: string;
}

以上代码使用了 IsNotEmpty 装饰器来校验参数是否为空,如果为空,则返回设置的错误信息。如果不为空,则通过 Matches 装饰器继续校验参数格式。

@ApiProperty 用来为 Swagger 文档提供接口参数数据的类型说明。

4.3 添加 redis 相关方法

redis.service.ts 中添加以下方法:

  generateSSOKey(userId: string) {
    return `sso: ${userId}`;
  }

  setSSO(key: string, value: string, expiresIn?: number) {
    if (expiresIn === undefined) {
      expiresIn = getBaseConfig(this.configService).jwt.expiresIn;
    }

    return this.redis.set(key, value, 'EX', expiresIn);
  }

  getSSO(key: string) {
    return this.redis.get(key);
  }

  delSSO(key: string) {
    return this.redis.del(key);
  }

  generateSignInErrorsKey(ip: string, userAgent: string) {
    const data = `${ip}:${userAgent}`;
    const key = createHash('sha256').update(data).digest('hex');
    return `sign-in:errors: ${key}`;
  }

  setSignInErrors(key: string, value: number) {
    const expiresIn = getBaseConfig(this.configService).signInErrorExpireIn;
    return this.redis.set(key, value, 'EX', expiresIn);
  }

  async getSignInErrors(key: string) {
    const result = (await this.redis.get(key)) || 0;
    return +result;
  }

  delSignInErrors(key: string) {
    return this.redis.del(key);
  }

在每次登录时,调用 setSSO 方法将生成的访问 token 保存到 redis 中,以实现单点登录功能(还需在 jwt 策略中校验,后面会讲到)。

setSignInErrors 方法用来将用户登录错误的次数记录到 redis 中,登录时可以校验错误次数,如果错误次数过多则可以拒绝用户登录。

在 env 文件中添加登录错误次数相关变量:

SIGN_IN_ERROR_LIMIT=3
SIGN_IN_ERROR_EXPIRES_IN=300

/src/common/config/index 中添加以下变量:

signInErrorLimit: +configService.get<number>('SIGN_IN_ERROR_LIMIT', 5),
signInErrorExpireIn: +configService.get<number>(
    'SIGN_IN_ERROR_EXPIRES_IN',
    60 * 5,
),

4.4 添加登录方法

首先在 /src/common 下新建一个 types 文件夹,然后添加以下文件和内容:

// src/common/types/index.d.ts

export interface IPayload {
  userName: string;
  userId: string;
}

后面我们将会使用这两个信息来生成访问凭证。

打开 auth.service.ts,添加以下代码:

...

import { BadRequestException } from '@nestjs/common';
import { RedisService } from 'src/redis/redis.service';
import { LoginDto } from './dto/auth.dto';
import { UserService } from 'src/user/user.service';
import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcrypt';

import type { IPayload } from 'src/common/types';

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

  async validateCaptcha(ip: string, userAgent: string, captcha: string) {
    const key = this.redisService.generateCaptchaKey(ip, userAgent);
    const value = await this.redisService.getCaptcha(key);

    if (value && value.toLowerCase() === captcha.toLowerCase()) {
      this.redisService.delCaptcha(key);
      return true;
    }

    return false;
  }
  
  validateUser(
    userName: string,
    password: string,
    isValidatePwd?: true,
  ): Promise<false | IPayload>;
  validateUser(
    userName: string,
    password: undefined,
    isValidatePwd: false,
  ): Promise<false | IPayload>;
  async validateUser(
    userName: string,
    password: string,
    isValidatePwd?: boolean,
  ): Promise<false | IPayload> {
    const user = await this.userService.findUser(userName);
    if (!user || user.disabled) {
      return false;
    }

    if (isValidatePwd === false) {
      return { userName: user.userName, userId: user.id };
    }

    const isPasswordValid = await bcrypt.compare(password, user.password);
    if (!isPasswordValid) {
      return false;
    }

    return { userName: user.userName, userId: user.id };
  }
  
  generateTokens(payload: IPayload) {
    const config = getBaseConfig(this.configService);
    const algorithm = config.jwt.algorithm;
    const accessToken = this.jwtService.sign(payload, {
      algorithm,
      expiresIn: config.jwt.expiresIn,
    });
    const refreshToken = this.jwtService.sign(payload, {
      algorithm,
      expiresIn: config.jwt.refreshTokenIn,
    });

    return { accessToken, refreshToken };
  }
  
  async login(data: LoginDto, ip: string, userAgent: string) {
    const signInErrorsKey = this.redisService.generateSignInErrorsKey(
      ip,
      userAgent,
    );
    const signInErrors =
      await this.redisService.getSignInErrors(signInErrorsKey);
    if (signInErrors >= getBaseConfig(this.configService).signInErrorLimit) {
      const expiresIn =
        getBaseConfig(this.configService).signInErrorExpireIn / 60;

      throw new BadRequestException(
        `验证码/用户名/密码错误次数过多,请${expiresIn}分钟后再试`,
      );
    }

    const isCaptchaValid = await this.validateCaptcha(
      ip,
      userAgent,
      data.captcha,
    );
    if (!isCaptchaValid) {
      this.redisService.setSignInErrors(signInErrorsKey, signInErrors + 1);
      throw new BadRequestException('验证码错误');
    }

    const payload = await this.validateUser(data.userName, data.password);
    if (!payload) {
      this.redisService.setSignInErrors(signInErrorsKey, signInErrors + 1);
      throw new BadRequestException('用户名或密码错误,或账号已被禁用');
    }

    const tokenObj = this.generateTokens(payload);
    this.redisService.setSSO(
      this.redisService.generateSSOKey(payload.userId),
      tokenObj.accessToken,
    );

    return tokenObj;
  }

}

validateCaptcha 这个方法用来比对 redis 中的验证码与用户登录时传过来的验证码是否一致。

validateUser 方法用来验证用户登录信息,这里我们使用了函数重载,这是为了后续在 JwtStrategy 中验证用户时,可以不用传入密码。

提示:因为保存到数据库中的用户密码是使用 bcrypt 库加密后的密文,所以也只能使用 bcrypt 库的 compare 方法去比较。

generateTokens 方法用来生成访问凭证与刷新凭证,后续在刷新 token 方法中也会用到。

login 方法中,我们首先校验用户登录错误次数是否超过了限定的值,如果超过了,则直接返回错误,让用户一段时间后再试。然后校验数据库中是否存在该用户,存在,则生成两个 token 返回,并将 accessToken 保存到 redis 中。

为什么要生成两个 token ?
双token:服务端返回两个token,一个有效期短(30分钟过期)的 accessToken 用来认证用户身份,一个有效期长(7天)的 refreshToken 用来刷新token。
优点:1. 用户只需要7天内登录过一次,即可刷新登录状态,不用频繁重新登录;2. accessToken 有效期短,被盗损失小,而 refreshToken 只在第一次获取和刷新 accessToken 时才在网络中传输,被盗风险小,万一被盗,刷新 accessToken 也需要同时提供两个 token。

4.5 添加登录接口

打开 auth.entity.ts 文件,添加以下实体类:

export class LoginEntity {
  @ApiProperty({ description: '访问令牌' })
  accessToken: string;

  @ApiProperty({ description: '刷新令牌' })
  refreshToken: string;
}

将用来为 Swagger 文档提供登录接口响应数据的类型说明。

打开 auth.controller.ts 文件,添加登录接口:

...
import { ... , Body, Post } from '@nestjs/common';
import { AuthEntity, LoginEntity } from './entities/auth.entity';
import { LoginDto } from './dto/auth.dto';
import { BaseResponseEntity } from 'src/common/entities/base-response.entity';

@ApiTags('auth')
@Controller('auth')
export class AuthController {
  ...

  @ApiOperation({
    summary: '登录',
  })
  @ApiBaseResponse(LoginEntity)
  @Post('login')
  login(
    @Body() data: LoginDto,
    @Ip() ip: string,
    @Headers('user-agent') userAgent: string,
  ): Promise<LoginEntity> {
    return this.authService.login(data, ip, userAgent);
  }
}

@ApiOperation() 装饰器用来为 Swagger 文档的登录接口添加说明。
@ApiBaseResponse() 是我们之前封装的一个装饰器,是用来:

  1. 定义 API 端点的成功响应;
  2. 为 Swagger 文档提供该接口响应数据的类型说明。

@Post() 装饰器用来声明一个 POST 类型的接口。@Body() 装饰器用来获取接口请求体中的参数数据,这里还指定使用 LoginDto 类对获取的参数进行格式校验。
现在打开 Swagger API 文档可以看到登录接口了。

image.png

我们点击 Try it out 按钮发送一个请求试试

image.png

发送请求后,可以看到接口返回了错误信息,因为我们参数传了空字符串。

image.png

但我们会发现,它返回的 message 错误信息是一个数组,而不是一个字符串,格式与成功响应时不一致,那我们要如何解决呢?

5. 全局异常过滤器

虽然 NestJS 内置的基础异常过滤器可以处理很多情况,但是有时候我们还是希望能够在异常时能记录一些日志信息,或在出现异常时返回自定义的响应内容。

让我们来添加一个捕获一切异常的过滤器吧。首先创建 /src/common/filter/all-exception.filter.ts 文件,然后添加以下代码:

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import { Inject } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { BaseResponseEntity } from '../entities/base-response.entity';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  constructor(
    private readonly httpAdapterHost: HttpAdapterHost,
    @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
  ) {}

  catch(exception: unknown, host: ArgumentsHost): void {
    let httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal server error';

    if (exception instanceof HttpException) {
      httpStatus = exception.getStatus();

      const res = exception.getResponse() as { message: string[] };
      message = res?.message?.join ? res?.message[0] : exception.message;
      this.handleHttpException(exception, httpStatus);
    } else if (
      exception instanceof Prisma.PrismaClientKnownRequestError ||
      exception instanceof Prisma.PrismaClientUnknownRequestError
    ) {
      message = exception.message;
      this.handlePrismaException(exception);
    } else if (exception instanceof Error) {
      this.handleGenericError(exception);
    } else {
      this.handleUnknownError(exception);
    }

    const responseBody: BaseResponseEntity = {
      statusCode: httpStatus,
      data: null,
      message: message,
    };

    const { httpAdapter } = this.httpAdapterHost;
    const ctx = host.switchToHttp();
    httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
  }

  private handleHttpException(exception: HttpException, status: number): void {
    this.logger.error(`HTTP Exception: ${exception.message}`, {
      status,
      stack: exception.stack,
    });
  }

  private handlePrismaException(
    exception:
      | Prisma.PrismaClientKnownRequestError
      | Prisma.PrismaClientUnknownRequestError,
  ): void {
    if (exception instanceof Prisma.PrismaClientUnknownRequestError) {
      this.logger.error(`Prisma Unknown Exception: ${exception.message}`, {
        stack: exception.stack,
      });
      return;
    }

    this.logger.error(`Prisma Exception: ${exception.message}`, {
      code: exception.code,
      meta: exception.meta,
      stack: exception.stack,
    });
  }

  private handleGenericError(exception: Error): void {
    this.logger.error(`Unhandled Exception: ${exception.message}`, {
      stack: exception.stack,
    });
  }

  private handleUnknownError(exception: unknown): void {
    this.logger.error('Unknown error', { exception });
  }
}

在这个异常过滤器中使用了 httpAdapter.reply 方法来修改响应内容,并分别处理了 Http 异常、 prisma 异常、 已知异常、未知异常。

接下来我们需要将这个异常过滤器注册为全局异常过滤器,在 main.ts 中添加以下代码:

...
import { AllExceptionsFilter } from './common/filter/all-exception.filter';

async function bootstrap() {
  ...

  const logger = app.get(WINSTON_MODULE_NEST_PROVIDER);
  app.useLogger(logger);
  // 主要增加部分
  app.useGlobalFilters(
    new AllExceptionsFilter(app.get(HttpAdapterHost), logger),
  );

  ...
}

我们注册了一个全局过滤器,并向这个过滤器传递了两个参数,一个 HttpAdapterHost 修改响应内容时需要用到,一个 logger 记录日志时需要使用。

现在我们再打开 Swagger API 在线文档,发送一个参数为空字符串的请求,可以发现 message 错误信息只是一个字符串了,而不是像之前一样返回了数组了。

6. 身份验证

在本文中将使用 passport@nestjs/passportpassport-jwt 来实现身份验证策略。

passport 拥有丰富的 strategies 生态系统,实现了各种身份验证机制。这里我们使用 passport-jwt 来实现特定的身份验证策略。

@nestjs/passport 可以通过请求头的 token 来验证用户,并将通过身份验证的用户的信息附加到 Request 对象,以便在路由处理程序中进一步使用。

运行以下命令安装相关库:

pnpm add passport passport-jwt @nestjs/passport

6.1 添加身份认证策略

/src/auth 文件夹下新建一个 jwt.strategy.ts 文件并添加以下内容:

import { ExtractJwt, Strategy, StrategyOptionsWithRequest } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RedisService } from 'src/redis/redis.service';
import { AuthService } from './auth.service';
import { IPayload } from 'src/common/types';
import { join } from 'path';
import { readFileSync } from 'fs';
import { getBaseConfig } from 'src/common/config';

import { Request } from 'express';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly configService: ConfigService,
    private readonly redisService: RedisService,
    private readonly authService: AuthService,
  ) {
    const staticPath = join(__dirname, '../../../');
    const publicKeyPath = getBaseConfig(configService).jwt.publicKeyPath;
    const publicKey = readFileSync(join(staticPath, publicKeyPath));

    const options: StrategyOptionsWithRequest = {
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: publicKey,
      passReqToCallback: true,
    };

    super(options);
  }

  async validate(req: Request, payload: IPayload): Promise<IPayload> {
    const token = req.headers.authorization?.split(' ')[1];
    const validToken = await this.redisService.getSSO(
      this.redisService.generateSSOKey(payload.userId),
    );
    if (validToken !== token) {
      throw new UnauthorizedException('账号已在其他地方登录');
    }

    const isValidUser = await this.authService.validateUser(
      payload.userName,
      undefined,
      false,
    );
    if (!isValidUser) {
      throw new UnauthorizedException('用户不存在或账号已被禁用');
    }

    return payload;
  }
}

ExtractJwt.fromAuthHeaderAsBearerToken 这是一个用于从 HTTP 请求头中提取 JWT Token 的方法,它会查找请求头中的 Authorization 字段并提取“Bearer”后面的 token 字符串。

ignoreExpiration 设置为 false 表示不忽略过期时间,即 token 过期直接返回错误。

secretOrKey 设置为使用公钥验证 token。

passReqToCallback 这个配置是用来决定是否将原始请求对象传递给 validate 回调函数。如果设置为 false ,则 validate 函数只能接受到 payload 数据。

validate 方法中,首先我们校验了单点登录,如果用户在其他地方登录了,在登录方法中会生成新 token 并保存到 redis 中,那我们通过比对 redis 中的 token 是否与请求头中的一致即可知道是否在其他地方登录了。然后,校验了用户是否有效,以防账号被禁用或删除后,还能进行操作。

现在打开 auth.module.ts 文件添加以下代码:

...
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [
    ...
    PassportModule,
  ],
  ...
  providers: [..., JwtStrategy],
})
export class AuthModule {}

这里我们导入了 PassportModuleJwtStrategy,并添加到对应位置。

6.2 添加 jwt 守卫

上面我们添加了 jwt 身份认证策略,但是现在这个策略还不会执行,我们还需要创建一个守卫来调用这个策略。

首先创建 /src/common/decorator/public.decorator.ts 文件并添加以下代码:

import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

这个装饰器将用来标注接口是否需要携带 token ,如果接口上添加了这个装饰器,则不进行身份认证。

现在创建 /src/common/guard/jwt-auth.guard.ts 文件并添加以下代码:

import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorator/public.decorator';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private readonly reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }

    return super.canActivate(context);
  }
}

我们创建了一个 jwt 守卫,它继承于 AuthGuard 守卫,并指定使用“jwt”策略(前面我们创建的策略的默认名称即为“jwt”)。这个守卫将在用户访问需要携带 token 信息的接口时,执行 jwt 身份认证策略代码。

提示:如果你想自定义策略名称,可以这样设置 export class JwtStrategy extends PassportStrategy(Strategy, 'name')

因为我们只有登录与获取验证码接口不需要携带 token 信息,因此我们可以将这个 JwtAuthGuard 守卫注册为全局守卫。

打开 app.module.ts 添加以下代码:

...
import { JwtAuthGuard } from './common/guard/jwt-auth.guard';

@Module({
...
  providers: [
    ...
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
  ],
})
export class AppModule {}

现在打开 API 在线文档,发送一个获取验证码接口请求,会发现返回了 401 错误。这是因为我们没有给这个接口标注不校验 token 。

在接口上添加以下代码:

...
import { Public } from 'src/common/decorator/public.decorator';

  @ApiOperation({
    summary: '获取验证码',
  })
  @ApiBaseResponse(AuthEntity)
  @Public() // 标注不需要身份认证
  @Get('captcha')
  getCaptcha(
    @Ip() ip: string,
    @Headers('user-agent') userAgent: string,
  ): AuthEntity {
    return this.authService.generateCaptcha(ip, userAgent);
  }

在接口上添加 @Public() 装饰器后,再发送一次请求,会发现正常返回验证码数据了。也需要在登录接口上加上这个装饰器哦,不然将无法登录。

7. 登出

要实现用户登出,仅需将 token 标记为失效,然后在 jwt 守卫中验证 token 是否有效。

首先,可以将已失效的 token 保存到 redis 中,在 redis.service.ts 中添加以下方法:

  generateBlackListKey(token: string) {
    return `blacklist: ${token}`;
  }

  setBlackList(token: string) {
    const key = this.generateBlackListKey(token);
    const expiresIn = getBaseConfig(this.configService).jwt.expiresIn;
    this.redis.set(key, 'logout', 'EX', expiresIn);
  }

  async isBlackListed(token: string) {
    const key = this.generateBlackListKey(token);
    const has = await this.redis.exists(key);
    return !!has;
  }

我们将已失效的 token 作为键,‘logout’ 作为值保存到 redis 中。isBlackListed 方法通过判断键是否存在,来判断 token 是否已失效。

然后,在 jwt.strategy.ts 中添加验证代码:

  async validate(req: Request, payload: IPayload): Promise<IPayload> {
    const token = req.headers.authorization?.split(' ')[1];
    const isBlackListed = await this.redisService.isBlackListed(token);
    if (isBlackListed) {
      throw new UnauthorizedException('请重新登录');
    }

    ...
  }

最后,在 auth.service.ts 中添加登出方法:

  async logout(accessToken: string, userId: string) {
    this.redisService.setBlackList(accessToken);
    this.redisService.delSSO(this.redisService.generateSSOKey(userId));
  }

这里还移除用来实现单点登录而在 redis 保存的信息。

现在,打开 auth.controller.ts 添加登出接口:

  @ApiOperation({
    summary: '用户登出',
  })
  @ApiBearerAuth()
  @ApiBaseResponse()
  @Post('logout')
  logout(
    @Headers('authorization') token: string,
    @Req() req: { user: IPayload },
  ) {
    return this.authService.logout(token.split(' ')[1], req.user.userId);
  }

ApiBearerAuth@nestjs/swagger 中导入,用来在 Swagger 文档中标记这个接口需要认证信息。IPayloadsrc/common/types 导入。

Req@nestjs/common 中导入,这里可以拿到 user 信息,是因为我们在 JwtStrategy 中继承了 PassportStrategy,这个策略会将通过验证的 token 解密并将解密后的信息挂载到 req 上。

运行项目后,打开在线 API 文档,可以看到有了登出接口了。

8. 刷新 token

打开 auth.service.ts 文件,添加刷新方法:

  async refreshToken(accessToken: string, refreshToken: string) {
    const isBlackListed = await this.redisService.isBlackListed(accessToken);
    if (isBlackListed) {
      throw new UnauthorizedException('请重新登录');
    }

    let payload: IPayload;
    try {
      payload = this.jwtService.verify<IPayload>(refreshToken);
    } catch {
      throw new UnauthorizedException('请重新登录');
    }

    const validToken = await this.redisService.getSSO(
      this.redisService.generateSSOKey(payload.userId),
    );
    if (accessToken !== validToken) {
      throw new UnauthorizedException('该账号已在其他地方登录,请重新登录');
    }

    const validUser = await this.validateUser(
      payload.userName,
      undefined,
      false,
    );
    if (validUser === false) {
      throw new UnauthorizedException('用户不存在或账号已被禁用');
    }

    const tokenObj = this.generateTokens(payload);
    this.redisService.setSSO(
      this.redisService.generateSSOKey(payload.userId),
      tokenObj.accessToken,
    );
    return tokenObj;
  }

在该方法中,我们首先检查了访问凭证 token 是否在黑名单中,如果在,则代表该账号已登出。然后,验证了 refreshToken 是否有效,如果有效,则验证用户是否已在其他地方登录。通过以上验证后,重新生成两个 token 返回。

打开 auth.dto.ts 文件,添加以下代码:

export class RefreshTokenDto {
  @IsString({ message: 'refreshToken 必须是字符串' })
  @IsNotEmpty({ message: 'refreshToken 不能为空' })
  @ApiProperty({ description: '刷新令牌' })
  refreshToken: string;
}

该 dto 将用来验证接口参数格式是否正确并为 swagger 文档提供参数说明。

现在,打开 auth.controller.ts 文件,添加刷新接口:

  @ApiOperation({ summary: '刷新 token' })
  @ApiBearerAuth()
  @ApiBaseResponse(LoginEntity)
  @Public()
  @Post('refresh-token')
  refreshToken(
    @Headers('authorization') token: string,
    @Body() data: RefreshTokenDto,
  ): Promise<LoginEntity> {
    if (!token) {
      throw new BadRequestException('请求头中必须包含 authorization 属性');
    }

    return this.authService.refreshToken(
      token.split(' ')[1],
      data.refreshToken,
    );
  }

为什么使用 @Public() 将刷新 token 接口标记为公开接口?因为访问该接口时,token 已过期。

正因为我们将它设为了公开接口,所以它不会经过 JwtStrategy 的验证,所以我们在刷新方法里做了大量校验。

下一章节见~