本系列教程将教你使用 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 语句后的结果为:
可以看到 role_names 字段的值为 sAdmin,test,这表示当前用户拥有两个角色,分别是 sAdmin 与 test。
提示:需要将 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,
},
},
},
},
},
},
},
});
}
只是这样查询出来的数据结构存在多级嵌套的情况,需要我们额外处理。结构如下图:
对于这种复杂查询还是使用原生 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() 是我们之前封装的一个装饰器,是用来:
- 定义 API 端点的成功响应;
- 为 Swagger 文档提供该接口响应数据的类型说明。
@Get() 装饰器用来声明一个 GET 类型的接口,这里还设置了接口地址为 userinfo。
@Req() 装饰器用来获取请求头信息,因为之前通过 passport 的策略将解码 token 的信息挂载到了 req 对象上,所以这里可以直接取出。
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.ts 在 providers 中添加以下代码:
// import { AuthorityGuard } from './common/guard/authority.guard';
{
provide: APP_GUARD,
useClass: AuthorityGuard,
},
现在我们就可以在 controller 中的方法上使用这个守卫控制权限了。
如图,如果用户没有 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 中。
至此权限相关功能就开发完成啦,下一章节见~