nestjs学习:nestjs实现简单RBAC权限控制
1.什么是RBAC?
RBAC(Role-Based Access Control)是一种访问控制模型,用于管理系统中的用户权限。在RBAC模型中,权限是根据用户的角色分配的,而不是直接分配给个别用户。每个角色都有一组特定的权限,用户被分配到角色后,就会继承该角色的权限。
相对于ACL(Access Control List,访问控制列表),RBAC具有以下优点:
- 简化权限管理:RBAC模型将权限分配给角色,而不是直接分配给用户。这样一来,当用户的角色发生变化时,只需要调整其角色的权限,而不需要逐个修改每个用户的权限。这简化了权限管理的复杂性,特别是在大型组织或系统中。
- 提高安全性:RBAC模型可以确保用户只能访问其所需的权限,而不会被授予不必要的权限。通过限制用户的权限范围,RBAC可以减少潜在的安全漏洞和错误配置的风险。
- 支持角色继承:RBAC模型支持角色之间的继承关系。这意味着一个角色可以继承另一个角色的权限,从而简化了权限的管理和维护。例如,一个高级管理员角色可以继承普通管理员角色的权限,而不需要为高级管理员重新定义相同的权限。
- 提高可扩展性:RBAC模型可以轻松地适应组织结构的变化和系统的扩展。当新的角色需要被添加时,只需要定义新的角色并分配相应的权限即可,而不需要修改现有的权限分配。
在ACL中,可以权限管理可以理解为下图:

ACL通过给每个用户记录其具备的权限实现权限控制。
在RBAC中,可以权限管理可以理解为下图:

RBAC模型将权限分配给角色,再通过给不同的用户分配不同的角色实现权限控制。相比ACL,RBAC对于我们打工仔来说更容易管理,不容易写bug。
举个例子
如果管理员权限有访问A、B、C的权限,在某一天,需要给管理员增加D的权限。在ACL中,存在管理员用户1,2,3,则需要分别给用户1,2,3添加访问D的权限。而在RBAC中,只需要给管理员这一角色添加D的权限。
2.前期准备
2.1使用typeorm建立数据库映射
在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',
port: 3306,
username: 'root',
password: '111111',
database: 'rbac_test',
synchronize: true,
logging: true,
entities: [],
poolSize: 10,
connectorPackage: 'mysql2',
extra: {
authPlugin: 'sha256_password',
},
}),
UserModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
2.2创建user模块
在user模块中分别添加User、Role、Permission的Entity
import {
Column,
CreateDateColumn,
Entity,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Role } from './role.entity';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 50,
})
username: string;
@Column({
length: 50,
})
password: string;
@CreateDateColumn()
createTime: Date;
@UpdateDateColumn()
updateTime: Date;
@ManyToMany(() => Role)
@JoinTable({
name: 'user_role_relation',
})
roles: Role[];
}
import {
Column,
CreateDateColumn,
Entity,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Permission } from './permission.entity';
@Entity()
export class Role {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 20,
})
name: string;
@CreateDateColumn()
createTime: Date;
@UpdateDateColumn()
updateTime: Date;
@ManyToMany(() => Permission)
@JoinTable({
name: 'role_permission_relation',
})
permissions: Permission[];
}
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
@Entity()
export class Permission {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 50
})
name: string;
@Column({
length: 100,
nullable: true
})
desc: string;
@CreateDateColumn()
createTime: Date;
@UpdateDateColumn()
updateTime: Date;
}
使用 *@Entity() *装饰器将一个类标记为实体后,TypeORM 将会自动创建与该类对应的数据库表,并且可以通过该实体类进行数据库操作,如插入、更新、删除和查询等。
在TypeOrm.forRoot 的 entities 数组加入这三个 entity。
TypeOrmModule.forRoot函数会在应用程序的根模块中调用,并传入一个配置对象,该对象包含了数据库连接的相关信息。这样,在应用程序启动时,TypeORM会使用这些配置信息来建立与数据库的连接,并根据需要执行数据库操作。
将nest程序跑起来,将会自动生成对应的数据库,并建立表和表之间的关联关系。
2.3启动nest服务器
npm run start:dev
成功生成所需的表并且关联关系正确
2.4生成种子数据
@InjectEntityManager()
entityManager: EntityManager;
//! 生成种子数据
async initData() {
const user1 = new User();
user1.username = '张三';
user1.password = '111111';
const user2 = new User();
user2.username = '李四';
user2.password = '222222';
const user3 = new User();
user3.username = '王五';
user3.password = '333333';
const role1 = new Role();
role1.name = '管理员';
const role2 = new Role();
role2.name = '普通用户';
const permission1 = new Permission();
permission1.name = '新增 aaa';
const permission2 = new Permission();
permission2.name = '修改 aaa';
const permission3 = new Permission();
permission3.name = '删除 aaa';
const permission4 = new Permission();
permission4.name = '查询 aaa';
const permission5 = new Permission();
permission5.name = '新增 bbb';
const permission6 = new Permission();
permission6.name = '修改 bbb';
const permission7 = new Permission();
permission7.name = '删除 bbb';
const permission8 = new Permission();
permission8.name = '查询 bbb';
role1.permissions = [
permission1,
permission2,
permission3,
permission4,
permission5,
permission6,
permission7,
permission8,
];
role2.permissions = [permission1, permission2, permission3, permission4];
user1.roles = [role1];
user2.roles = [role2];
await this.entityManager.save(Permission, [
permission1,
permission2,
permission3,
permission4,
permission5,
permission6,
permission7,
permission8,
]);
await this.entityManager.save(Role, [role1, role2]);
await this.entityManager.save(User, [user1, user2]);
}
数据库中生成如下数据
3.实现主要登录、校验、权限管理
3.1登录接口实现
- 安装ValidationPipe用到的包
npm install --save class-validator class-transformer
- 创建dto并添加校验装饰器
import { IsNotEmpty, Length } from 'class-validator';
export class UserLoginDto {
@IsNotEmpty()
@Length(1, 50)
username: string;
@IsNotEmpty()
@Length(1, 50)
password: string;
}
- 在
main.ts
中全局启动ValidationPipe
- 使用
@nestjs/jwt
库来生成JWT令牌
首先,
JwtModule.register()
方法被调用,接受一个对象作为参数。这个对象包含了 JWT 模块的配置信息。
global: true
表示将该模块注册为全局模块,使得整个应用程序都可以使用 JWT 功能。
secret: 'zzz'
是用于签名和验证 JWT 的密钥。在实际应用中,应该使用一个更长、更复杂的密钥来增加安全性。
signOptions
是一个对象,用于配置 JWT 的签名选项。
-
expiresIn: '7d'
表示 JWT 的过期时间为 7 天。这意味着生成的 JWT 在 7 天后会自动失效,需要重新登录或获取新的令牌。
- 在
user.service.ts
中实现登录的主要逻辑
需要注入EntityManager
@InjectEntityManager()
entityManager: EntityManager;
async login(loginUser: UserLoginDto) {
const user = await this.entityManager.findOne(User, {
where: {
username: loginUser.username,
},
relations: {
roles: true,
},
});
if (!user) {
throw new HttpException('用户不存在', HttpStatus.ACCEPTED);
}
if (user.password != loginUser.password) {
throw new HttpException('密码错误', HttpStatus.ACCEPTED);
}
return user;
}
- 在
user.controller.ts
中实现调用service方法并将service返回的user信息存入token中
需要提前注入JwtService
@Controller('user')
export class UserController {
@Inject(JwtService) private jwtService: JwtService;
constructor(private readonly userService: UserService) {}
@Get('init')
async initData() {
await this.userService.initData();
return 'done';
}
@Post('login')
async login(@Body() loginUser: UserLoginDto) {
const user = await this.userService.login(loginUser);
const token = this.jwtService.sign({
user: {
username: user.username,
roles: user.roles,
},
});
return {
token,
};
}
}
- 使用postman测试接口
成功返回token,在下次访问时需要带上token才能访问某些做出限制的资源
3.2权限控制
- 添加 aaa、bbb 两个模块,分别生成 CRUD 方法
nest g resource aaa
nest g resource bbb
此时的接口没有做限制,可以直接访问。
假设这些接口需要进行权限验证才能访问。管理员角色具有 aaa 和 bbb 的增删改查权限,而普通用户只有 bbb 的增删改查权限。因此,需要对接口调用进行限制。因此,要实现以下两个目标:
- 只有在登录状态下才能访问。
- 只有在通过权限验证后才能访问。
3.2.1登录状态
使用guard来检查登录状态
- 新建一个
login.guard.ts
nest g guard login
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { Role } from 'src/user/entities/role.entity';
declare module 'express' {
interface Request {
user: {
username: string;
roles: Role[];
};
}
}
@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);
request.user = data.user;
return true;
} catch (e) {
throw new UnauthorizedException('token失效,请重新登录');
}
}
}
在guard中将请求头的authorization字段取出,在HTTP请求中,Authorization头字段用于向服务器提供身份验证凭据。它与JWT(JSON Web Token)之间存在一定的关系。
JWT是一种用于在网络应用之间安全传输信息的开放标准(RFC 7519)。它使用JSON对象作为令牌,用于在客户端和服务器之间传递声明。JWT通常用于身份验证和授权,以及在分布式系统中传递声明性信息。当使用JWT进行身份验证时,通常将JWT作为身份验证凭据放置在Authorization头字段中。这是通过在Authorization
头字段的值中添加"Bearer
"关键字和JWT令牌
来完成的。例如:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
在服务器端,当收到带有Authorization头字段的请求时,它会提取JWT令牌并进行验证,即代码中的this.jwtService.verify(token)
。验证过程包括检查令牌的签名、有效期和其他声明。如果验证成功,服务器将允许请求继续进行,并使用JWT中的声明来进行授权和身份验证。
总结来说,Authorization头字段用于在HTTP请求中传递身份验证凭据,而JWT是一种常用的身份验证凭据格式。将JWT放置在Authorization头字段中是一种常见的做法,以便在服务器端进行验证和授权。
这里采用providers
数组而不采用@UseGuards()
来注入Guard
当Guard被绑定到APP_GUARD
令牌时,它将自动应用于整个应用程序的所有路由处理程序。这意味着每个请求都会经过该Guard进行身份验证和授权检查,以确保只有经过验证的用户可以访问受保护的路由。这就存在一个问题:即使我们使用login接口登录时,仍然会触发该guard。
在 AppModule
添加 token 为 APP_XXX
的 provider
的可以用来声明全局 Guard、Pipe、Intercepter。
解决所有路由都会返回用户未登录问题
- 添加
custom-decorator.ts
来放分配权限的装饰器
import { SetMetadata } from '@nestjs/common';
export const RequireLogin = () => SetMetadata('require-login', true);
分别在AaaController和BbbController中使用该装饰器
- 修改LoginGuard来判断是否需要登录
getAllAndOverride
是 reflector
对象的一个方法,它接受两个参数:装饰器的名称和一个数组。该方法的作用是获取指定装饰器的参数,并且如果在给定的数组中找到多个装饰器,则返回最后一个装饰器的参数。在这段代码中,装饰器的名称是 'require-login' ,而数组包含了两个元素:context.getClass()
和 context.getHandler()
。这两个方法是获取当前上下文的类和处理程序的方法。
此时,可以正常登录,因为登录接口并没有设置metadata
,通过反射取到的require-login为undefined。
在访问aaa的请求中携带token后也可以正常访问
目前的实现是,只要携带了token就能正常访问,不会考虑用户的权限,还需要做用户的权限校验。
3.2.2权限控制
- 新建一个
PermmissionGuard
来做权限控制
nest g guard permission
- 全局注入
PermmissionGuard
将PermissionGuard
的注入写在LoginGuard
后面,LoginGuard
会先执行
在LoginGuard
中,如果用户处于登录状态,会解析token
拿到user
信息,并将user
挂载在request
上,传递给PermissionGuard
token
中的user信息在登录的时候写入。
- 根据用户的角色来查询该登录用户所具有的权限,初步实现
PermmissionGuard
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { UserService } from 'src/user/user.service';
import { Request } from 'express';
import { Permission } from 'src/user/entities/permission.entity';
@Injectable()
export class PermissionGuard implements CanActivate {
@Inject(UserService) private userService: UserService;
async canActivate(context: ExecutionContext): Promise<boolean> {
const request: Request = context.switchToHttp().getRequest();
if (!request.user) {
return true;
}
//! 可能一个用户是多个角色
const roles = await this.userService.findRoleByIds(
request.user.roles.map((item) => item.id),
);
//! 可能存在roles[0].permissions 和roles[1].permissions
const permissions: Permission[] = roles.reduce((total, current) => {
total.push(...current.permissions);
return total;
}, []);
return true;
}
}
以上代码主要实现两点功能:
- 通过调用
userService.findRoleByIds
来获取用户对应角色具备的权限。 - 处理可能存在roles[0].permissions 和roles[1].permissions,即一个用户饰演多个角色。
此时携带token访问aaa接口,数据库返回如下权限结果
此时数据库权限的名称并没有和如下代码中接口的名称对应
- 建立对应关系--在刚刚定义的
custom-decorator.ts
中新增RequirePermission
装饰器
import { SetMetadata } from '@nestjs/common';
export const RequireLogin = () => SetMetadata('require-login', true);
export const RequirePermission = (...permissions: string[]) =>
SetMetadata('require-permission', permissions);
当使用RequirePermission
装饰器修饰一个控制器或处理程序方法时,它会将一个元数据键值对'require-permission'
和权限数组``permissions
作为值添加到该方法或控制器的元数据中。
在AaaController中使用,传入修饰器函数的值需要和数据库中的值一致。
- 修改
PermissionGuard
,实现权限控制
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { UserService } from 'src/user/user.service';
import { Request } from 'express';
import { Permission } from 'src/user/entities/permission.entity';
import { Reflector } from '@nestjs/core';
@Injectable()
export class PermissionGuard implements CanActivate {
@Inject(UserService) private userService: UserService;
@Inject(Reflector) private reflector: Reflector;
async canActivate(context: ExecutionContext): Promise<boolean> {
const request: Request = context.switchToHttp().getRequest();
if (!request.user) {
return true;
}
//! 可能一个用户是多个角色
const roles = await this.userService.findRoleByIds(
request.user.roles.map((item) => item.id),
);
//! 可能存在roles[0].permissions 和roles[1].permissions
const permissions: Permission[] = roles.reduce((total, current) => {
total.push(...current.permissions);
return total;
}, []);
//! 获取当前handler的元数据
const requirePermissions = this.reflector.getAllAndOverride(
'require-permission',
[context.getClass(), context.getHandler()],
);
const isPermit = permissions.some((item) => {
return item.name == requirePermissions;
});
if (isPermit || requirePermissions == undefined) {
return true;
} else {
throw new UnauthorizedException('您没有权限访问该接口');
}
}
}
通过反射拿到当前handler上设置的元数据,再与之前获取到的当前用户的权限列表做对比,如果当前handler对应的requirePermissions
存在于permissions
中,则返回true
,否则抛出UnauthorizedException
。
登录李四账号,李四拥有着aaa接口的crud权限,没有bbb接口的权限。
测试结果和代码结果一致。
至此,整个权限控制已经完成!但是在每次接口请求都会请求数据库,且涉及多个表的联查。此处可以使用redis
进行优化。
4.使用redis做缓存
- 安装redie的相关包
npm install redis
- 新建一个redis模块和服务
nest g module redis
nest g service redis
- 在
RedisModule
中添加redis
的provider
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
import { createClient } from 'redis';
@Global()
@Module({
providers: [
RedisService,
{
provide: 'REDIS_CLENT',
async useFactory() {
const client = createClient({
socket: {
host: '127.0.0.1',
port: 6379,
},
});
await client.connect();
return client;
},
},
],
exports: [RedisService],
})
export class RedisModule {}
useFactory
是一个用于创建提供者的工厂方法。它允许我们在运行时动态地创建和配置提供者
useFactory
适用于以下场景:
- 动态配置:当我们需要根据一些条件来动态配置提供者时,可以使用
useFactory
。例如,我们可以根据环境变量的值来创建不同的数据库连接。 - 异步初始化:有时,我们需要在提供者初始化之前执行一些异步操作,例如从远程服务器获取配置。在这种情况下,我们可以使用
useFactory
返回一个Promise
,NestJS
会等待Promise
解析完成后再创建提供者。 - 复杂依赖关系:当提供者的创建需要依赖其他提供者时,我们可以使用
useFactory
来注入这些依赖项。这样,我们可以在工厂函数中访问其他提供者,并根据需要进行配置。
- 在service中添加redis的查找和存储方法
import { Inject, Injectable } from '@nestjs/common';
import { RedisClientType } from 'redis';
@Injectable()
export class RedisService {
@Inject('REDIS_CLENT') private redisClient: RedisClientType;
async listGet(key: string) {
//!在Redis中,LRANGE是一个用于获取列表(List)的指令 以下代码相当于LRANGE key start stop
return await this.redisClient.lRange(key, 0, -1);
}
async listSet(key: string, list: string[], ttl?: number) {
for (let i = 0; i < list.length; i++) {
await this.redisClient.lPush(key, list[i]);
}
if (ttl) {
await this.redisClient.expire(key, ttl);
}
}
}
在Redis
中,LRANGE
是一个用于获取列表(List)的指令,lRange
代码相当于LRANGE key start stop
;Redis
的LPUSH
命令用于将一个或多个值插入到列表的头部,LPUSH key value [value ...]
。
- 将RedisService注入到PermissionGuard,修改PermissionGuard
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { UserService } from 'src/user/user.service';
import { Request } from 'express';
import { Permission } from 'src/user/entities/permission.entity';
import { Reflector } from '@nestjs/core';
import { RedisService } from 'src/redis/redis.service';
@Injectable()
export class PermissionGuard implements CanActivate {
@Inject(UserService) private userService: UserService;
@Inject(Reflector) private reflector: Reflector;
//! 注入redisService
@Inject(RedisService) private redisService: RedisService;
async canActivate(context: ExecutionContext): Promise<boolean> {
const request: Request = context.switchToHttp().getRequest();
if (!request.user) {
return true;
}
//!得假设用户名不能重复了哈
let permissions = await this.redisService.listGet(
`user_${request.user.username}_permission`,
);
console.log(permissions);
if (permissions.length == 0) {
console.log('请求数据库');
//! 可能一个用户是多个角色
const roles = await this.userService.findRoleByIds(
request.user.roles.map((item) => item.id),
);
let permissionsList: Permission[] = roles.reduce((total, current) => {
total.push(...current.permissions);
return total;
}, []);
permissions = permissionsList.map((item) => item.name);
this.redisService.listSet(
`user_${request.user.username}_permission`,
permissions,
60 * 30,
);
}
//! 获取当前handler的元数据
const requirePermissions = this.reflector.getAllAndOverride(
'require-permission',
[context.getClass(), context.getHandler()],
);
const isPermit = permissions.some((item) => {
return item == requirePermissions;
});
if (isPermit || requirePermissions == undefined) {
return true;
} else {
throw new UnauthorizedException('您没有权限访问该接口');
}
}
}
首先查询是否有该用户的权限存放在redis中,如果没有就查询数据库,并将查找的权限结果存入redis中,并设置过期时间为30分钟。下次再访问的时候直接从redis中取。
- 测试下
使用李四访问他没有权限访问的bbb
使用李四访问他有权限访问的aaa
34行的console.log('请求数据库');
没有打印,说明确实从redis中取了数据。
RedisInsight中可以看出多了李四的数据。
总结
转载自:https://juejin.cn/post/7266463919141060623