RBAC权限控制

326 阅读4分钟

前言

一直以来我都把 401403 都统称为鉴权失败,但实际上二者还是有区别的,一般 401 被称为 身份验证失败,即 不存在 Token 或者 Token 失效403 被称为 鉴权失败,即 该 Token 所代表的用户没有该功能的权限

在前面我们做了登录和注册功能,只是做了身份验证,那么接下来就来处理 鉴权

RBAC

RBAC(Role Based Access Control) 即基于角色的权限控制:

    graph LR
    用户1 --> 角色1 --> 权限1
    角色1 --> 权限2

用户关联角色角色关联权限多对多 的表关系。

如此我们就需要设计 3 个主表:userrolepermission,再然后需要设计 2 个关联表:user_role_relationrole_permission_relation

// user.entity.ts
@Entity()
export class User {
  // ...省略 user 的其他字段

  @ManyToMany(() => Role, (role) => role.users)
  @JoinTable({
    name: 'user_role_relation',

    // joinColumn 对应当前 user表,所以里面的name是 user_id
    // 即: user_role_relation 表里的 user_id 是与 User 表里的 id 关联
    joinColumn: {
      name: 'user_id',
      referencedColumnName: 'id',
    },

    inverseJoinColumn: {
      name: 'role_id',
      referencedColumnName: 'id',
    },
  })
  roles: Role[]
}
// role.entity.ts
@Entity()
export class Role {
  // ...省略 role 的其他字段

  @ManyToMany(() => User, (user) => user.roles)
  users: User[]

  @ManyToMany(() => Permission, (permission) => permission.roles)
  @JoinTable({
    name: 'role_permission_relation',

    joinColumn: {
      name: 'role_id',
      referencedColumnName: 'id',
    },

    inverseJoinColumn: {
      name: 'permission_id',
      referencedColumnName: 'id',
    },
  })
  permissions: Permission[]
}
// permission.entity.ts
@Entity()
export class Role {
  // ...省略 permission 的其他字段

  @ManyToMany(() => Role, (role) => role.permissions)
  roles: Role[]
}

上面三个是主表,并且我在 userrole 里定义了 2 个关联表。

全局守卫-身份验证

在登录注册的文章中,我使用了 @UseGuards(LoginGuard) 来对某个接口 Handler 做身份验证。

// user.controller.ts
@Get('/list')
@UseGuards(LoginGuard) // 路由守卫,身份验证
findAll() {
  return this.userService.findAll()
}

但实际上,我们的身份验证是大部分接口都需要的,如果给每个接口上都加上这个装饰器,则会显得代码臃肿多余。所以,我们一般会将这种守卫设为全局守卫

// app.module.ts
@Module({
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_GUARD,
      useClass: LoginGuard,
    },
    {
      provide: APP_GUARD,
      useClass: RoleGuard,
    },
  ],
})
export class AppModule {}

给予 APP_GUARD 这个 token 的 provide 就意味着全局守卫,这里我定义了 2 个全局守卫,一个负责身份验证,一个负责权限验证

但如此之后,每个接口都会触发 LoginGuard 这个守卫,但是像注册、登录这种接口明显是不需要身份验证的,那么其实我们可以有 2 种方式:

  1. 添加白名单,白名单内的不需要身份验证

  2. 添加条件,只有满足这个条件才去身份验证

这里我们采用第二种方式,我们添加一个自定义装饰器:

// 该装饰器功能:给 class 或 handler 添加上 REQUIRE_LOGIN :true 这个字段
export const RequireLogin = () => SetMetadata(REQUIRE_LOGIN, true)

然后我们只需要设计成:只有存在 @RequireLogin() 的 class 或 handler 才会触发身份验证即可。

那么就只需要在我们的 LoginGuard 中使用 reflector

@Injectable()
export class LoginGuard implements CanActivate {
  @Inject()
  private reflector: Reflector

  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    // 如果存在 REQUIRE_LOGIN 这个元数据,则需要身份验证
    // getAllAndOverride 这个方法比较有意思,获取并覆盖,有点类似 Object.assign
    const requireLogin = this.reflector.getAllAndOverride(REQUIRE_LOGIN, [context.getClass(), context.getHandler()])

    console.log({ requireLogin })

    if (!requireLogin) {
      return true
    }
  }
}

然后我们只需要在 controller 里的 class 或者 handler 上添加 @RequireLogin() 即可:

// 在 handler 上添加
@Get('/list')
@RequireLogin()
findAll() {
  return this.userService.findAll();
}

// 在 class 上添加
@Controller('book')
@RequireLogin()
export class BookController {
  constructor(private readonly bookService: BookService) {}

  @Post('/create')
  async create(@Body() createBookDto: CreateBookDto) {
    return await this.bookService.create(createBookDto);
  }

  @Get('/list')
  findAll() {
    return this.bookService.findAll();
  }
}

在 class 上添加了之后就不需要在其他每个 handler 上添加了。因为:

// 在 class 添加之后怎么样都会返回 true
const requireLogin = this.reflector.getAllAndOverride(REQUIRE_LOGIN, [context.getClass(), context.getHandler()])

当然,实际上你也可以用 @UseGuards(LoginGuard) 给 class 上添加这个守卫,最终效果也是一样的,实现方法有很,只要语义清晰,可读性高的都是好方法。

全局守卫-鉴权

我在上面还提到了一个 RoleGuard 的全局守卫,该守卫是在身份验证成功之后,用来判断用户是否有调用这个接口的权限

@Injectable()
export class RoleGuard implements CanActivate {
  @Inject()
  private userService: UserService

  @Inject()
  private reflector: Reflector

  @Inject()
  private redisService: RedisService

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest<Request>()
    // 获取 PERMISSION_CODE 值
    const code = this.reflector.get(PERMISSION_CODE, context.getHandler())

    // 如果没有 code 值则说明该接口不需要权限
    let isCan = !code ? true : false
    if (code) {
      // 如果 code 有值则先去 redis 中看看有没有命中缓存
      const userId = req.userInfo.userId + ''
      const list = await this.redisService.getAuth(userId)

      if (list && list?.length > 0) {
        console.log('定位到缓存, 使用缓存数据', list)

        if (list.some((v) => v == code)) {
          isCan = true
        }
      } else {
        // 如果没有缓存,则需要去数据库中查询,并存储到 redis 缓存中
        console.log('未查找到缓存数据,生成缓存中.....')
        const roles = await this.userService.findRolesByIds(req.userInfo.roles)
        const permissionList = roles.map((v) => v.permissions?.map((v) => v.name))
        const roleSet = new Set(permissionList.flat(1))

        if (roleSet.has(code)) {
          isCan = true
        }

        this.redisService.setAuth(userId, [...roleSet])
      }
    }

    if (isCan == false) {
      throw new ForbiddenException('您没有该权限')
    }

    return isCan
  }
}

首先我们可以看到上面有 PERMISSION_CODE ,这个也是我写了一个自定义的装饰器:

export const SetAuthCode = (code: string) => SetMetadata(PERMISSION_CODE, code)

然后在接口 handler 上添加:

@Post('/create')
@SetAuthCode('新增书籍')
async create(@Body() createBookDto: CreateBookDto) {
  return await this.bookService.create(createBookDto);
}

这里我添加的是名为: 新增书籍 ,在真正项目中这个值应该为一个 英文字母 的值,比如 BOOK_CREATE 这里用中文更清晰明了,所以简单表达了。

思路和身份验证的类似,不过为了性能方面的考虑,我们还需要在鉴权这里调用 redis 缓存。