这篇文章将以表结构和代码示例介绍在Spring Security中怎么实现菜单-角色的动态分配及动态鉴权。

作者:后端小肥肠


目录

1. 前言

2. 动态鉴权

2.1. 动态鉴权原理

2.2. 动态鉴权实现方式

3. 成果界面展示

4. 表结构关系

5. 核心代码讲解

5.1. 菜单-角色动态分配代码讲解

5.1.1. 将菜单与角色绑定

5.1.2. 根据用户名查询菜单

5.1.3 SysRoleAndPermissionVo实体类编写 

5.2. 动态鉴权代码讲解

6. 结语


1. 前言

          欢迎来到【Spring Security系列】!上一章中介绍了怎么在SpringSecurity中实现用户-角色的动态分配,本文将继续深入探讨如何实现菜单动态分配及动态鉴权。

        菜单动态分配可以根据用户权限动态生成菜单,提高系统实时性、简化权限管理、降低误操作风险、增强系统安全性。

        动态鉴权允许我们在更细粒度的层面上控制用户对资源的访问权限。通过业务逻辑、用户属性等动态因素,我们能够灵活地调整访问策略,提高系统的安全性。

2. 动态鉴权

2.1. 动态鉴权原理

        Spring Security 的动态鉴权原理涉及到一系列的组件和概念。以下是 Spring Security 中实现动态鉴权的一般原理:     

1. AccessDecisionManager(访问决策管理器): 这是 Spring Security 中的一个核心组件,负责最终判断用户是否有权限访问特定资源。AccessDecisionManager 通常包含多个 AccessDecisionVoter,每个 Voter 负责投票判断用户是否具有访问权限。

2. AccessDecisionVoter(访问决策投票器): 这是 AccessDecisionManager 中的子组件,用于投票判断用户是否有权访问资源。Spring Security 提供了多个默认的投票器,如 RoleVoter、AuthenticatedVoter 等。也可以自定义投票器来实现特定的鉴权逻辑。

3. SecurityMetadataSource(安全元数据源): 安全元数据源负责提供资源(URL、方法等)与权限的映射关系。这个映射关系通常在配置中定义,可以是静态的,也可以是动态的。SecurityMetadataSource 在运行时为 AccessDecisionManager 提供了资源与权限的对应关系。

4. 动态权限更新: 在一些场景下,权限可能需要在运行时动态更新。Spring Security 提供了一些机制来处理权限的动态变化,确保系统可以在运行时适应权限的变化。

5. 表达式语言:Spring Security支持使用表达式语言(如SpEL)来定义访问控制规则。这使得开发人员可以在运行时使用动态条件来决定是否允许访问,增加了鉴权规则的灵活性。

2.2. 动态鉴权实现方式

        Spring Security 提供了多种方式来实现动态鉴权,具体的选择取决于项目的需求和复杂性。以下是一些常见的 Spring Security 动态鉴权的实现方式:

1. 基于注解的动态鉴权: 使用 @PreAuthorize@Secured 等注解,结合 SpEL 表达式来定义方法级别的访问控制规则。这种方式允许在代码中直接标注需要鉴权的方法,并使用表达式动态定义权限规则。

@PreAuthorize("hasRole('ADMIN') and hasIpAddress('192.168.1.0/24')")
public void adminMethod() {
    // 方法逻辑
}

2. 基于方法的动态鉴权配置: 在配置类中使用 ExpressionUrlAuthorizationConfigurer 配置动态鉴权规则。通过 access 方法传递表达式,动态定义不同 URL 的权限规则。 本文将采用这种方式实现动态鉴权,以下是一个简单示例。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/admin/**").access("hasRole('ADMIN') and hasIpAddress('192.168.1.0/24')")
                .antMatchers("/user/**").access("hasRole('USER')")
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .and()
            .httpBasic();
    }
}

3. 自定义 AccessDecisionManager: 实现自己的 AccessDecisionManager 接口,定义如何投票决策用户是否有权限访问资源。这种方式允许你完全自定义鉴权逻辑。

public class CustomAccessDecisionManager implements AccessDecisionManager {

    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
            throws AccessDeniedException, InsufficientAuthenticationException {
        // 自定义鉴权逻辑
    }

    // 其他方法的实现
}

 4. 自定义 SecurityMetadataSource: 实现 SecurityMetadataSource 接口,提供资源与权限的映射关系。通过实现这个接口,你可以在运行时动态获取资源与权限的对应关系。

public class CustomSecurityMetadataSource implements SecurityMetadataSource {

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) {
        // 返回资源对应的权限配置
    }

    // 其他方法的实现
}

5.  使用数据库存储权限配置: 将权限配置存储在数据库中,通过自定义实现 FilterInvocationSecurityMetadataSource 从数据库中动态加载资源与权限的对应关系。

public class DatabaseSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        // 从数据库加载资源对应的权限配置
    }

    // 其他方法的实现
}

3. 成果界面展示

        因目前我做的业务只控制到菜单级别,所以只显示菜单控制的相关页面,按钮级别的控制(动态鉴权)也会讲解,但是仅仅有后台表和相关代码,相关界面实现可以参考菜单管理分配及用户管理分配。

管理菜单列表,进行增删改查操作:

Spring Security 如何管理菜单权限 spring security按钮权限_sql

根据不同角色,为其分配不同对应菜单:

Spring Security 如何管理菜单权限 spring security按钮权限_java_02

4. 表结构关系

Spring Security 如何管理菜单权限 spring security按钮权限_数据库_03

        sys_frontend_menu表为菜单表,存储系统中的菜单项,通过在sys_role_frontend_menu增删数据即可将相关角色与菜单进行绑定,进而实现根据角色进行菜单的动态分配。

Spring Security 如何管理菜单权限 spring security按钮权限_spring boot_04

        sys_role_backend_api表中存储的是接口列表,表中存储的是所有api接口信息,可以通过在sys_role_backend_api中增删数据实现角色与菜单绑定,进而实现根据角色进行动态鉴权。

5. 核心代码讲解

5.1. 菜单-角色动态分配代码讲解

5.1.1. 将菜单与角色绑定
@Transactional
    public   boolean saveRoleMenu(String roleId, SysRoleAndPermissionVo... sysRoleAndPermissionVos) {

        System.out.println("roleId = " + roleId);

        //先删除数据
        this.delRoleId(roleId);
        //
        if(sysRoleAndPermissionVos !=null) {
            Set<SysRoleFrontendMenuTable> set = new HashSet<>();
            SysRoleFrontendMenuTable roleFrontendMenu = null;
            for (SysRoleAndPermissionVo roleVo : sysRoleAndPermissionVos) {
                roleFrontendMenu = new SysRoleFrontendMenuTable();
                //存储roleID和FrontendMenuId到多对对的中间表
                roleFrontendMenu.setRoleId(roleVo.getRoleId());
                roleFrontendMenu.setFrontendMenuId(roleVo.getId());
                set.add(roleFrontendMenu);
            }
            System.out.println("set = " + set);
            //再批量保存
            return this.saveBatch(set);
        }
        return  false;
    }
5.1.2. 根据用户名查询菜单

        根据用户名查询菜单有两种实现方式,一种是写成接口由前端来调取,另外一种是重写AuthenticationSuccessHandler,将当前登录用户对应菜单在认证成功后直接返回给前端。如下述代码:

@Component
@Slf4j
public class  SecurAuthenticationSuccessHandler extends JSONAuthentication implements AuthenticationSuccessHandler {

    @Autowired
    ISysUserService service;
    @Autowired
    RedisUtils redisUtils;
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        //取得账号信息
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        //用户鉴权
        SecurityContextHolder.getContext().setAuthentication(authentication);
        String token=redisUtils.get(TOKEN_KEY+userDetails.getUsername())==null?"":redisUtils.get(TOKEN_KEY+userDetails.getUsername()).toString();
        if(token =="") {
            System.out.println("初次登录,token还没有,生成新token。。。。。。");
            //如果token为空,则去创建一个新的token
            jwtTokenUtil = new JwtTokenUtil();
            token = jwtTokenUtil.generateToken(userDetails);
            //把新的token存储到缓存中
            redisUtils.set(TOKEN_KEY+userDetails.getUsername(),token,3600L * 11);


        }
        redisUtils.sSetAndTime(VISIT_USER_KEY,60*60*24,userDetails.getUsername()+ System.currentTimeMillis());
        //加载前端菜单
        Map<String,Object> map = new HashMap<>();
        List<UserMenuVo> menus = null;
        try {
            menus = service.getUserMenus(userDetails.getUsername());
        } catch (Exception e) {
            e.printStackTrace();
            R<Map<String,Object>> data = R.failed("获取用户菜单失败");
            this.WriteJSON(request, response, data);
            return;
        }

        //
        map.put("username",userDetails.getUsername());
        map.put("auth",userDetails.getAuthorities());
        map.put("menus",menus);
        map.put("token",token);
        //装入token
        ResponseStructure data = ResponseStructure.success(map);
        //输出
        this.WriteJSON(request, response, data);

    }
}

         service.getUserMenus(userDetails.getUsername())为根据用户名获取菜单列表方法:

public List<UserMenuVo> getUserMenus(String username) throws Exception {
        List<UserMenuVo> resUserMenus = null;
        if (StringUtils.isEmpty(username)){
            throw new Exception("传入参数不可为空");
        }
            resUserMenus = new ArrayList<>();
            List<UserMenuVo> userMenuVos = baseMapper.getUserMenus(username);
//            log.info("用户关联的菜单数量为:"+userMenuVos.size());
            for (int i = 0; i < userMenuVos.size(); i++) {
                UserMenuVo resUserMenu = new UserMenuVo();
                UserMenuVo userMenuVo = userMenuVos.get(i);
                resUserMenu.setFrontendMenuId(userMenuVo.getFrontendMenuId() == null ? "" : userMenuVo.getFrontendMenuId());
                resUserMenu.setPid(userMenuVo.getPid() == null ? "" : userMenuVo.getPid());
                resUserMenu.setFrontendMenuName(userMenuVo.getFrontendMenuName() == null ? "" : userMenuVo.getFrontendMenuName());
                resUserMenu.setFrontendMenuSort(userMenuVo.getFrontendMenuSort());
                resUserMenu.setFrontendMenuUrl(userMenuVo.getFrontendMenuUrl() == null ? "" : userMenuVo.getFrontendMenuUrl());
                resUserMenus.add(resUserMenu);
            }
            return resUserMenus;

    }

         baseMapper.getUserMenus(username)方法为根据用户名获取菜单列表的Mapper层方法:

@Select("select DISTINCT a.id as frontendMenuId, a.frontend_menu_name as frontendMenuName,\n" +
            "a.pid,a.frontend_menu_url as frontendMenuUrl,a.frontend_menu_sort as frontendMenuSort from sys_frontend_menu a,\n" +
            "sys_role b,sys_frontend_menu_role c,sys_user d,\n" +
            "sys_user_role e where (\n" +
            "a.id=c.front_menu_id and b.id=c.role_id and\n" +
            "d.id=e.user_id and e.role_id=c.role_id and d.username=#{username})ORDER BY\n" +
            "a.frontend_menu_sort asc")
    List<UserMenuVo> getUserMenus(@Param("username") String username);
5.1.3 SysRoleAndPermissionVo实体类编写 
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SysRoleAndPermissionVo {
    String  id;
    String  name;
    String  roleId;
    String  pid;
}

5.2. 动态鉴权代码讲解

动态鉴权组件注入及配置

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Autowired
    DynamicPermission  dynamicPermission;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //放行注册API请求,其它任何请求都必须经过身份验证.
        http.authorizeRequests()
                .antMatchers(HttpMethod.POST,"/user/register").permitAll()
                //动态加载资源
                .anyRequest().access("@dynamicPermission.checkPermisstion(request,authentication)");

    }
  
}

动态鉴权组件编写 

@Component
public class DynamicPermission {

    @Autowired
    SysBackendApiTableService service;


    /**
     * 判断有访问API的权限
     *
     * @param request
     * @param authentication
     * @return
     * @throws MyaccessDeniedException
     */
    public boolean checkPermisstion(HttpServletRequest request,
                                    Authentication authentication) throws MyaccessDeniedException {

        Object principal = authentication.getPrincipal();
        System.out.println("DynamicPermission principal = " + principal);

        if (principal instanceof UserDetails) {

            UserDetails userDetails = (UserDetails) principal;
            //得到当前的账号
            String username = userDetails.getUsername();
            //Collection<? extends GrantedAuthority> roles = userDetails.getAuthorities();

            // System.out.println("DynamicPermission  username = " + username);
            //通过账号获取资源鉴权
            List<SysBackendApiTable> apiUrls = service.getApiUrlByUserName(username);

            AntPathMatcher antPathMatcher = new AntPathMatcher();
            //当前访问路径
            String requestURI = request.getRequestURI();
            //提交类型
            String urlMethod = request.getMethod();

            // System.out.println("DynamicPermission requestURI = " + requestURI);

            //判断当前路径中是否在资源鉴权中
            boolean rs = apiUrls.stream().anyMatch(item -> {
                //判断URL是否匹配
                boolean hashAntPath = antPathMatcher.match(item.getBackendApiUrl(), requestURI);

                //判断请求方式是否和数据库中匹配(数据库存储:GET,POST,PUT,DELETE)
                String dbMethod = item.getBackendApiMethod();

                //处理null,万一数据库存值
                dbMethod = (dbMethod == null) ? "" : dbMethod;
                int hasMethod = dbMethod.indexOf(urlMethod);

                System.out.println("hashAntPath = " + hashAntPath);
                System.out.println("hasMethod = " + hasMethod);
                System.out.println("hashAntPath && hasMethod = " + (hashAntPath && hasMethod != -1));
                //两者都成立,返回真,否则返回假
                return hashAntPath && (hasMethod != -1);
            });
            //返回
            if (rs) {
                return rs;
            } else {
                throw new MyaccessDeniedException("您没有访问该API的权限!");
            }

        } else {
            throw new MyaccessDeniedException("不是UserDetails类型!");
        }
    }
}

        如要实现角色与后台api接口的动态绑定可以参考菜单的相关代码,这里不再赘述。 

6. 结语

        本文讲解了在SpringSecurity中基于表结构和相关核心代码如何实现菜单动态分配及动态鉴权,针对本文的中的技术问题如您有更好的实现或解决方式,欢迎在评论区留言探讨~~