Skip to content

RBAC权限设计1

一个用户在使用系统做某些操作的时候,系统会去数据库或者其他持久化的地方查询该用户所拥有的权限,然后根据查询出的结果判断此次操作是否正常

简单的情况,一张表存储用户的权限,然后直接查询判断即可。但用户量足够大又或者权限非常多的话怎么办呢?一个新的系统需要接入用户、权限的时候,又该怎么办?

RBAC 权限设计#

什么是 RBAC 模型#

为了解决前述的问题,我们将引入 RBAC 权限管理设计。

RBAC(Role-Based Access Control) 的三要素即用户角色权限。 用户通过赋予的角色,执行角色所拥有的权限。

image-20250211160505815

RBAC 引入之后的流程如上图所示,用户在进入系统之后,会先进行角色判断,再根据对应的角色查询所匹配的权限,最后根据返回结果来判断是否可执行。

直观来说,整个调用的链路被拉长了,直接使用用户与权限的绑定关系,明显速度会更快。

更加直观的区别如下图:

image-20250211160928747

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

而RBAC是下面这个样子的:

image-20250211161507798

在具体处理的时候,先给角色分配权限,然后给用户分配对应的角色

比如,有A,B,C三个权限,用户也有三个张三,李四,王五,首先如果是ACL的方式,在分配时,就需要每个用户分别分配ABC三个权限。如果是RBAC的方式,只需要将三个权限分配给一个角色,比如管理员角色,然后这一个角色分配给用户就行。

这样说好像不是太直观,换个说法,当权限有增删改的时候,如果我们新增了一个D的权限,如果要给三个用户分配就需要依次再分配三次,但是RBAC的话,只需要将这个新增的权限,给到对应的角色即可。

通过下述的表格数据,我们来对比一下两个方案的差别:

方案用户量权限数权限表数据量
用户 -> 权限11010 * 1
用户 -> 权限100,0001010 * 100,000
用户 -> 角色 -> 权限11010 * Role
用户 -> 角色 -> 权限100,0001010 * Role

上面的数据可能看得有些懵懂,我们转换文字版本来解释一下:

如果一个用户拥有 10 个权限的话,使用用户权限关联表后,一个用户就会有 10 条数据,10 万个用户的话就有 100 万的数据,代表着当一个用户进入系统之后,我们需要在百万级别的数据表中查询对应的权限数据。

而使用 RBAC 之后,当用户进入系统之后,先查询用户对应的角色,再查询角色映射对应的权限表,即便是一个角色对应一个用户,那么查询量也就是在 10 * 10 ,比直接查询百万数据表的数据量直线下降,如上对比可以看出,使用 RBAC 能大量节约查询成本与时间。

同时一个角色可以挂载多个权限,从实际使用场景、覆盖的范围以及性能优化上都比单纯的用户-权限表更高效。

其实,RBAC模型还有细分,我们上面这种划分,基本是最简单的用户角色权限管理模型RBAC0,除此之外还有RBAC1RBAC2RBAC3,这几个都是在RBAC0的基础上,在进行细分,比如:一个角色可以从另一个角色继承许可权,角色还具有上下级的关系;角色的动态职责和静态职责分离,甚至还可以有用户组,角色组的处理。我们这里就不纠结太多,理解RBAC0模型就已经足够了。

RBAC表结构设计#

一般情况下,RBAC0模型设计数据库表结构如下:

image-20250211164042486

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

image-20250211164717215

用户、角色、权限都是多对多关系

工程创建#

还是直接通过nest cli命令创建项目

Terminal window
nest n rbac-test -g -p pnpm

先安装必要的依赖

Terminal window
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中访问路由,就能直接帮我们创建相关数据了

image-20250211175021607

登录与JWT处理#

接下来就和上节课一样,处理一下登录和JWT,登录同样做一下简单验证,所以我们先导入需要用到的包,登录主要是class-validator装饰器

Terminal window
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 '登录成功';
}

同样现在就已经有参数的验证了:

image-20250212111729617

接下来实现查询数据库的逻辑,在 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;
}

image-20250212112420597

登录成功之后,把相关信息存放到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设置为全局,这样不用在其他模块每次都引用。现在我们可以在login登录成功之后,存在JWT信息

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,
}
}
}

image-20250212113129073

为了验证登录之后jwt的处理,新添加两个模块employee和department

nest g res employee --no-spec
nest g res departmen --no-spec

两个模块都有对应的controller,现在都能进行简单的访问,具体内容我们就不再进行修改了,主要是现在当然这两个模块的路由,我们还是都能够访问到的

image-20250212131519526

当然,我们现在的目的是,如果没有请求头没有携带JWT信息,就不允许访问

所以,和之前一样,同样可以创建登录守卫,进行处理

Terminal window
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信息的时候

image-20250212132802424

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

image-20250212132737838

但是我们有两个模块,而且每个模块里面的方法都需要去处理守卫,直接把守卫放在路由上太麻烦,当然我们也可以将守卫放在Controller类上。这里我们干脆直接将守卫放在全局,做成全局守卫

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

这样做之后,无论是Employee模块,还是Department模块,就全部加上了守卫

image-20250212133604681

image-20250212133648496

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

image-20250212133819650

其实无非就是对于一些需要验证的路由或者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守卫,通过用户拥有权限与路由装饰器规定权限进行匹配,如果成功,则方向,所以首先还是创建守卫

Terminal window
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 {}