在后台管理系统中,权限是很关键的,也是很容易出问题的,不少开源软件都容易存在权限提升漏洞,也就是一个普通用户可以通过某种方法把自己提升为管理员,或者高级用户。一旦出现这样的问题,就会产生较大的安全风险。所以在后台管理系统中,如何控制API的权限,如何控制按钮的权限是一个关键的问题。
接下来我们从功能权限的角度说明一下在YZPass中,如何使用
使用说明
使用要点
功能权限通过角色来实现,给不同的角色勾上不同的菜单,就能看到不同的页面了。在按钮权限上YAPass内置了 查看,编辑,完全控制三种级别。
注意:需要给每一个菜单定义一个唯一标识,比如:user 表示用户管理
前端使用
菜单权限
下面是 web/src/routers/modules/common.tsx中关于用户管理的路由定义。每个菜单需要一个唯一的key,以区分不同资源。这个key也是后续按钮权限的接口权限会用到,并需要保持一致的。
json
{
path: "/system/user",
element: <Userpage />,
title: i18n.t("menu.system.user"),
key: "user",
},
按钮权限
自带菜单权限,不需要开发人员额外做什么,按钮权限通过自定义控件 <AuthButton permission="user:edit"> ... </AuthButton>
来控制。比如我们在 web/src/views/system/user/index.tsx中,可以看到以下代码
tsx
<AuthButton permission="user:edit">
<Button type="link" onClick={() => edit(val)}>{t("edit")}</Button>
</AuthButton>
上面一段代码就是按钮权限控制,表示这个按钮需要拥有 用户编辑 权限才能看到。
后端使用
接口权限基于java的注解实现,只要在对应的接口上加上相关注解就可以了。比如我们在 api/src/main/java/com/yzpass/api/user/controller/UserController
中可以找到以下代码:
java
public final String resId = "user";
...
@RequireEdit(resId)
@PostMapping("save")
public Result<UserDTO> save(@RequestBody UserDTO input) {
return userService.save(input);
}
对的,就是 @RequireEdit(resId)
这样的注解。我们提供了以下注解方便权限控制:
- RequireRead 表示读权限
- RequireEdit 表示编辑权限
- RequireFullControl 表示完全控制权限
- RequirePermission 表示通用的权限控制,需要再指定 PermissionLevel
- RequireAllPermission 表示需要满足多个权限才能访问
- RequireAnyPermission 表示只要任一权限即可访问
关于使用的部分,就是这么多了,是不是比较简单?如果你想了解更多的实现细节,可以继续看后面的设计与实现。如果只关心使用读到这里就可以了。
设计与实现
接口设计
我们通过 /api/profile/menu
这个接口获取当前用户拥有的菜单与权限。有些系统实现是把权限放到登录接口里的,我认为那是一个糟糕的设计,因为那样一来如果用户的权限发生变化了还得重新登录,比较麻烦。通过一个独立的接口获取权限就可以不用重新登录,用户刷新一下页面,就可以更新权限。这个接口的返回数据示例如下:
json
{
"code": 0,
"data": {
"menuArr": [
"role",
"user",
...
],
"permissionArr": [
"user:read",
"user:edit",
"role:fullControl",
"role:read",
...
]
}
}
为了方便阅读,隐去了更多细节,从上面的接口我们看到关键是两个数组:
- menuArr, 这里返回了当前用户拥有哪些菜单,这里返回的是菜单的key。
- permissionArr,这里返回了当前用户拥有哪些权限,用来控制按钮的显示。这里的值是菜单的 key + : + 权限。
按钮权限默认做了三个约定: read 表示查看权限,edit表示编辑权限, fullControl 表示完全控制,这个是 系统管理/角色管理 里的编辑界面对应的。
前端实现
AuthButton 封装
我们看代码 web\src\routers\utils\authButton.tsx
tsx
import { store } from "@/redux/index";
/**
* @description 按钮权限
* */
const AuthButton = (props: { permission: string, children: JSX.Element }) => {
const { permission } = props;
const permissions = store.getState().auth.permissions;
if (permissions.indexOf(permission) == -1) return <></>;
// * 当前账号有权限,正常访问按钮
return props.children;
};
export default AuthButton;
代码里其实就是一个简单的判断,从 redux 里获取权限列表,也就是上面的menu接口里返回的permissionArr,判断一下当前需要的权限是否存在。如果有权限就返回按钮进行显示,如果没有权限就返回空的<></>
菜单接口请求与存储
在 web\src\layouts\components\Menu\index.tsx
文件中,有以下一段代码,是请求菜单接口的
tsx
const getMenuData = async () => {
setLoading(true);
try {
let { data } = await getMenuList();
if (!data) return;
let menuArr = mergeMenu(data.menuArr);
// 把按钮权限存储到 redux 中,做按钮权限判断
setPermissions(data.permissionArr);
console.log("menuArr", menuArr);
setMenuList(deepLoopFloat(menuArr));
// 存储处理过后的所有面包屑导航栏到 redux 中
setBreadcrumbList(findAllBreadcrumb(menuArr));
// 把路由菜单处理成一维数组,存储到 redux 中,做菜单权限判断
const dynamicRouter = handleRouter(menuArr);
setAuthRouter(dynamicRouter);
setMenuListAction(data.menuArr);
} catch (error) {
console.log('menu error', error);
} finally {
setLoading(false);
}
};
后端实现
表结构
我们定义了比较常规的 用户表 yu_user ,角色表 yu_role, 用户角色关系表 yu_user_role。 以及角色资源关系表 yu_role_res。
这里特别说明一下 角色资源关系表 yu_role_res,res_id 表示资源标识,对应前端路由里的key, 这个资源就是指菜单,都是在前端定义的。edit 和 full_control 就是表示角色对的当前资源是有 编辑 或者 完成控制的权限。而查看权限是没有单独的字段的,只要有对应的行就表示有查看权限。
注解定义
前文已经交待过相关的注解,下面我们看一个示例 com.yzpass.api.security.annotation.RequireRead
less
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRead {
String value(); // 指定所需权限的资源名称
}
注解本身比较简单,就是定义一个权限value,在controller对应的方法上加上注解就会做权限限制
AOP实现
后端权限的限制是通过注解AOP实现的。主要的逻辑在 com.yzpass.api.security.PermissionAspect ,这里的实现依赖Spring AOP。 下面是其中一个注解的实现
java
@Before("@annotation(com.yzpass.api.security.annotation.RequireRead)")
public void checkRequireRead(JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RequireRead requireRead = method.getAnnotation(RequireRead.class);
String permission = requireRead.value() + ":" + PermissionLevel.READ.getTitle();
checkPermission(permission);
}
上面的代码就是在方法执行前,会执行的代码。找到对应的权限,然后判断是否拥有对应的权限。上面引用的checkPermission方法的实现如下:
java
private void checkPermission(String permission) {
var attr = RequestContextHolder.getRequestAttributes();
if(attr!=null){
var permissions = attr.getAttribute("permissions", RequestAttributes.SCOPE_REQUEST);
if (permissions != null) {
if(permissions instanceof Set set){
if(set.contains(permission)){
return;
}
}
}
}
throw new NoPermissionException("您没有权限访问当前接口,需要权限:" + permission + ",请联系管理员");
}
全局过滤器
我们看到这里是从 上下文获取权限。那么这个上下文的权限是什么 时候注入进去的呢?就在 com.yzpass.api.security.AuthFilter
这个全局过滤器里。关键代码块:
java
private void regPermission(UUID userId,HttpServletRequest request) {
Set<String> set = userPermissionService.getPemissionSet(userId);
var attr = RequestContextHolder.getRequestAttributes();
if(attr!=null){
log.info("permissions: {}",set);
attr.setAttribute("permissions",set, RequestAttributes.SCOPE_REQUEST);
}
}
我们看到这里引用了 userPermissionService.getPemissionSet(userId)
这个方法就是从数据库里读取当前用户拥有的权限。当然为了性能考虑,我们加了缓存的处理。
读取用户权限清单
vbnet
public Set<String> getPemissionSet(UUID userId){
String userRoleKey = userRoleKey(userId);
//获取用户有哪些角色
List<UUID> roleIds = iCache.getAndCache(userRoleKey,this::readRoles,userId);
Set<String> set = new HashSet<>();
//对于每个角色,获取对应的权限
for(UUID uuid:roleIds){
String roleResKey = roleResKey(uuid);
List<ResDTO> resDTOS = iCache.getAndCache(roleResKey,this::readResByRoleId,uuid);
for(var dto: resDTOS){
set.add(dto.getResId() + ":" + PermissionLevel.READ.getTitle());
if(Boolean.TRUE.equals(dto.getEdit())){
set.add(dto.getResId() + ":" + PermissionLevel.EDIT.getTitle());
}
if(Boolean.TRUE.equals(dto.getFullControl())){
set.add(dto.getResId()+ ":" + PermissionLevel.FULLCONTROL.getTitle());
}
}
}
return set;
}
总结
以上就是YZPass-admin-template中,关于接口权限和按钮权限的使用说明,及设计与实现。当然有一些细节依然没有完全表达出来,感兴趣的伙伴可以到源代码中查看完整的实现逻辑。
感谢您的阅读,开源不易,求求各位赏个赞!!~~
项目地址: gitee.com/yzpass/yzpa...