一个用户在使用系统做某些操作的时候,系统会去数据库或者其他持久化的地方查询该用户所拥有的权限,然后根据查询出的结果判断此次操作是否正常
简单的情况,一张表存储用户的权限,然后直接查询判断即可。但用户量足够大又或者权限非常多的话怎么办呢?一个新的系统需要接入用户、权限的时候,又该怎么办?
RBAC 权限设计#
什么是 RBAC 模型#
为了解决前述的问题,我们将引入 RBAC 权限管理设计。
RBAC(Role-Based Access Control) 的三要素即用户、角色与权限。 用户通过赋予的角色,执行角色所拥有的权限。

RBAC 引入之后的流程如上图所示,用户在进入系统之后,会先进行角色判断,再根据对应的角色查询所匹配的权限,最后根据返回结果来判断是否可执行。
直观来说,整个调用的链路被拉长了,直接使用用户与权限的绑定关系,明显速度会更快。
更加直观的区别如下图:

这种记录每个用户有什么权限的方式,其实也有专业的说法,叫做ACL(Access Control List)访问控制表
而RBAC是下面这个样子的:

在具体处理的时候,先给角色分配权限,然后给用户分配对应的角色
比如,有A,B,C三个权限,用户也有三个张三,李四,王五,首先如果是ACL的方式,在分配时,就需要每个用户分别分配ABC三个权限。如果是RBAC的方式,只需要将三个权限分配给一个角色,比如管理员角色,然后这一个角色分配给用户就行。
这样说好像不是太直观,换个说法,当权限有增删改的时候,如果我们新增了一个D的权限,如果要给三个用户分配就需要依次再分配三次,但是RBAC的话,只需要将这个新增的权限,给到对应的角色即可。
通过下述的表格数据,我们来对比一下两个方案的差别:
| 方案 | 用户量 | 权限数 | 权限表数据量 |
|---|---|---|---|
| 用户 -> 权限 | 1 | 10 | 10 * 1 |
| 用户 -> 权限 | 100,000 | 10 | 10 * 100,000 |
| 用户 -> 角色 -> 权限 | 1 | 10 | 10 * Role |
| 用户 -> 角色 -> 权限 | 100,000 | 10 | 10 * Role |
上面的数据可能看得有些懵懂,我们转换文字版本来解释一下:
如果一个用户拥有 10 个权限的话,使用用户权限关联表后,一个用户就会有 10 条数据,10 万个用户的话就有 100 万的数据,代表着当一个用户进入系统之后,我们需要在百万级别的数据表中查询对应的权限数据。
而使用 RBAC 之后,当用户进入系统之后,先查询用户对应的角色,再查询角色映射对应的权限表,即便是一个角色对应一个用户,那么查询量也就是在 10 * 10 ,比直接查询百万数据表的数据量直线下降,如上对比可以看出,使用 RBAC 能大量节约查询成本与时间。
同时一个角色可以挂载多个权限,从实际使用场景、覆盖的范围以及性能优化上都比单纯的用户-权限表更高效。
其实,RBAC模型还有细分,我们上面这种划分,基本是最简单的用户角色权限管理模型RBAC0,除此之外还有RBAC1、RBAC2、RBAC3,这几个都是在RBAC0的基础上,在进行细分,比如:一个角色可以从另一个角色继承许可权,角色还具有上下级的关系;角色的动态职责和静态职责分离,甚至还可以有用户组,角色组的处理。我们这里就不纠结太多,理解RBAC0模型就已经足够了。
RBAC表结构设计#
一般情况下,RBAC0模型设计数据库表结构如下:

针对上面这样的数据表设计,我们在代码的entity中,应该是下面这样的设计:

用户、角色、权限都是多对多关系
工程创建#
还是直接通过nest cli命令创建项目
nest n rbac-test -g -p pnpm先安装必要的依赖
pnpm add @nestjs/typeorm typeorm mysql2 -S创建user模块
nest g res user --no-spec还是先在AppModule中引入TypeOrmModule
import { Module } from '@nestjs/common'import { AppController } from './app.controller'import { AppService } from './app.service'import { TypeOrmModule } from '@nestjs/typeorm'import { UserModule } from './user/user.module'
@Module({ imports: [ TypeOrmModule.forRoot({ type: 'mysql', host: 'localhost', port: 3306, username: 'root', password: '123456', database: 'rbac_test', synchronize: true, logging: true, entities: [__dirname + '/**/*.entity{.ts,.js}'], timezone: 'Z', }), UserModule, ], controllers: [AppController], providers: [AppService],})export class AppModule {}分别创建User、Role、Permission实体:
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable,} from 'typeorm'import { Role } from './role.entity'
@Entity()export class User { @PrimaryGeneratedColumn() id: number
@Column() username: string
@Column() password: string
@ManyToMany(() => Role) @JoinTable({ name: 'user_role_relation', }) roles: Role[]}import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn,} from 'typeorm'import { Permission } from './permission.entity'
@Entity()export class Role { @PrimaryGeneratedColumn() id: number
@Column() name: string
@ManyToMany(() => Permission) @JoinTable({ name: 'role_permission_relation', }) permissions: Permission[]}import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity()export class Permission { @PrimaryGeneratedColumn() id: number
@Column() name: string}现在通过pnpm run start:dev启动服务,就可以直接帮我们在rbac_test数据库创建相应的表了。
数据录入#
为了测试,我们通过对象的方式直接插入数据,在service中做出处理
@Injectable()export class UserService { @InjectEntityManager() private entityManager: EntityManager
async initData() { const user1 = new User() user1.username = 'jack' user1.password = '123456'
const user2 = new User() user2.username = 'rose' user2.password = '123456'
const user3 = new User() user3.username = 'tom' user3.password = '123456'
const role1 = new Role() role1.name = '超级管理员'
const role2 = new Role() role2.name = '员工管理员'
const role3 = new Role() role3.name = '部门管理员'
const permission1 = new Permission() permission1.name = '新增 员工' const permission2 = new Permission() permission2.name = '更新 员工' const permission3 = new Permission() permission3.name = '删除 员工' const permission4 = new Permission() permission4.name = '查询 员工'
const permission5 = new Permission() permission5.name = '新增 部门' const permission6 = new Permission() permission6.name = '更新 部门' const permission7 = new Permission() permission7.name = '删除 部门' const permission8 = new Permission() permission8.name = '查询 部门'
role1.permissions = [ permission1, permission2, permission3, permission4, permission5, permission6, permission7, permission8, ]
role2.permissions = [permission1, permission2, permission3, permission4] role3.permissions = [permission5, permission6, permission7, permission8]
user1.roles = [role1] user2.roles = [role2] user3.roles = [role3]
await this.entityManager.save(Permission, [ permission1, permission2, permission3, permission4, permission5, permission6, permission7, permission8, ])
await this.entityManager.save(Role, [role1, role2, role3])
await this.entityManager.save(User, [user1, user2, user3]) }}为了外部能够访问,controller中添加路由:
@Controller('user')export class UserController { constructor(private readonly userService: UserService) {}
@Get('initData') async initData() { await this.userService.initData() return '初始化数据成功' }
//......}在apiFox中访问路由,就能直接帮我们创建相关数据了

登录与JWT处理#
接下来就和上节课一样,处理一下登录和JWT,登录同样做一下简单验证,所以我们先导入需要用到的包,登录主要是class-validator装饰器
pnpm add class-validator class-transformer -S然后和之前一样,我们在LoginUserDto上做一下简单的验证
import { IsNotEmpty } from 'class-validator'
export class LoginUserDto { @IsNotEmpty({ message: '用户名不能为空', }) username: string
@IsNotEmpty({ message: '密码不能为空', }) password: string}在Controller上添加login方法,并且对参数添加验证管道
@Post('login')async login(@Body(ValidationPipe) user: LoginUserDto) { console.log(user); return '登录成功';}同样现在就已经有参数的验证了:

接下来实现查询数据库的逻辑,在 UserService 添加 login 方法:
async login(loginUserDto: LoginUserDto) { const user = await this.entityManager.findOne(User, { where: { username: loginUserDto.username, }, relations: { roles: true, }, });
if (!user) { throw new HttpException('不存在该用户', HttpStatus.ACCEPTED); }
if (user.password !== loginUserDto.password) { throw new HttpException('密码不正确', HttpStatus.ACCEPTED); }
return user;}使用entityManager.findOne方法查询User信息,并且通过relations关联查出Role角色的信息,现在Controller中就可以调用Service里的login方法测试一下:
@Post('login')async login(@Body(ValidationPipe) user: LoginUserDto) { const result = await this.userService.login(user); console.log(result); return result;}
登录成功之后,把相关信息存放到JWT中,首先引入JWT相关包
pnpm add @nestjs/jwt -S首先还是直接全局配置JWT,在AppMoudle中配置一下:
import { Module } from '@nestjs/common'import { AppController } from './app.controller'import { AppService } from './app.service'import { TypeOrmModule } from '@nestjs/typeorm'import { UserModule } from './user/user.module'import { JwtModule } from '@nestjs/jwt'
@Module({ imports: [ JwtModule.register({ global: true, secret: 'MySecret', signOptions: { expiresIn: '1d' }, }), TypeOrmModule.forRoot({ type: 'mysql', host: 'localhost', port: 3306, username: 'root', password: '123456', database: 'rbac_test', synchronize: true, logging: true, entities: [__dirname + '/**/*.entity{.ts,.js}'], timezone: 'Z', }), UserModule, ], controllers: [AppController], providers: [AppService],})export class AppModule {}global
import { Body, Controller, Get, Inject, Post, ValidationPipe,} from '@nestjs/common'import { UserService } from './user.service'import { LoginUserDto } from './dto/login-user.dto'import { JwtService } from '@nestjs/jwt'
@Controller('user')export class UserController { @Inject(JwtService) private jwtService: JwtService
constructor(private readonly userService: UserService) {}
@Post('login') async login(@Body(ValidationPipe) user: LoginUserDto) { const result = await this.userService.login(user) const token = this.jwtService.sign({ user: { username: result.username, roles: result.roles, }, })
return { token, } }}
为了验证登录之后jwt的处理,新添加两个模块employee和department
nest g res employee --no-specnest g res departmen --no-spec两个模块都有对应的controller,现在都能进行简单的访问,具体内容我们就不再进行修改了,主要是现在当然这两个模块的路由,我们还是都能够访问到的

当然,我们现在的目的是,如果没有请求头没有携带JWT信息,就不允许访问。
所以,和之前一样,同样可以创建登录守卫,进行处理
nest g guard login --no-spec --flat在LoginGuard中处理一下业务
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException,} from '@nestjs/common'import { JwtService } from '@nestjs/jwt'import { Observable } from 'rxjs'import { Request } from 'express'
@Injectable()export class LoginGuard implements CanActivate { @Inject(JwtService) private jwtService: JwtService
canActivate( context: ExecutionContext ): boolean | Promise<boolean> | Observable<boolean> { const request: Request = context.switchToHttp().getRequest()
const authorization = request.headers.authorization
if (!authorization) { throw new UnauthorizedException('请先登录') }
try { const token = authorization.split(' ')[1] const data = this.jwtService.verify(token) console.log(data) return true } catch (e) { throw new UnauthorizedException('token 失效,请重新登录 ---' + e.message) } }}Controller中加入登录守卫
@Controller('employee')export class EmployeeController { constructor(private readonly employeeService: EmployeeService) {}
@Post() @UseGuards(LoginGuard) create(@Body() createEmployeeDto: CreateEmployeeDto) { return this.employeeService.create(createEmployeeDto) }
@Get() @UseGuards(LoginGuard) findAll() { return this.employeeService.findAll() }
// .....}当我们头信息没有携带token信息的时候

当登录之后,头信息中携带token信息

但是我们有两个模块,而且每个模块里面的方法都需要去处理守卫,直接把守卫放在路由上太麻烦,当然我们也可以将守卫放在Controller类上。这里我们干脆直接将守卫放在全局,做成全局守卫
@Module({ // ...... providers: [ AppService, { provide: 'APP_GUARD', useClass: LoginGuard, }, ],})export class AppModule {}这样做之后,无论是Employee模块,还是Department模块,就全部加上了守卫


但是现在问题又出来了,设置为全局守卫之后,虽然方便,但是像login这样的登录,也被加入了守卫,像这样的路由,应该是可以直接访问的。

其实无非就是对于一些需要验证的路由或者controller,我们需要过滤一下,而不需要验证的,就直接放行。
对于这种,我们可以使用自定义装饰器帮我们来处理这个问题。
在src下直接添加custom-decorator.ts专门来存放自定义装饰器
import { SetMetadata } from '@nestjs/common'
export const LoginRequired = () => SetMetadata('LoginRequired', true)我们在需要验证的Employee和Department的Controller上,加上自定义装饰器
@Controller('department')@LoginRequired()export class DepartmentController { constructor(private readonly departmentService: DepartmentService) {} ......}
@Controller('employee')@LoginRequired()export class EmployeeController { constructor(private readonly employeeService: EmployeeService) {} ......}然后,我们在登录守卫中,取出@LoginRequired修饰目标 handler 的 metadata 来判断是否需要登录
@Injectable()export class LoginGuard implements CanActivate { @Inject() private reflector: Reflector;
@Inject(JwtService) private jwtService: JwtService;
canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { const request: Request = context.switchToHttp().getRequest();
const isLoginRequired = this.reflector.getAllAndOverride('LoginRequired', [ context.getClass(), context.getHandler(), ]);
console.log('---', isLoginRequired);
if (!isLoginRequired) { return true; }
const authorization = request.headers.authorization;
if (!authorization) { throw new UnauthorizedException('请先登录'); }
try { const token = authorization.split(' ')[1]; const data = this.jwtService.verify(token); console.log(data); return true; } catch (e) { throw new UnauthorizedException('token 失效,请重新登录 ---' + e.message); } }}通过reflector.getAllAndOverride来获取类装饰器上的内容,如果没有@LoginRequired修饰的,就直接返回undefined,下面的判断就直接放行就行了。
但是这样还不够,我们还需要再做登录用户的权限控制,某个用户在访问某个路由的时候,如果没有权限,也应该禁止访问。我们可以进行下面的处理:
1、获取用户所具有的角色,拥有哪些权限
2、路由方法需要什么具体的权限才能访问
3、判断用户拥有的权限和路由权限是否匹配,如果匹配才放行
所以,根据这样的想法:
1、首先在UserService先实现根据RoleId查询关联权限的操作
async findRolesByIds(roleIds: number[]) { return this.entityManager.find(Role, { where: { id: In(roleIds), }, relations: { permissions: true, }, });}2、自定义装饰器permissionRequired修饰具体路由
import { SetMetadata } from '@nestjs/common'
export const LoginRequired = () => SetMetadata('LoginRequired', true)
export const PermissionRequired = (...permissions: string[]) => SetMetadata('PermissionRequired', permissions)在Controller的路由上使用:
// 员工Controller@Controller('employee')@LoginRequired()export class EmployeeController { constructor(private readonly employeeService: EmployeeService) {}
@Post() @PermissionRequired('新增 员工') create(@Body() createEmployeeDto: CreateEmployeeDto) { return this.employeeService.create(createEmployeeDto); }
@Get() @PermissionRequired('查询 员工') findAll() { return this.employeeService.findAll(); } //......}
// 部门Controller@Controller('department')@LoginRequired()export class DepartmentController { constructor(private readonly departmentService: DepartmentService) {}
@Post() @PermissionRequired('新增 部门') create(@Body() createDepartmentDto: CreateDepartmentDto) { return this.departmentService.create(createDepartmentDto); }
@Get() @PermissionRequired('查询 部门') findAll() { return this.departmentService.findAll(); }3、创建permissionGuard守卫,通过用户拥有权限与路由装饰器规定权限进行匹配,如果成功,则方向,所以首先还是创建守卫
nest g guard permission --no-spec --flat同样声明成全局守卫:
providers: [ AppService, { provide: 'APP_GUARD', useClass: LoginGuard, }, { provide: 'APP_GUARD', useClass: PermissionGuard, },],这里permissionGuard具体业务实现有几个问题:
1、需要获取当前用户的角色
2、需要从userService中获取角色具有的所有权限
3、用户权限与路由权限进行匹配
对于获取当前用户的角色,我们可以从JWT中再获取一次,当然,也能在之前通过LoginGuard获取出来的数据再通过request进行传递,所以我们先修改一下之前LoginGuard里面的代码:
@Injectable()export class LoginGuard implements CanActivate { @Inject() private reflector: Reflector;
@Inject(JwtService) private jwtService: JwtService;
canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { // ......
try { const token = authorization.split(' ')[1]; const data = this.jwtService.verify(token); console.log(data);
// 注意request上并没有user属性,因此需要类型处理,我们这里简单处理为any (request as any).user = data.user; return true; } catch (e) { throw new UnauthorizedException('token 失效,请重新登录 ---' + e.message); } }}permissionGuard中处理:
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException,} from '@nestjs/common'import { UserService } from './user/user.service'import { Reflector } from '@nestjs/core'import { Permission } from './user/entities/permission.entity'
@Injectable()export class PermissionGuard implements CanActivate { @Inject(UserService) private userService: UserService
@Inject() private reflector: Reflector
async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest() const user = request.user if (!user) { return true } const roles = await this.userService.findRolesByIds( user.roles.map((item) => item.id) )
const permissions: Permission[] = roles.reduce((acc, cur) => { acc.push(...cur.permissions) return acc }, [])
console.log(permissions)
console.log('拥有的权限---', permissions)
const permissionRequired = this.reflector.getAllAndOverride( 'PermissionRequired', [context.getClass(), context.getHandler()] ) console.log('路由权限---', permissionRequired)
// 如果没有设置权限,则直接通过 if (!permissionRequired) { return true }
// 拥有权限与路由权限进行对比 for (let i = 0; i < permissionRequired.length; i++) { const curPermission = permissionRequired[i] const found = permissions.find((item) => item.name === curPermission) console.log('found---', found) if (!found) { throw new UnauthorizedException('该接口你没有访问的权限') } } return true }}由于用到了UserService,所以,别忘记,需要在User模块导出UserService
@Module({ controllers: [UserController], providers: [UserService], exports: [UserService],})export class UserModule {}