基于ACL (Access Control List)实现权限控制

发布时间 2023-12-05 11:05:41作者: 奥托

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了