接口权限与按钮权限的设计与实现-YZPass中的安全控制

在后台管理系统中,权限是很关键的,也是很容易出问题的,不少开源软件都容易存在权限提升漏洞,也就是一个普通用户可以通过某种方法把自己提升为管理员,或者高级用户。一旦出现这样的问题,就会产生较大的安全风险。所以在后台管理系统中,如何控制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) 这样的注解。我们提供了以下注解方便权限控制:

  1. RequireRead 表示读权限
  2. RequireEdit 表示编辑权限
  3. RequireFullControl 表示完全控制权限
  4. RequirePermission 表示通用的权限控制,需要再指定 PermissionLevel
  5. RequireAllPermission 表示需要满足多个权限才能访问
  6. RequireAnyPermission 表示只要任一权限即可访问

关于使用的部分,就是这么多了,是不是比较简单?如果你想了解更多的实现细节,可以继续看后面的设计与实现。如果只关心使用读到这里就可以了。

设计与实现

接口设计

我们通过 /api/profile/menu 这个接口获取当前用户拥有的菜单与权限。有些系统实现是把权限放到登录接口里的,我认为那是一个糟糕的设计,因为那样一来如果用户的权限发生变化了还得重新登录,比较麻烦。通过一个独立的接口获取权限就可以不用重新登录,用户刷新一下页面,就可以更新权限。这个接口的返回数据示例如下:

json 复制代码
{
    "code": 0,
    "data": {
        "menuArr": [           
            "role",
            "user",
            ...
        ],
        "permissionArr": [           
            "user:read",
            "user:edit",
            "role:fullControl",
            "role:read",
            ...
        ]
    }
}

为了方便阅读,隐去了更多细节,从上面的接口我们看到关键是两个数组:

  1. menuArr, 这里返回了当前用户拥有哪些菜单,这里返回的是菜单的key。
  2. 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...

相关推荐
佚名涙36 分钟前
go中锁的入门到进阶使用
开发语言·后端·golang
qq. 28040339845 小时前
CSS层叠顺序
前端·css
喝拿铁写前端6 小时前
SmartField AI:让每个字段都找到归属!
前端·算法
猫猫不是喵喵.6 小时前
vue 路由
前端·javascript·vue.js
草捏子6 小时前
从CPU原理看:为什么你的代码会让CPU"原地爆炸"?
后端·cpu
嘟嘟MD6 小时前
程序员副业 | 2025年3月复盘
后端·创业
烛阴6 小时前
JavaScript Import/Export:告别混乱,拥抱模块化!
前端·javascript
bin91536 小时前
DeepSeek 助力 Vue3 开发:打造丝滑的表格(Table)之添加行拖拽排序功能示例12,TableView16_12 拖拽动画示例
前端·javascript·vue.js·ecmascript·deepseek
胡图蛋.6 小时前
Spring Boot 支持哪些日志框架?推荐和默认的日志框架是哪个?
java·spring boot·后端
无责任此方_修行中6 小时前
关于 Node.js 原生支持 TypeScript 的总结
后端·typescript·node.js