NestJS 项目实战-权限管理系统开发(五)

471 阅读4分钟

本系列教程将教你使用 NestJS 构建一个生产级别的 REST API 风格的权限管理后台服务【代码仓库地址】。

在线预览地址】账号:test,密码:d.12345

本章节内容: 1. 获取用户权限信息接口;2. 接口权限控制。

1. 获取用户权限信息接口

1.1 添加权限实体

在项目根目录下新开一个终端窗口,运行 nest g res permission 命令,选择 REST API 风格与不生成 CRUD 代码。

创建 /src/permission/entities/permission.entity.ts 文件,并添加以下内容:

import { Permission } from '@prisma/client';
import { ApiProperty } from '@nestjs/swagger';

export class PermissionEntity
  implements Omit<Permission, 'deleted' | 'updatedAt' | 'createdAt'>
{
  @ApiProperty({ description: '权限ID' })
  id: number;

  @ApiProperty({ description: '父权限ID', default: null })
  pid: number;

  @ApiProperty({ description: '权限名称' })
  name: string;

  @ApiProperty({ description: '权限路径' })
  path: string;

  @ApiProperty({
    description: '权限类型',
    enum: ['DIRECTORY', 'MENU', 'BUTTON'],
  })
  type: Permission['type'];

  @ApiProperty({ description: '权限标识' })
  permission: string;

  @ApiProperty({ description: '组件路径' })
  component: string;

  @ApiProperty({ description: '图标' })
  icon: string;

  @ApiProperty({ description: '排序' })
  sort: number;

  @ApiProperty({ description: '重定向地址' })
  redirect: string;

  @ApiProperty({ description: '是否隐藏', default: false })
  hidden: boolean;

  @ApiProperty({ description: '是否缓存', default: false })
  cache: boolean;

  @ApiProperty({ description: '是否禁用', default: false })
  disabled: boolean;

  @ApiProperty({
    description: 'vue-router 的 props 属性',
    default: false,
  })
  props: boolean;

  @ApiProperty({ description: '创建时间(UTC)' })
  createdAt: string;

  @ApiProperty({ description: '更新时间(UTC)' })
  updatedAt: string;
}

后续在获取用户权限信息接口的返回实体中要用到。也用来为 Swagger 文档提供接口响应数据的类型说明。

1.2 添加查询用户权限信息方法

首先,创建一个 /src/user/entities/user-permission-info.entity.ts 文件,并添加以下内容:

import type { User, Profile, Permission } from '@prisma/client';

export class UserPermissionInfoEntity {
  user_name: User['userName'];
  nick_name: Profile['nickName'];
  avatar: Profile['avatar'];
  role_names: string;
  id: Permission['id'];
  pid: Permission['pid'];
  name: Permission['name'];
  path: Permission['path'];
  permission: Permission['permission'];
  type: Permission['type'];
  component: Permission['component'];
  cache: Permission['cache'];
  hidden: Permission['hidden'];
  icon: Permission['icon'];
  redirect: Permission['redirect'];
  props: Permission['props'];
  sort: Permission['sort'];
}

将作为后续从数据库中查询出的数据的类型说明。

user.service.ts 中添加一个 findUserPermissionInfo 方法:

import { UserPermissionInfoEntity } from './entities/user-permission-info.entity';

...

async findUserPermissionInfo(id: string) {
    return await this.prismaService.$queryRaw<UserPermissionInfoEntity>`
      WITH filtered_users AS (
        SELECT u.id, u.user_name, p.avatar, p.nick_name
        FROM users u
            LEFT JOIN profiles p ON u.id = p.user_id
        WHERE u.id = ${id} and u.deleted = false and u.disabled = false
      ),
      user_roles AS (
        SELECT ur.user_id, 
        STRING_AGG(r.name, ','ORDER BY r.name) AS role_names, 
        ARRAY_AGG(r.id) AS role_ids
        FROM role_in_user ur
        JOIN roles r ON ur.role_id = r.id AND r.disabled = false
        WHERE ur.user_id IN (SELECT id FROM filtered_users)
        GROUP BY ur.user_id
      ),
      role_permissions AS (
        SELECT DISTINCT
            ur.user_id, p.pid, p.id, p.name, p.path, p.permission, p.type, p.icon, 
            p.component, p.redirect, p.hidden, p.sort, p.cache, p.props
        FROM user_roles ur
          JOIN role_in_permission rp ON rp.role_id = ANY (ur.role_ids)
          JOIN permissions p ON rp.permission_id = p.id AND p.disabled = false
      )
      SELECT fu.user_name, fu.nick_name, fu.avatar, ur.role_names, rp.pid,
      rp.id, rp.name, rp.path, rp.permission, rp.type, rp.icon, rp.component, rp.redirect, rp.hidden, 
      rp.sort, rp.cache, rp.props
      FROM filtered_users fu
        LEFT JOIN user_roles ur ON fu.id = ur.user_id
        LEFT JOIN role_permissions rp ON fu.id = rp.user_id
      ORDER BY rp.sort DESC;
    `;
  }

Prisma 为我们提供了 $queryRaw 方法,用来执行原生 SQL 语句。

这里的 SQL 中我们使用了 WITH 子句,它是一种临时结果集,可以让复杂查询更容易理解和维护。

  • filtered_users: 过滤出特定用户。

  • user_roles: 获取用户的角色信息。STRING_AGG(r.name, ','ORDER BY r.name) AS role_names 表示将角色名称排序后,使用逗号分隔并连接成一个字符串。

  • role_permissions: 获取角色对应的权限。

vscode 的数据库图形化插件中,运行以上 sql 语句后的结果为:

image.png

可以看到 role_names 字段的值为 sAdmin,test,这表示当前用户拥有两个角色,分别是 sAdmintest

提示:需要将 sql 语句中的 ${id} 替换为具体的id哦,例如: u.id = 'ssssss'。

其实我们也可以不使用 SQL 语句,而是使用 Prisma 给我们提供的对象语法查询,如下:

async findUserPermissionInfo(id: string) {
    return await this.prismaService.user.findUnique({
      where: { id, deleted: false, disabled: false },
      select: {
        userName: true,
        profile: {
          select: {
            nickName: true,
            avatar: true,
          },
        },
        roleInUser: {
          select: {
            roles: {
              select: {
                name: true,
                permissionInRole: {
                  select: {
                    permissions: true,
                  },
                },
              },
            },
          },
        },
      },
    });
  }

只是这样查询出来的数据结构存在多级嵌套的情况,需要我们额外处理。结构如下图:

image.png

对于这种复杂查询还是使用原生 SQL 语句更方便一些。

1.3 添加接口返回实体

首先,创建一个 /src/auth/entities/userinfo.entity.ts 文件,并添加以下内容:

import { ApiProperty, OmitType } from '@nestjs/swagger';
import { PermissionEntity } from 'src/permission/entities/permission.entity';

class MenuEntity extends OmitType(PermissionEntity, [
  'createdAt',
  'updatedAt',
  'disabled',
  'permission',
  'sort',
  'type',
]) {
  @ApiProperty({
    description: '子菜单',
    type: () => [MenuEntity],
    required: false,
    default: [],
  })
  children?: MenuEntity[];
}

export class UserInfoEntity {
  @ApiProperty({ description: '用户名/昵称' })
  name: string;

  @ApiProperty({ description: '用户头像', required: false })
  avatar?: string;

  @ApiProperty({
    description: '用户角色',
    required: false,
  })
  roles?: string[];

  @ApiProperty({
    description: '用户权限',
    required: false,
  })
  permissions?: string[];

  @ApiProperty({
    description: '用户可访问菜单',
    type: () => [MenuEntity],
    required: false,
  })
  menus?: MenuEntity[];
}

将用来为 Swagger 文档提供该接口响应数据的类型说明。

1.4 添加缓存权限信息相关方法

打开 /src/redis/redis.service.ts 文件,添加以下方法:

  generateUserPermissionKey(id: string) {
    return `permission: ${id}`;
  }

  getUserPermission(id: string) {
    const key = this.generateUserPermissionKey(id);
    return this.redis.smembers(key);
  }

  setUserPermission(id: string, permissions: string[]) {
    const key = this.generateUserPermissionKey(id);
    this.redis.sadd(key, permissions);
    this.redis.expire(
      key,
      getBaseConfig(this.configService).jwt.expiresIn,
    );
  }
  
  delUserPermission(id: string) {
    const key = this.generateUserPermissionKey(id);
    this.redis.del(key);
  }

1.5 添加生成菜单方法

新建 /src/common/utils/index.ts 文件并添加以下方法:

export const generateMenus = <T extends { id: number; pid: number | null }>(
  permissions: T[],
) => {
  const permissionMap = new Map<number, T & { children?: T[] }>();
  const menus: (T & { children?: T[] })[] = [];

  permissions.forEach((permission) => {
    permissionMap.set(permission.id, permission);
  });

  permissions.forEach((permission) => {
    if (!permission.pid) {
      menus.push(permission);
      return;
    }

    const parent = permissionMap.get(permission.pid);
    if (!parent) {
      menus.push(permission);
      return;
    }

    if (parent.children) {
      parent.children.push(permission);
      return;
    }

    parent.children = [permission];
  });

  return menus;
};

这里没有使用递归而是使用了 Map 结构来生成菜单树,性能会更好一点。

1.6 接口实现

/src/auth/auth.service.ts 中添加 getUserInfo 方法:

import { generateMenus } from 'src/common/utils';
import { UserInfoEntity } from './entities/userinfo.entity';
...

  async getUserInfo(id: string) {
    const userInfo = await this.userService.findUserPermissionInfo(id);
    if (!userInfo?.length) {
      throw new NotFoundException('用户不存在或账号已被禁用');
    }

    const userInfoItem = userInfo[0];
    const userAuthInfo: UserInfoEntity = {
      name: userInfoItem.nick_name || userInfoItem.user_name,
      avatar: userInfoItem.avatar,
      roles: userInfoItem.role_names?.split(','),
      permissions: [],
      menus: [],
    };

    if (!userAuthInfo.roles?.length) {
      return userAuthInfo;
    }

    const config = getBaseConfig(this.configService);
    if (userInfoItem.user_name === config.defaultAdmin.username) {
      userAuthInfo.permissions = [config.defaultAdmin.permission];
    } else {
      userAuthInfo.permissions = userInfo
        .filter((item) => item.permission)
        .map((item) => item.permission);
    }

    this.redisService.setUserPermission(id, userAuthInfo.permissions);

    const menus = userInfo
      .filter((item) => item.type && item.type !== 'BUTTON')
      .map((item) => {
        return {
          id: item.id,
          pid: item.pid,
          name: item.name,
          path: item.path,
          component: item.component,
          cache: item.cache,
          hidden: item.hidden,
          icon: item.icon,
          redirect: item.redirect,
          props: item.props,
        };
      });
    userAuthInfo.menus = generateMenus(menus);

    return userAuthInfo;
  }

在这个方法中,首先调用了 userService 中的 findUserPermissionInfo 方法获取用户权限信息,然后再对获取到的权限信息进行处理,并将用户的权限信息保存到 redis 中。当登录用户为默认管理员时,直接返回默认的超级权限。

现在打开 /src/auth/auth.controller.ts 文件,在其中新增以下接口:

...
import { UserInfoEntity } from './entities/userinfo.entity';

  @ApiOperation({
    summary: '获取用户权限信息',
  })
  @ApiBearerAuth()
  @ApiBaseResponse(UserInfoEntity)
  @Get('userinfo')
  getUserInfo(@Req() req: { user: IPayload }): Promise<UserInfoEntity> {
    return this.authService.getUserInfo(req.user.userId);
  }

@ApiOperation() 装饰器用来在 Swagger 文档中添加该接口说明。
@ApiBearerAuth() 装饰器用来在 Swagger 文档中标识该接口需要 Bearer Token 认证。
@ApiBaseResponse() 是我们之前封装的一个装饰器,是用来:

  1. 定义 API 端点的成功响应;
  2. 为 Swagger 文档提供该接口响应数据的类型说明。

@Get() 装饰器用来声明一个 GET 类型的接口,这里还设置了接口地址为 userinfo
@Req() 装饰器用来获取请求头信息,因为之前通过 passport 的策略将解码 token 的信息挂载到了 req 对象上,所以这里可以直接取出。

image.png

2. 接口权限控制

本文将使用装饰器与路由守卫来实现接口级的权限控制。

2.1 添加权限装饰器

新建 /src/common/decorator/authority.decorator.ts 文件并添加以下内容:

import { SetMetadata } from '@nestjs/common';

export const AUTHORITY_KEY = 'permissions';
export const Authority = (...permissions: string[]) =>
  SetMetadata(AUTHORITY_KEY, permissions);

这里我们创建了一个 Authority 装饰器方法,它接收字符串类型的参数,然后调用 SetMetadata 方法将传入的数据添加到其所装饰的方法的元数据上。

2.2 添加权限守卫

新建 /src/common/guard/authority.guard.ts 文件并添加以下内容:

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AUTHORITY_KEY } from '../decorator/authority.decorator';
import { RedisService } from 'src/redis/redis.service';
import { ConfigService } from '@nestjs/config';
import { getBaseConfig } from 'src/common/config';

import { IPayload } from '../types';

@Injectable()
export class AuthorityGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly configService: ConfigService,
    private readonly redisService: RedisService,
  ) {}

  async canActivate(context: ExecutionContext) {
    const needPermissions = this.reflector.get<string[]>(
      AUTHORITY_KEY,
      context.getHandler(),
    );
    if (!needPermissions) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const user: IPayload = request.user;

    const { defaultAdmin } = getBaseConfig(this.configService);
    if (user.userName === defaultAdmin.username) {
      return true;
    }

    const permissions = await this.redisService.getUserPermission(user.userId);
    if (
      permissions.some((permission) => needPermissions.includes(permission))
    ) {
      return true;
    }

    return false;
  }
}

这里我们首先获取了方法需要的权限,如果不需要任何权限则直接返回 true。接下来判断了用户是否为默认管理员,如果是,则也返回 true。如果用户不是默认管理员,则从 redis 中取出用户拥有的权限,然后判断是否符合条件。

2.3 全局注册权限守卫

打开 app.module.tsproviders 中添加以下代码:

// import { AuthorityGuard } from './common/guard/authority.guard';

{
      provide: APP_GUARD,
      useClass: AuthorityGuard,
},

现在我们就可以在 controller 中的方法上使用这个守卫控制权限了。

image.png

如图,如果用户没有 user:logout 权限则无法登出。这里仅仅是示例,一般来说登出不需要权限。

3.4 权限守卫优化

目前的权限守卫虽然可以控制权限,但是还存在一点问题。如果 redis 中没有用户的权限信息,那么用户将不能访问任何设置了需要权限的接口。

我们可以添加一个从数据库中查询用户权限信息的方法,当在 redis 中找不到权限信息时,就调用该方法查询。

首先修改 /src/permission/permission.service.ts 的内容为:

import { Injectable } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';

@Injectable()
export class PermissionService {
  constructor(private readonly prismaService: PrismaService) {}

  async findPermission(userId: string) {
    const permissions = await this.prismaService.$queryRaw<
      {
        user_name: string;
        permissions: string[];
      }[]
    >`
      WITH user_permissions AS (
        SELECT u.user_name,
          ARRAY_AGG(DISTINCT pe.permission) FILTER (WHERE pe.permission IS NOT NULL) AS permissions
        FROM users u
          LEFT JOIN role_in_user ur ON u.id = ur.user_id
          LEFT JOIN roles r ON ur.role_id = r.id AND r.deleted = false AND r.disabled = false
          LEFT JOIN role_in_permission rp ON r.id = rp.role_id
          LEFT JOIN permissions pe ON rp.permission_id = pe.id AND pe.deleted = false AND pe.disabled = false
        WHERE u.id = ${userId} AND u.deleted = false AND u.disabled = false
        GROUP BY u.user_name
      )
      SELECT * FROM user_permissions;
    `;

    return permissions[0]?.permissions || [];
  }
}

这里我们同样使用了 SQL 语句查询用户权限,并在 SQL 中对查询结果做了去重。

其次在 /src/permission/permission.module.ts 中导出:

import { Module } from '@nestjs/common';
import { PermissionService } from './permission.service';
import { PermissionController } from './permission.controller';

@Module({
  controllers: [PermissionController],
  providers: [PermissionService],
  exports: [PermissionService], // 导出服务
})
export class PermissionModule {}

最后在权限守卫中添加以下代码:

...
import { PermissionService } from 'src/permission/permission.service';

@Injectable()
export class AuthorityGuard implements CanActivate {
  constructor(
    ...
    private readonly permissionService: PermissionService,
  ) {}

  async canActivate(context: ExecutionContext) {
    ...

    const userPermissions = await this.permissionService.findPermission(
      user.userId,
    );
    if (userPermissions.length) {
      this.redisService.setUserPermission(user.userId, userPermissions);
    }
    if (
      userPermissions.some((permission) => needPermissions.includes(permission))
    ) {
      return true;
    }

    return false;
  }
}

现在如果 redis 中没有保存用户的权限信息,就会去数据库中查询相关用户的权限了,并缓存查询结果到 redis 中。

至此权限相关功能就开发完成啦,下一章节见~