【Nest.js 10】权限管理-登录认证JWT、权限划分

1,030 阅读11分钟

前言

权限管理是软件开发绕不过去的一个业务,之前我曾介绍过前端如何做权限管理:

  1. vue3管理系统权限管理
  2. React18管理系统权限管理

本篇文章将介绍后端如何做权限管理,对Nest不熟悉的,可先查看我之前的文章:

  1. 前端想了解后端?那得先学会 TypeScript 装饰器!
  2. 【Nest.js 10】项目创建&文件分析

1. 基本概念

在Nest的执行顺序中,守卫处于中间件middleware之后,拦截器interceptor之前。

image.png

守卫的作用:根据运行时出现的某些条件(例如权限,角色,访问控制列表等)来确定给定的请求是否由路由处理程序处理,还是直接返回请求失败信息。

守卫其实同拦截器,异常过滤器一样,都是中间件的底层逻辑,不过Nest对其进行了细分,守卫专用作权限校验。

1. 创建守卫

nest g guard guards/roles --no-spec --flat

//简写为
nest g gu guards/roles --no-spec --flat

--no-spec:不生成测试文件。

--flat:不要将生成的文件放入目录中,而是直接放在当前目录下

执行这段命令后,将会在src目录下,新建guards文件夹,并在其下新建roles.guard.ts

通过命令,生成的守卫模块为:

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}

@Injectable():告诉NestJS依赖注入系统这个类可以作为一个服务被注入到其他地方;

canActivate():函数接收单个参数context,即当前请求的上下文。并返回一个布尔值,表示是否阻止请求到达控制器。

2. 绑定守卫

共有三种级别的守卫,分别为:1. 方法范围的守卫;2. 控制器范围的守卫;3. 全局守卫

1. 方法范围的守卫

使用 @UseGuards() 装饰器注册方法范围级别的守卫

@UseGuards(RolesGuard)
@Get('profile')
getProfile() {}

2. 控制器范围的守卫

使用 @UseGuards() 装饰器注册控制器范围级别的守卫

@Controller('cats')
@UseGuards(RolesGuard) //控制器级别的守卫
export class CatsController {}

3. 全局守卫

一般在app.module.ts,providers中,使用useClass进行注册。

import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { RolesGuard } from './guards/roles.guard';
//...

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

2. 配置准备

1. 配置环境变量

目的:项目启动时,根据启动参数,读取不同的配置文件。

  1. 安装
npm i cross-env -D
  1. 修改package.json
  "scripts": {
      "start:dev": "cross-env NODE_ENV=development nest start --watch",
      "start:prod": "cross-env NODE_ENV=production node dist/main",
    },
  1. 在项目中使用 process.env.NODE_ENV 读取环境变量

2. 配置全局白名单

目的:使用JWT接口验证,肯定是要配置白名单的,例如登录接口需配置在白名单内。虽然Nest文档上使用SetMetadata设置元数据来区分不需要权限的接口。但个人而言,更喜欢使用@nestjs/config将配置注入到全局,白名单在配置文件中统一管理。

1. 安装

npm i @nestjs/config

npm i js-yaml

npm i -D @types/js-yaml

@nestjs/config:内部基于dotenv实现,在Nest中,用于提供全局配置。

js-yaml:将 YML 格式的字符串解析成 JavaScript 对象。

2. 自定义配置文件

新建src/config/development.yml

# jwt 配置
jwt:
  secretkey: 'you_secretkey'
  expiresin: '60s'
# 权限 白名单配置
router:
  whitelist:
    [
      { path: '/user/register', method: 'POST' },
      { path: '/user/login', method: 'POST' },
    ]

3. 读取配置文件

新建src/config/index.ts

import { readFileSync } from 'fs';
import * as yaml from 'js-yaml';
import { join } from 'path';

//读取环境变量
const env = process.env.NODE_ENV;

export default () => {
  return yaml.load(
    readFileSync(join(__dirname, '../../', `src/config/${env}.yml`), 'utf8'),
  ) as Record<string, any>;
};

4. 全局注册

修改app.module.ts

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import configuration from './config';
//...

@Module({
  imports: [
    ConfigModule.forRoot({
      cache: true, //缓存
      load: [configuration], //加载配置文件
      isGlobal: true, //设置为全局
    })
  ],
  //...
})
export class AppModule {}

使用ConfigModule.forRoot()静态方法,配置和初始化配置模块。

3. 登录认证 JWT

1. 安装

npm i @nestjs/passport passport

npm i @nestjs/jwt passport-jwt

npm i @types/passport-jwt -D

@nestjs/passport:是一个用于在 NestJS 应用程序中集成 passport 的模块。Passport.js 是一个流行的 Node.js 身份验证中间件,提供了多种身份验证策略,如本地身份验证、OAuth、JWT 等。

@nestjs/jwt:是一个用于在 NestJS 应用程序中实现JWT身份验证的模块。

passport-jwt:是一个 passport 策略,与@nestjs/passport结合使用,用于通过JWT身份验证。

四个包之间的关系:passport 是Node.js身份验证中间件;@nestjs/passport用于将 Passport 集成到 Nest 应用程序中;passport-jwt是passport的一种策略,即验证JWT的token是否有效;@nestjs/jwt是Nest的JWT验证模块,用于JWT模块的注册与token的生成。

2. Auth模块

Auth模块负责注册passport与passport-jwt

1. 使用命令行生成 Auth Module

nest g mo auth

2. 新建auth/auth.strategy.ts

编写身份验证策略,这里验证JWT策略

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.get('jwt.secretkey'),
    });
  }

  async validate(payload) {
    return payload;
  }
}

jwtFromRequest:ExtractJwt.fromAuthHeaderAsBearerToken():从请求头中使用 Authorization 字段提取 JWT,并且期望格式为 Bearer 。

ignoreExpiration:false 不忽略 JWT 的过期时间,即如果令牌过期,将被视为无效。

secretOrKey:验证 JWT 的密钥,这里从配置服务中读取 jwt.secretkey,即上文yml文件的secretkey字段的值you_secretkey

validate:可在validate函数中,做额外的自定义权限校验,例如检查用户状态。这里直接返回参数。

3. 修改auth.module.ts

导入PassportModule,注册JwtStrategy

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

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

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

3. User模块

User模块负责注册@nestjs/jwt,以及在登录接口中返回生成的token值

1. 使用命令行新建一个User模块

nest g mo user

nest g co user --no-spec

nest g s user --no-spec

2. 修改user.module.ts,注册JWT

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  imports: [
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (config: ConfigService) => ({
        secret: config.get('jwt.secretkey'),
        signOptions: { expiresIn: config.get('jwt.expiresin') },
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

JwtModule.registerAsync:动态注册 JWT 模块,允许在运行时从外部配置中获取 JWT 的相关配置。

imports:导入ConfigModule模块,以便使用ConfigService服务

inject:接受ConfigService服务,在实例化过程中,Nest 将解析该数组并将其作为参数传递给工厂函数。

useFactory:允许动态创建提供程序。这里根据ConfigService服务读取配置文件,获取创建的参数

secret:从配置服务中获取 JWT 的秘钥,即yml配置文件中secretkey的值you_secretkey

signOptions:设置 JWT 的签名选项,这里设置过期时间,即yml配置文件中expiresin的值60s

3. 修改user.service.ts

import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

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

  //生成令牌
  createToken(payload: { username: string; userId: string }): string {
    //jwt会使用secret中配置的密钥,对payload进行加密,从而生成token
    //这里根据用户名与用户ID进行加密,生成token
    const accessToken = this.jwtService.sign(payload);
    return accessToken;
  }
}

UserService中包含一个createToken方法,用于根据用户名与用户id生成token。

4. 修改user.controller.ts

import { Controller, Body, Post, Get, Request } from '@nestjs/common';

import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}
  @Post('/login')
  async login(@Body() params) {
    //可先从数据库中查询用户,将用户信息作为载荷,生成token
    //这里直接模拟一条用户数据
    const user = { userId: '1', username: '张三', role: 1 };
    const accessToken = this.userService.createToken(user);
    return {
      accessToken,
    };
  }

  @Get()
  getUser(@Request() req) {
    /* auth.strategy.ts中,validate方法返回的值会自动创建一个user对象,
    并将其作为 req.user 分配给请求对象。
    之后的在别的模块,直接使用req.user便可获取用户信息,这里当调用到该接口时,直接返回用户信息 */
    return req.user;
  }
}

UserController中包含2个方法:

login方法用于在请求/user/login时,将参数传递给userService.createToken以生成token,并将token返回给客户端。

getUser方法用于在请求/user时,将用户信息返回给客户端

4. 守卫

守卫的作用:在控制器处理请求前,先读取配置中的白名单,如果请求路由在白名单中,直接不拦截,否则继续走passport 策略,验证JWT是否有效。

1. 安装

npm i path-to-regexp@^7.1.0

path-to-regexp 是一个用于将 URL 路径字符串转换为正则表达式的 JavaScript 库,主要用于路由匹配。在这里用于校验路径是否匹配

2. 新建src/guards/auth.guard.ts

import { ConfigService } from '@nestjs/config';
import { AuthGuard } from '@nestjs/passport';
import { pathToRegexp } from 'path-to-regexp';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  private globalWhiteList = [];
  constructor(private readonly config: ConfigService) {
    super();
    this.globalWhiteList = [].concat(this.config.get('router.whitelist') || []);
  }

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const isInWhiteList = this.checkWhiteList(context);
    //如果在白名单内,直接返回true
    //否则调用super.canActivate,走PassPort策略
    if (isInWhiteList) {
      return true;
    }

    return super.canActivate(context);
  }

  /**
   * 检查接口是否在白名单内
   * @param context
   * @returns
   */
  checkWhiteList(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    const i = this.globalWhiteList.findIndex((route) => {
      // 请求方法类型相同
      if (req.method.toUpperCase() === route.method.toUpperCase()) {
        // 对比 url
        return !!pathToRegexp(route.path).exec(req.url);
      }
      return false;
    });
    return i > -1;
  }
}

canActivate():接收单个参数context,即当前请求的上下文。并返回一个布尔值,表示是否阻止请求到达控制器。如果请求路由在白名单中,直接返回true。否则调用JWT验证。

3. 修改app.module.ts,全局注册

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
import configuration from './config';
import { JwtAuthGuard } from 'src/guards/auth.guard';
import { APP_GUARD } from '@nestjs/core';
import { UserModule } from './user/user.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      cache: true, //缓存
      load: [configuration], //加载配置文件
      isGlobal: true, //设置为全局
    }),
    AuthModule,
    UserModule,
  ],
  controllers: [],
  providers: [
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard, //全局注册守卫
    },
  ],
})
export class AppModule {}

在providers中,使用useClass注册全局守卫。

运行 npm run start:dev 启动项目,使用POST访问localhost:3000/user/login,获取token。

image.png

将token使用Authorization字段加到请求头中,值为Bearer token(中间有空格),使用GET访问localhost:3000/user,获取用户信息

image.png

对于无权限,或者token过期的请求,接口会返回401状态码报错

{
      "message""Unauthorized",
      "statusCode"401
  }

4. 权限划分

JWT仅负责登录验证,确保用户需先登录才能访问接口。而项目中大部分情况,还需做权限的进一步细分,比如管理员账户,非管理员账户或者是VIP账户,非VIP账户。

思路分为三步:

  1. 登录时,会查询数据库,获取用户权限信息。而passport会通过validate函数将用户信息存放在request.user上。

  2. 通过在路由上使用SetMetadata设置元数据,将路由需要的权限附加在路径处理程序上。

  3. 新建一个守卫,守卫通过reflector获取路由上的元数据,即权限内容。通过req.user获取用户信息,根据用户信息上的权限等级与元数据上的所需权限进行比较,符合返回true,否则false。

第1步在JWT中已配置好,用户的信息为{ userId: '1', username: '张三', role: 1 },通过req.user获取。

1. 设置元数据

  1. 新建src/decorators/require-role.decorator.ts
import { SetMetadata } from '@nestjs/common';

export enum Role {
  SUPER_ADMIN = 1, // 超级管理员
  ADMIN = 2, // 管理员
  DEVELOPER = 3, // 开发者
  HUMAN = 4, // 普通用户
}

export const ROLES_KEY = 'roles';
export const RequireRole = (role: Role) => SetMetadata(ROLES_KEY, role);

自定义@Roles()装饰器,传递参数role,并调用SetMetadata设置元数据

  1. 修改user.controller.ts
//...
import { RequireRole, Role } from 'src/decorators/require-role.decorator';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}
  
  //...

  @RequireRole(Role.SUPER_ADMIN) //使用自定义装饰器
  @Get()
  getUser(@Request() req) {
    return req.user;
  }
}

2. 守卫

1. 新建守卫

修改src/guards/roles.guard.ts

import {
    CanActivate,
    ExecutionContext,
    Injectable,
    ForbiddenException,
  } from '@nestjs/common';
  import { Reflector } from '@nestjs/core';
  import { ROLES_KEY } from 'src/decorators/require-role.decorator';
  
  @Injectable()
  export class RolesGuard implements CanActivate {
    constructor(private readonly reflector: Reflector) {}
  
    async canActivate(ctx: ExecutionContext): Promise<boolean> {
      // 全局配置,
      const req = ctx.switchToHttp().getRequest();
  
      const role = this.reflector.getAllAndOverride(ROLES_KEY, [
        ctx.getClass(),
        ctx.getHandler(),
      ]);
  
      if (role && req.user.role > role) {
        throw new ForbiddenException('对不起,您无权操作');
      }
  
      return true;
    }
  }

使用this.reflector.getAllAndOverride获取元数据。传递常量ROLES_KEY,获取接口对应的权限等级

使用ctx.switchToHttp().getRequest(),获取上下文中的请求信息,从请求信息中获取用户信息

如果用户等级数字大于接口元数据上定义的,说明无权限(数字越小,权限越高)

2. 全局注册守卫

修改app.module.ts

//...
import { JwtAuthGuard } from 'src/guards/auth.guard';
import { RolesGuard } from 'src/guards/roles.guard';

@Module({
  //...  
  providers: [
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard, //全局注册守卫
    },
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ],
})
export class AppModule {}

提示:注意注册顺序,RolesGuard在JwtAuthGuard之后。

因为在user.controller.ts的login中,用户等级为1,代表超级管理员。因此访问localhost:3000/user,接口正常返回。如果将等级降低,改为2

  @Post('/login')
  async login(@Body() params) {
    //可先从数据库中查询用户,将用户信息作为载荷,生成token
    const user = { userId: '1', username: '张三', role: 2 };
    //...
  }

使用POST访问localhost:3000/user/login,重新获取token。再将token添加到请求头Authorization字段中,重新访问localhost:3000/user,接口将提示无权限。

image.png