基于ACL (Access Control List)实现权限控制
ACL是直接给用户分配权限:
比如用户1有权限A、B、C,用户二有权限A,用户3有权限A、B。
这种记录每个用户有什么权限的方式,叫做访问控制表 (Access control List);
用户和权限是多对多的关系,存储这种关系需要用户表、角色表、用户-角色的中间表。
我们来实践一下:
在数据库中创建acl_test的数据库:
CREATE DATABASE acl_test DEFAULT CHARACTER SET utf8mb4;
然后再创建一个nest项目:
nest new acl-test -p npm
再新建的项目里安装typeorm:
npm install --save @nestjs/typeorm typeorm mysql2
在appModule中引入TypeOrmModule:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [
TypeOrmModule.forRoot({
type: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "root",
database: "acl_test",
synchronize: true,
logging: true,
entities: [],
poolSize: 10,
connectorPackage: 'mysql2',
extra: {
authPlugin: 'sha256_password',
}
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
然后创建一个名为user的模块:
nest g resource user
添加User和Permission的Entity:
user.entity.ts:
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 50
})
username: string;
@Column({
length: 50
})
password: string;
@CreateDateColumn()
createTime: Date;
@UpdateDateColumn()
updateTime: Date;
}
permission.entity.ts:
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;
}
然后在User里加入和Permission的关系,也就是多对多:
@ManyToMany(() => Permission)
@JoinTable({
name: 'user_permission_relation'
})
permissions: Permission[]
通过@ManyToMany声明和Permission的多对多关系
多对多是需要通过中间表来维护的,通过@JoinTable声明,指定中间表的名字
然后再TypeOrm.forRoot的entities数组中加入这俩entity:
然后把nest服务跑起来试试:
npm run start:dev
可以看到正确生成了三个表:
user_permission_relation表中也生成了userId、permissionId这两个外键。并且中间表的两个外键也都是主表删除或者更新时,从表级联删除或者更新。
然后我们插入一些数据,不用 sql 插入,而是用 TypeORM 的 api 来插入:
修改下 UserService,添加这部分代码:
@InjectEntityManager()
entityManager: EntityManager;
async initData() {
const permission1 = new Permission();
permission1.name = 'create_aaa';
permission1.desc = '新增 aaa';
const permission2 = new Permission();
permission2.name = 'update_aaa';
permission2.desc = '修改 aaa';
const permission3 = new Permission();
permission3.name = 'remove_aaa';
permission3.desc = '删除 aaa';
const permission4 = new Permission();
permission4.name = 'query_aaa';
permission4.desc = '查询 aaa';
const permission5 = new Permission();
permission5.name = 'create_bbb';
permission5.desc = '新增 bbb';
const permission6 = new Permission();
permission6.name = 'update_bbb';
permission6.desc = '修改 bbb';
const permission7 = new Permission();
permission7.name = 'remove_bbb';
permission7.desc = '删除 bbb';
const permission8 = new Permission();
permission8.name = 'query_bbb';
permission8.desc = '查询 bbb';
const user1 = new User();
user1.username = '东东';
user1.password = 'aaaaaa';
user1.permissions = [
permission1, permission2, permission3, permission4
]
const user2 = new User();
user2.username = '光光';
user2.password = 'bbbbbb';
user2.permissions = [
permission5, permission6, permission7, permission8
]
await this.entityManager.save([
permission1,
permission2,
permission3,
permission4,
permission5,
permission6,
permission7,
permission8
])
await this.entityManager.save([
user1,
user2
]);
}
注入EntityManager,实现权限和用户的保存。
aaa增删改查、bbb增删改查、一共8个权限
user1 有 aaa 的 4 个权限,user2 有 bbbb 的 4 个权限。
调用 entityManager.save 来保存。
然后改一下userController:
@Get('init')
async initData() {
await this.userService.initData();
return 'done'
}
然后访问一下/user/init的路由:
permission表中插入了数据:
user表中插入了数据:
user_permission_relation表中也正确的插入了数据:
然后我们来实现登录相关的接口,这次通过session+cookie
的方式:
安装session相关的包:
npm install express-session @types/express-session
在main.ts中使用这个中间件:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as session from 'express-session';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(session({
secret: 'guang',
resave: false,
saveUninitialized: false
}));
await app.listen(3000);
}
bootstrap();
secret 是加密 cookie 的密钥。
resave 是 session 没变的时候要不要重新生成 cookie。
saveUninitialized 是没登录要不要也创建一个 session。
然后在 UserController 添加一个 /user/login 的路由:
@Post('login')
login(@Body() loginUser: LoginUserDto, @Session() session){
console.log(loginUser)
return 'success'
}
然后去创建dto对象:
export class LoginUserDto {
username: string;
password: string;
}
安装参数验证需要的包:
npm install --save class-validator class-transformer
然后给 dto 对象添加 class-validator 的装饰器:
import { IsNotEmpty, Length } from "class-validator";
export class LoginUserDto {
@IsNotEmpty()
@Length(1, 50)
username: string;
@IsNotEmpty()
@Length(1, 50)
password: string;
}
然后全局启用ValidationPipe:
然后在UserService中实现一下login方法:
async login(loginUserDto: LoginUserDto) {
//去数据库里根据用户名查询
const user = await this.entityManager.findOneBy(User, {
username: loginUserDto.username,
});
//如果在数据库中没有查询到数据
if (!user) {
throw new HttpException('用户不存在', HttpStatus.ACCEPTED);
}
//如果查询到了 但是密码不对
if (user.password !== loginUserDto.password) {
throw new HttpException('密码错误', HttpStatus.ACCEPTED);
}
return user;
}
然后改一下UserController的login方法:
@Post('login')
async login(@Body() loginUser: LoginUserDto, @Session() session) {
const user = await this.userService.login(loginUser);
session.user = {
username: user.username,
};
return 'success';
}
测试一下,session已经返回了:
登录成功之后会返回cookie,之后只要带上这个cookie就可以查询到服务端的对应的session,从而取出user信息。
然后添加aaa,bbb两个模块,分别生成CRUD方法:
nest g resource aaa
nest g resource bbb
然后重新启动一下,/aaa和/bbb已经可以访问了。
而实际上这些接口是要被控制权限访问的
用户东东有aaa的增删改查权限,而用户光光拥有bbb的增删改查权限。
所以要对接口的调用做限制。
先添加一个LoginGuard,限制只有登录状态才可以访问这些接口:
nest g guard login --no-spec --flat
然后增加登录状态的检查:
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { Request } from 'express';
//默认session里没有user的类型,所以需要扩展一下 利用同名 interface 会自动合并的特点来扩展 Session。
declare module 'express-session' {
interface Session {
user: { username: string };
}
}
@Injectable()
export class LoginGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request: Request = context.switchToHttp().getRequest();
//从请求中去拿user,如果拿不到则表示未登录
if (!request.session?.user) {
throw new UnauthorizedException('用户未登录');
}
return true;
}
}
然后给aaa和bbb接口都加上这个登录Guard:
再访问一下,就可以抛出未登录的异常:
带上之前登录返回的cookie,就可以正常访问aaa或者bbb的接口了。
光有登录鉴权还不够,我们还需要做当前登录用户的权限控制,所以需要再写一个PermissionGuard:
nest g guard permission --no-spec --flat
因为 PermissionGuard 里需要用到 UserService 来查询数据库,所以把它移动到 UserModule 里。
在PermissionGuard注入一下UserService
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { UserService } from './user.service';
@Injectable()
export class PermissionGuard implements CanActivate {
@Inject(UserService)
private userService: UserService;
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
console.log(this.userService);
return true;
}
}
在UserModule的provides、exports里添加UserService和PermissionGuard:
import { Module, UseGuards } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { PermissionGuard } from './permission.guard';
@Module({
controllers: [UserController],
providers: [UserService, PermissionGuard],
exports: [UserService, PermissionGuard]
})
export class UserModule {}
这样就可以在 PermissionGuard 里注入 UserService 了。
然后再Aaamodule中引入这个UserModule:
然后就可以在/aaa的handler里添加PermissionGuard:
使用apiFox访问一下:
首先重新登录,post方法请求/user/login,然后带上cookie使用get请求一下/aaa接口,打印出来了UserService,说明PermissionGuard里成功注入UserService。
然后来实现权限检查的逻辑,在UserService里添加一个方法:
async findByUsername(username: string) {
const user = await this.entityManager.findOne(User, {
where: {
username,
},
relations: {
permissions: true
}
});
return user;
}
根据用户名查找用户,并且查询出关联的权限来:
在 PermissionGuard 里调用下:
import {
CanActivate,
ExecutionContext,
Injectable,
Inject,
UnauthorizedException,
} from '@nestjs/common';
import { UserService } from './user.service';
import { Request } from 'express';
@Injectable()
export class PermissionGuard implements CanActivate {
@Inject(UserService)
private userService: UserService;
async canActivate(context: ExecutionContext): Promise<boolean> {
const request: Request = context.switchToHttp().getRequest();
const user = request.session.user;
if (!user) {
throw new UnauthorizedException('用户未登录');
}
const foundUser = await this.userService.findByUsername(user.username);
console.log(foundUser);
return true;
}
}
打印下查找到登录用户的信息。
我们是试试看:
先登录,拿到cookie,然后去访问/aaa:
然后我们就根据当前handler需要的权限来判断是否返回true就可以了。
那怎么给当前handler标记需要什么权限呢?
很明显是通过metadata
:
给/aaa接口声明需要query_aaa的权限.
然后再PermissionGuard里通过reflector取出来:
取出 handler 声明的 metadata,如果用户权限里包含需要的权限,就返回 true,否则抛出没有权限的异常。
我们试一下,这次用光光的账号:
可以看到,光光并没有访问/aaa接口的权限:
apifox返回了:
然后登录东东的账号,可以正确访问/aaa:
东东是有query_aaa的权限的。
这样就通过ACL的方式完成了接口权限的控制。
但是每次访问接口,都会触发三个表的关联查询,效率很低,那该如何优化呢?
可以把权限放入redis中,使用redis的缓存来做这种优化:
我们引入下redis:
npm install redis
然后创建一个模块来封装redis操作:
nest g module redis
然后新建一个 service:
nest g service redis --no-spec
然后在 RedisModule 里添加 redis 的 provider:
import { Global, Module } from '@nestjs/common';
import { createClient } from 'redis';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [RedisService,
{
provide: 'REDIS_CLIENT',
async useFactory() {
const client = createClient({
socket: {
host: 'localhost',
port: 6379
}
});
await client.connect();
return client;
}
}
],
exports: [RedisService]
})
export class RedisModule {}
并使用 @Global 把这个模块声明为全局的。
这样,各个模块就都可以注入这个 RedisService 了。
然后在 RedisService 里添加一些 redis 操作方法:
import { Inject, Injectable } from '@nestjs/common';
import { RedisClientType } from 'redis';
@Injectable()
export class RedisService {
@Inject('REDIS_CLIENT')
private redisClient: RedisClientType
async listGet(key: string) {
return await this.redisClient.lRange(key, 0, -1);
}
async listSet(key: string, list: Array<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);
}
}
}
注入 redisClient,封装 listGet 和 listSet 方法,listSet 方法支持传入过期时间。
底层用的命令是 lrange 和 lpush、exprire。
然后在 PermissionGuard 里注入来用下:
先查询redis,没有再去查数据库并保存到redis,有的话直接用redis的缓存结果。
key 为 user_${username}_permissions,这里的 username 是唯一的。
缓存过期时间为 30 分钟。
import { RedisService } from './../redis/redis.service';
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { UserService } from './user.service';
@Injectable()
export class PermissionGuard implements CanActivate {
@Inject(UserService)
private userService: UserService;
@Inject(Reflector)
private reflector: Reflector;
@Inject(RedisService)
private redisService: RedisService;
async canActivate(
context: ExecutionContext,
): Promise<boolean> {
const request: Request = context.switchToHttp().getRequest();
const user = request.session.user;
if(!user) {
throw new UnauthorizedException('用户未登录');
}
let permissions = await this.redisService.listGet(`user_${user.username}_permissions`);
if(permissions.length === 0) {
const foundUser = await this.userService.findByUsername(user.username);
permissions = foundUser.permissions.map(item => item.name);
this.redisService.listSet(`user_${user.username}_permissions`, permissions, 60 * 30)
}
const permission = this.reflector.get('permission', context.getHandler());
if(permissions.some(item => item === permission)) {
return true;
} else {
throw new UnauthorizedException('没有权限访问该接口');
}
}
}
再走一遍登录流程,访问一下/aaa接口,可以看到redis中存入了对应的权限:
之后就会先查redis,不会再访问sql了