RBAC简介
RBAC(Role Based Access Control)是基于角色的权限控制,简单来说就是给用户赋予一些角色,那么该用户就会拥有这些角色的所有权限。接下来我们就用 NestJS 来实现基于 RBAC 的权限控制
创建表
除了我们已有的 user 表,我们还需创建角色表(role),权限字段表(permission)。可以直接用命令新增两个模块
nest g res role
nest g res permission
进入权限 permission 模块的 entity 中编写 permission 表的字段
//permission.entity.ts
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
export class Permission {
@PrimaryGeneratedColumn()
id: string;
@Column({
length: 50,
})
name: string;
@Column({
length: 100,
nullable: true,
})
desc: string;
@CreateDateColumn()
createTime: Date;
@UpdateDateColumn()
updateTime: Date;
}
一个权限可以赋给多个角色,同时一个角色也可以有多个权限,因此它们是多对多(ManyToMany)的关系,所以我们可以用role_permission_relation
关系表将它们关联起来,角色(role)模块下的 entity 如下
//role.entity.ts
import {
Column,
CreateDateColumn,
Entity,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import {
Permission } from '../../permission/entities/permission.entity';
@Entity()
export class Role {
@PrimaryGeneratedColumn()
id: string;
@Column({
length: 20,
})
name: string;
@CreateDateColumn()
createTime: Date;
@UpdateDateColumn()
updateTime: Date;
@ManyToMany(() => Permission)
@JoinTable({
name: 'role_permission_relation',
})
permissions: Permission[];
}
同样的用户(user)与角色(role)直接也是多对多的关系,因此 user 的 entity 修改如下
import {
Column,
Entity,
PrimaryGeneratedColumn,
BeforeInsert,
ManyToMany,
JoinTable,
} from 'typeorm';
import encry from '../../utils/crypto';
import * as crypto from 'crypto';
import {
Role } from '../../role/entities/role.entity';
@Entity('user')
export class User {
@PrimaryGeneratedColumn()
id: string; // 标记为主键,值自动生成
@Column({
length: 30 })
username: string; //用户名
@Column({
nullable: true })
nickname: string; //昵称
@Column()
password: string; //密码
@Column({
nullable: true })
avatar: string; //头像
@Column({
nullable: true })
email: string; //邮箱
@ManyToMany(() => Role)
@JoinTable({
name: 'user_role_relation',
})
roles: Role[]; //角色
@Column({
nullable: true })
salt: string;
@Column({
type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
create_time: Date;
@Column({
type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
update_time: Date;
@BeforeInsert()
beforeInsert() {
this.salt = crypto.randomBytes(4).toString('base64');
this.password = encry(this.password, this.salt);
}
}
最后在各自的 module 导入实体,以 permission.module.ts 为例
import {
Module } from '@nestjs/common';
import {
PermissionService } from './permission.service';
import {
PermissionController } from './permission.controller';
import {
Permission } from './entities/permission.entity';
import {
TypeOrmModule } from '@nestjs/typeorm';
@Module({
controllers: [PermissionController],
providers: [PermissionService],
imports: [TypeOrmModule.forFeature([Permission])],
})
export class PermissionModule {
}
启动项目,我们就会看到数据库中多了几张表
添加模拟数据
接下来给每个模块添加一个新增数据的接口用于添加数据来观察它们之间的关系
permission 模块
新增 permission 数据很简单,直接保存前端传来的的name
和desc
字段即可
//permission.service.ts
import {
Injectable } from '@nestjs/common';
import {
InjectRepository } from '@nestjs/typeorm';
import {
ApiErrorCode } from 'src/common/enums/api-error-code.enum';
import {
ApiException } from 'src/common/filter/http-exception/api.exception';
import {
Repository } from 'typeorm';
import {
CreatePermissionDto } from './dto/create-permission.dto';
import {
Permission } from './entities/permission.entity';
@Injectable()
export class PermissionService {
constructor(
@InjectRepository(Permission)
private permissionRepository: Repository<Permission>,
) {
}
async create(createPermissionDto: CreatePermissionDto) {
const name = createPermissionDto.name;
const existPermission = await this.permissionRepository.findOne({
where: {
name },
});
if (existPermission)
throw new ApiException('权限字段已存在', ApiErrorCode.PERMISSSION_EXIST);
return await this.permissionRepository.save(createPermissionDto);
}
}
//create-user.dto.ts
export class CreatePermissionDto {
name: string;
desc: string;
}
然后我们可以调用http://localhost:3000/permission
接口新增一些权限字段create
,read
,update
,delete
新增完毕之后,permission 表如下
role 模块
新增 role 表中的数据和上面有所不同,因为它有一个关系字段permissions
,因此需要导入 permission 实体,然后根据前端传来的权限字段 id(permissionIds)查询对应 permission 实体插入 role 表中
//role.module.ts
import {
Module } from '@nestjs/common';
import {
RoleService } from './role.service';
import {
RoleController } from './role.controller';
import {
Role } from './entities/role.entity';
import {
Permission } from '../permission/entities/permission.entity';
import {
TypeOrmModule } from '@nestjs/typeorm';
@Module({
controllers: [RoleController],
providers: [RoleService],
imports: [TypeOrmModule.forFeature([Role, Permission])],
})
export class RoleModule {
}
//create-role.dto.ts
export class CreateRoleDto {
name: string;
permissionIds: number[];
}
//role.service.ts
import {
Injectable } from '@nestjs/common';
import {
InjectRepository } from '@nestjs/typeorm';
import {
In, Repository } from 'typeorm';
import {
CreateRoleDto } from './dto/create-role.dto';
import {
Role } from './entities/role.entity';
import {
Permission } from '../permission/entities/permission.entity';
import {
ApiException } from 'src/common/filter/http-exception/api.exception';
import {
ApiErrorCode } from 'src/common/enums/api-error-code.enum';
@Injectable()
export class RoleService {
constructor(
@InjectRepository(Role)
private roleRepository: Repository<Role>,
@InjectRepository(Permission)
private permissionRepository: Repository<Permission>,
) {
}
async create(createRoleDto: CreateRoleDto) {
//查询传入数组permissionIds的全部permission实体
const permissions = await this.permissionRepository.find({
where: {
id: In(createRoleDto.permissionIds),
},
});
const name = createRoleDto.name;
const existRole = await this.roleRepository.findOne({
where: {
name },
});
if (existRole)
throw new ApiException('角色已存在', ApiErrorCode.ROLE_EXIST);
return this.roleRepository.save({
permissions, name });
}
}
最后调用http://localhost:3000/role
就能添加一些角色,分别添加超级管理员(create
,read
,update
,delete
),管理员(create
,read
,update
),用户(read
)
添加完成之后 role 表如下
同时我们可以查看role_permission_relation
关系表来看一下它们的多对多关系
user 模块
user 模块和 role 模块逻辑差不多,需要查询 role 表,因此要引入 role 实体
//user.module.ts
import {
Module } from '@nestjs/common';
import {
UserService } from './user.service';
import {
UserController } from './user.controller';
import {
User } from './entities/user.entity';
import {
Role } from '../role/entities/role.entity';
import {
TypeOrmModule } from '@nestjs/typeorm';
import {
JwtModule } from '@nestjs/jwt';
@Module({
controllers: [UserController],
providers: [UserService],
imports: [
TypeOrmModule.forFeature([User, Role]),
JwtModule.register({
secret: process.env.JWT_SECRET }),
],
exports: [UserService],
})
export class UserModule {
}
在 user.service.ts 写对应逻辑
import {
HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
CreateUserDto } from './dto/create-user.dto';
import {
User } from './entities/user.entity';
import {
Role } from '../role/entities/role.entity';
import {
InjectRepository } from '@nestjs/typeorm';
import {
In, Repository } from 'typeorm';
import {
ApiException } from 'src/common/filter/http-exception/api.exception';
import {
ApiErrorCode } from 'src/common/enums/api-error-code.enum';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(Role)
private roleRepository: Repository<Role>,
) {
}
async create(createUserDto: CreateUserDto) {
const {
username, password, roleIds } = createUserDto;
const existUser = await this.userRepository.findOne({
where: {
username },
});
if (existUser)
throw new ApiException('用户已存在', ApiErrorCode.USER_EXIST);
try {
//查询数组roleIds对应所有role的实例
const roles = await this.roleRepository.find({
where: {
id: In(roleIds),
},
});
const newUser = await this.userRepository.create({
username,
password,
roles,
});
await this.userRepository.save(newUser);
return '注册成功';
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
async findOne(username: string) {
const user = await this.userRepository.findOne({
where: {
username },
});
if (!user)
throw new ApiException('用户名不存在', ApiErrorCode.USER_NOTEXIST);
return user;
}
}
在 create-user.dto.ts 规定前端传来参数格式
//create-user.dto.ts
export class CreateUserDto {
username: string;
password: string;
roleIds: number[];
}
创建两个用户分别为 admin 和 user1 分别赋予全部角色和用户角色
权限控制
我们可以通过自定义装饰器在调用接口之前传入一个权限数组,即必须拥有数组内的权限才能够调用此接口。所以可与创建一个全局的导航守卫,里面查询到该用户的所有权限然后与自定义装饰器传入的权限进行对比即可
现在 public 目录下新增一个@Permissions
装饰器
//public.decorator.ts
import {
SetMetadata } from '@nestjs/common';
export const Public = () => SetMetadata('isPublic', true);
export const Permissions = (...permissions: string[]) =>
SetMetadata('permissions', permissions);
然后在 permission 模块下创建一个导航守卫
nest g guard permission --no-spec --flat
同样的,这个守卫需要设置为全局守卫
//permission.module.ts
import {
Module } from '@nestjs/common';
import {
PermissionService } from './permission.service';
import {
PermissionController } from './permission.controller';
import {
Permission } from './entities/permission.entity';
import {
TypeOrmModule } from '@nestjs/typeorm';
import {
APP_GUARD } from '@nestjs/core';
import {
PermissionGuard } from '../permission/permission.guard';
@Module({
controllers: [PermissionController],
providers: [
PermissionService,
{
provide: APP_GUARD,
useClass: PermissionGuard,
},
],
imports: [TypeOrmModule.forFeature([Permission])],
})
export class PermissionModule {
}
接下来我们开始写相关逻辑,在 user 中简单写一个测试接口,同时用我们刚写的自定义装饰器传入两个权限
//user.controller.ts
@Post('test')
@Permissions('create', 'read')
test(@Body() testParams) {
return this.userService.test(testParams);
}
修改一下权限守卫,获取传入的元数据
//permission.guard.ts
import {
CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import {
Reflector } from '@nestjs/core';
import {
Observable } from 'rxjs';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
'permissions',
[context.getClass(), context.getHandler()],
);
console.log(requiredPermissions);
return true;
}
}
在调用接口之前我们先把登录守卫修改一下,让其都放行,不然接口是调不通的,只需要修改auth.guard.ts
让其都返回 true 即可
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import {
Request } from 'express';
import {
JwtService } from '@nestjs/jwt';
import {
ConfigService } from '@nestjs/config';
import {
Reflector } from '@nestjs/core';
import {
ApiErrorCode } from 'src/common/enums/api-error-code.enum';
import {
ApiException } from 'src/common/filter/http-exception/api.exception';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private reflector: Reflector,
) {
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
//即将调用的方法
context.getHandler(),
//controller类型
context.getClass(),
]);
//这里暂时都放行
return true;
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) throw new ApiException('验证不通过', ApiErrorCode.FORBIDDEN);
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get('JWT_SECRET'),
});
request['user'] = payload;
} catch {
throw new ApiException(
'登录状态已过期,请重新登录',
ApiErrorCode.LOGIN_EXPIRE,
);
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
调用 user/test 接口查看我们的打印结果
可以发现已经获取到装饰器传来的权限数组了,接下来我们要做的是查询用户的所有权限与之对比,看是否包含装饰器传来的数组,包含的话则放行否则抛出没有权限的错误给前端。
因为需要查询该用户的权限字段,所以在user.service.ts
写一个查询权限字段名的方法,这里模拟查询admin
用户的权限
//user.service.ts
async findPermissionNames() {
const user = await this.userRepository.findOne({
where: {
username: 'admin' },
relations: ['roles', 'roles.permissions'],
});
if (user) {
const permissions = user.roles.flatMap((role) => role.permissions);
const permissionNames = permissions.map((item) => item.name);
return [...new Set(permissionNames)];
} else {
return [];
}
}
因为这个方法要提供给权限守卫使用,所以需要暴露出去,并在permission.module.ts
中导入
//...
import {
UserModule } from 'src/user/user.module';
@Module({
//...
imports: [UserModule, TypeOrmModule.forFeature([Permission])],
})
export class PermissionModule {
}
然后在导航守卫中引入,因为获取的是异步的,因此守卫做了一点修改
//permission.guard.ts
import {
CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import {
Reflector } from '@nestjs/core';
import {
UserService } from 'src/user/user.service';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private reflector: Reflector,
private userServicese: UserService,
) {
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
'permissions',
[context.getClass(), context.getHandler()],
) || [];
const permissionNames = await this.userServicese.findPermissionNames();
console.log(requiredPermissions);
console.log(permissionNames);
return true;
}
}
调用user/test
接口即可获得接口权限字段与该用户拥有的权限字段
最后看该角色权限是否包含接口权限即可
//permission.guard.ts
//...
const isContainedPermission = requiredPermissions.every((item) =>
permissionNames.includes(item),
);
if (!isContainedPermission) {
throw new ApiException('权限不足', ApiErrorCode.Forbidden);
}
return true;
完善一下
在auth.guard.ts
我们已经将 jwt 验证的信息放入了 request['user']中了
所以可以在权限守卫中获取到登录用户的信息,然后调用 user 模块中的findPermissionNames
即可获取当前登录人的权限集合,权限守卫修改如下
//permission.guard.ts
import {
CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import {
Reflector } from '@nestjs/core';
import {
UserService } from 'src/user/user.service';
import {
Request } from 'express';
import {
ApiException } from 'src/common/filter/http-exception/api.exception';
import {
ApiErrorCode } from 'src/common/enums/api-error-code.enum';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private reflector: Reflector,
private userServicese: UserService,
) {
}
async canActivate(context: ExecutionContext): Promise<boolean> {
interface CusRequest extends Request {
user?: any;
}
const request: CusRequest = context.switchToHttp().getRequest();
const requiredPermissions =
this.reflector.getAllAndOverride<string[]>('permissions', [
context.getClass(),
context.getHandler(),
]) || [];
if (requiredPermissions.length === 0) return true;
const [, token] = request.headers.authorization?.split(' ') ?? [];
const permissionNames = await this.userServicese.findPermissionNames(
token,
request.user,
);
const isContainedPermission = requiredPermissions.every((item) =>
permissionNames.includes(item),
);
if (!isContainedPermission) {
throw new ApiException('权限不足', ApiErrorCode.Forbidden);
}
return true;
}
}
findPermissionNames
获取到传来的用户信息,将查询的 username 替换即可
//user.service.ts
async findPermissionNames(token: string, userInfo) {
const user = await this.userRepository.findOne({
where: {
username: userInfo.username },
relations: ['roles', 'roles.permissions'],
});
if (user) {
const permissions = user.roles.flatMap((role) => role.permissions);
const permissionNames = permissions.map((item) => item.name);
return [...new Set(permissionNames)];
} else {
return [];
}
}
最后调用登录接口登录只有一个用户
角色的 user1 用户,拿到它的 token 调用user/test
接口
因为角色用户
只有read
权限,而 test 接口需要create
, read
权限,所以被拦截了,我们将接口改成只需要read
权限再试一下
//user.controller.ts
@Post('test')
@Permissions('read')
test(@Body() testParams) {
return this.userService.test(testParams);
}
此时我们发现请求成功了!
到这里基于RBAC权限控制基本完成了,但是还有很多不足之处,比如不需要每次调用接口都查询数据库中用户的权限,我们只需要在登录时查询一次缓存在Redis中即可,这部分后续文章会进行介绍,欢迎点赞收藏加关注~