循序渐进学spring security 第十一篇 如何动态权限控制URL?如何动态给用户添加权限?

回顾

前面我们介绍了spring security 可以对于登录用户具有的权限进行授权哪些资源可以访问,哪些资源不可以访问,如果不了解如何配置资源访问权限的可以回去看之前的文章《循序渐进学spring security 第七篇,如何基于用户表和权限表配置权限?越学越简单了》,不知道大家是否有发现,我们之前介绍的都是将接口直接配置在配置文件上的,这对于很多实际业务场景还是不能够满足的,比如在一些企业,会有一个后台管理,企业管理员可以在后台配置前台页面的菜单,给哪些菜单授权,哪些用户身份具备权限的,就能访问到这些菜单,否则将访问不到。如果每次管理员在配置前台菜单权限时,都需要去改代码配置,这无疑是不现实的,那么,有没有办法动态的去实现配置呢?当然是有的,这就是本文的主题

在阅读本文之前,请移步阅读我之前的文章,有助于对本文的理解

  1. 面试不要在说不熟悉spring security了,一个demo让你使劲忽悠面试官
  2. 循序渐进学习spring security 第二篇,如何修改默认用户?
  3. 循序渐进学习spring security 第三篇,如何自定义登录页面?登录回调?
  4. 循序渐进雪spring security 第四篇,登录流程是怎样的?登录用户信息保存在哪里?
  5. 循序渐进学习spring security 第五篇,如何处理重定向和服务器跳转?登录如何返回JSON串?
  6. 循序渐进学spring security第六篇,手把手教你如何从数据库读取用户进行登录验证,mybatis集成
  7. 循序渐进学spring security 第七篇,如何基于用户表和权限表配置权限?越学越简单了
  8. 循序渐进学spring security 第八篇,如何配置密码加密?是否支持多种加密方案?
  9. 循序渐进学spring security 第九篇,支持多种加密方案源码解读和示例代码
    10.循序渐进学spring security 第十篇 如何用token登录?JWT闪亮登场

为什么要动态权限?

一句话:为了灵活的满足企业的业务需要
举个例子

大部分企业都是小变大的过程中,开始,公司只有10个人,人事部只有小张一人,小张负责财务,招聘,前台等所有角色,小张具有财务,招聘,前台所有的操作或者查看权限

后来,公司逐渐变大,人事部也增加了2个人,小李和小王,小张升值人事部的领导,而小李主要负责财务,小王负责招聘和前台,小张是领导,可以具备招聘,财务,前台等所有的权限,可以随时查看或者操作小李的工作事项,今天哪些人旷工,哪些人请假了,哪些人出差提了报销,报销金额多少等业务;也可以查看或者操作小王招聘和前台的业务;而小李,因为是负责财务,因此可以只能查看和操作财务业务,而无法越权查看或者操作小王的业务。

公司在继续扩大上市了,人事部现在有1000个人了,还有有多个分公司,可能每天都有人员入职离职,都需要做权限修改,如果按照我们之前介绍的,在配置文件中将URL的权限配置死了,每次都要重启之后才能生效,这无疑是要让人崩溃的

如何更好的管理权限?

数据库设计

要想管理好权限,首先必须将数据库设计好,这样在进行权限管理时,就会非常的简单了

而数据库如何设计好?最重要的一点,就是隔离,也就是说,一张表,只代表一个业务,不要掺杂其他的业务

首先,要有

用户表定义

CREATE TABLE `h_user` (
                          `id` int NOT NULL AUTO_INCREMENT,
                          `username` varchar(50) NOT NULL COMMENT '用户名',
                          `password` varchar(500) NOT NULL COMMENT '密码',
                          `enabled` tinyint(1) NOT NULL COMMENT '是否启动,0-不启用,1-启用',
                          PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT '用户表';

角色表定义

CREATE TABLE `h_role` (
                                 `id` int NOT NULL AUTO_INCREMENT,
                                 `name` varchar(50) NOT NULL COMMENT '角色名称',
                                 `code` varchar(50) NOT NULL COMMENT '角色编码',
                                 PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT '角色表';

角色其实就是权限

菜单表

CREATE TABLE `h_menu` (
                          `id` int NOT NULL AUTO_INCREMENT,
                          `name` varchar(50) NOT NULL COMMENT '菜单名称',
                          `url` varchar(200) NOT NULL COMMENT '菜单URL',
                          `parent_id` int NOT NULL default 0 COMMENT '上级菜单id',
                          PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT '菜单表';

菜单表定义了哪些菜单需要交给权限来管理,URL就是交给权限管理接口

角色人员表

CREATE TABLE `h_role_user` (
                               `role_id` int NOT NULL COMMENT '角色id',
                               `user_id` int NOT NULL COMMENT '菜单id'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT '角色用户表';

角色菜单表

CREATE TABLE `h_role_menu` (
                               `role_id` int NOT NULL COMMENT '角色id',
                               `menu_id` int NOT NULL COMMENT '菜单id'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT '角色菜单表';

为什么人员、角色、菜单表的关系是分开的?为什么不把菜单表合并到角色表中?角色表中也可以保存个用户id,这样不更好吗?
其实,如果这样做的话,才是真的不好,如果一个用户之前是负责财务的,现在他调到其他部门去了,现在负责会计和结算,那就得把这个用户的角色删除了,然后再新建两条角色的记录,重新填写角色名称,角色编码,菜单URL,选择人等操作

但是如果是这种设计会是怎么样呢?
角色和菜单的关系基本不会变动,配置好了,基本上就不会变。而变动的主要是人,今天可能新入职1000个员工,也可能有500个员工离职,但我们只需要维护角色用户表就可以,是不是更轻松了?

好了,给这些表添加一些初始数据

INSERT INTO `h_user` (`username`, `password`, `enabled`) VALUES ('harry', '{bcrypt}$2a$10$gQv1oUFK/LvbV7p4Nk0xE.Gn8H1lYV1hqVJfReWSUYUQBfCkGq2uy', '1');
INSERT INTO `h_user` (`username`, `password`, `enabled`) VALUES ('mike', '{bcrypt}$2a$10$gQv1oUFK/LvbV7p4Nk0xE.Gn8H1lYV1hqVJfReWSUYUQBfCkGq2uy', '1');


INSERT INTO `h_role` (`name`, `code`) VALUES ('超级管理员', 'admin'),('用户管理员','user');

INSERT INTO `h_menu` (`name`, `url`) VALUES ('后台管理', '/admin'),('用户管理','/user/**');

INSERT INTO `h_role_menu` (`role_id`, `menu_id`) VALUES (1, 1),(1,2),(2,2);

INSERT INTO `h_role_user` (`role_id`, `user_id`) VALUES (1, 1),(2,2);
  • 初始化了两个用户,harry和mike,id分别是1,2
  • 初始化了两个角色,超级管理员和用户管理员,这里的code实际上就是权限
  • 初始化了两个菜单,分别是后台管理和用户管理
  • 初始化了三条角色菜单记录,角色:超级管理员具备菜单“后台管理和用户管理”的权限,用户管理员只有用户管理的权限
  • 初始化了两条角色用户记录,用户:harry有超级管理员的角色,用户mike:有户管理员的角色,因此,用户harry,可以访问后台管理和用户管理两个菜单,而mike,只能访问用户管理这个菜单,或者说接口

好了,说完数据库设计,接下里介绍如何实现

如何实现动态权限管理?

动态获取url权限配置

创建类MenuFilterInvocationSecurityMetadataSource 实现FilterInvocationSecurityMetadataSource 接口的Collection getAttributes(Object object)方法,这个实现类主要是读取数据库中菜单的权限,动态加载权限

权限判断

创建类:MenuAccessDecisionManager 实现AccessDecisionManager接口的decide(Authentication authentication, Object object, Collection collection) 方法,在这里可以进行菜单或者说接口的权限校验

好了,接下来我们开始搭建项目,见证奇迹

搭建项目

创建项目:security-mybatis-jwt-perm

本项目是基于上一篇《循序渐进学spring security 第十篇 如何用token登录?JWT闪亮登场 》的项目进行改造的

添加maven依赖:

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-compress</artifactId>
            <version>1.18</version>
            <scope>test</scope>
        </dependency>

这个依赖主要是用用了里面工具包,比如集合和字符串的工具包

修改pom项目名和artifactId 都统一为:security-mybatis-jwt-perm

新建数据库操作相关类和mapper

创建实体类

  • 创建Menu类
@Data
public class Menu {
    //菜单id
    private int id;
    //菜单名称
    private String name;
    //菜单URL
    private String url;
    //上级菜单id
    private int parentId;
}
  • 创建Role类,其实就是将原来的Authorities类修改为Role类,然后就该了几个字段
public class Role implements GrantedAuthority {
    private int id;

    private String name;
    //权限编码
    private String code;

    @Override
    public String getAuthority() {
        return "ROLE_"+code;
    }
}
  • 修改User类
@Data
public class User implements UserDetails {
    private int id;
    private String password;
    private String username;
    private boolean accountNonExpired=true;
    private boolean accountNonLocked=true;
    private boolean credentialsNonExpired=true;
    private boolean enabled;

    private List<Role>authorities;
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }
    @Override
    public String getPassword() {
        return password;
    }
    @Override
    public String getUsername() {
        return username;
    }
    @Override
    public boolean isAccountNonExpired() {
        return accountNonExpired;
    }
    @Override
    public boolean isAccountNonLocked() {
        return accountNonLocked;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return credentialsNonExpired;
    }
    @Override
    public boolean isEnabled() {
        return enabled;
    }
}

添加mybatis 的mapper.xml

  • 添加RoleMapper.xml
<mapper namespace="com.harry.security.mapper.RoleMapper">

    <select id="findRolesByUserId" resultType="com.harry.security.entity.Role" parameterType="integer">
        select role.id,role.`name`,role.`code` from h_role role
        LEFT JOIN h_role_user ru on ru.role_id=role.id
        WHERE ru.user_id=#{userId}
    </select>

    <select id="findRolesByUrl" resultType="com.harry.security.entity.Role" parameterType="string">
        select role.id,role.`name`,role.`code` from h_role role
        LEFT JOIN h_role_menu rm on rm.role_id=role.id
        LEFT JOIN h_menu m on m.id=rm.menu_id
        WHERE m.url=#{url};
    </select>
</mapper>

这里,提供了两个查询,

  1. findRolesByUserId 是根据用户id查询用户具有的角色,
  2. findRolesByUrl 是根据菜单URL,查询菜单是属于哪些角色管理的
  • 添加MenuMapper.xml
<mapper namespace="com.harry.security.mapper.MenuMapper">

    <select id="findMenusByRoleId" resultType="com.harry.security.entity.Menu" parameterType="integer">
        SELECT m.id,m.`name`,m.parent_id,m.url from h_menu m
        LEFT JOIN h_role_menu rm
        on rm.menu_id=m.id
        WHERE rm.role_id=#{roleId};
    </select>
    <select id="findAllMenus" resultType="com.harry.security.entity.Menu">
        SELECT m.id,m.`name`,m.parent_id,m.url from h_menu m
    </select>

</mapper>

这里也是提供了两个查询接口,

  1. findMenusByRoleId 方法是根据传入的角色id,查询该角色下的管理的菜单
  2. findAllMenus 方法是查询所有的菜单
  • 修改UserMapper.xml
<mapper namespace="com.harry.security.mapper.UserMapper">

    <resultMap id="BaseUser" type="com.harry.security.entity.User" >
        <id property="id" column="id" ></id>
        <result property="username" column="username" ></result>
        <result property="password" column="password" ></result>
        <result property="enabled" column="enabled" ></result>
    </resultMap>
    <select id="findUserByUsername" resultMap="BaseUser" parameterType="string">
        select u.id,u.username,u.`password`,u.enabled from h_user u where u.username=#{username};
    </select>

</mapper>

用户只有一个根据用户名查询用户信息的接口

添加mapper接口

  • 创建RoleMapper接口
@Mapper//指定这是一个操作数据库的mapper
public interface RoleMapper {
    /**
     * 根据用户id查找角色
     * @param userId
     * @return
     */
    List<Role> findRolesByUserId(int userId);

    /**
     * 根据URL查找角色
     * @param url
     * @return
     */
    List<Role> findRolesByUrl(String url);
}
  • 穿创建MenuMapper接口
@Mapper//指定这是一个操作数据库的mapper
public interface MenuMapper {

    /**
     * 根据角色id查找菜单
     * @param roleId
     * @return
     */
    List<Menu> findMenusByRoleId(int roleId);

    /**
     * 查询所有配置菜单
     * @return
     */
    List<Menu> findAllMenus();
}
  • UserMapper接口原来就有,不需要改

动态权限配置

动态获取url权限配置

创建类MenuFilterInvocationSecurityMetadataSource,实现接口FilterInvocationSecurityMetadataSource

public class MenuFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired
    private MenuMapper menuMapper;
    @Autowired
    private RoleMapper roleMapper;
    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        Set<ConfigAttribute> set = new HashSet<>();
        // 获取请求地址
        String requestUrl = ((FilterInvocation) object).getRequestUrl();
        log.info("requestUrl >> {}", requestUrl);
        List<Menu> allMenus = menuMapper.findAllMenus();
        if (!CollectionUtils.isEmpty(allMenus)) {
            List<String> urlList = allMenus.stream().filter(f->f.getUrl().endsWith("**")?requestUrl.startsWith(f.getUrl().substring(0,f.getUrl().lastIndexOf("/"))):requestUrl.equals(f.getUrl())).map(menu -> menu.getUrl()).collect(Collectors.toList());
            for (String url:urlList){
                List<Role> roles = roleMapper.findRolesByUrl(url); //当前请求需要的权限
                if(!CollectionUtils.isEmpty(roles)){
                    roles.forEach(role -> {
                        SecurityConfig securityConfig = new SecurityConfig(role.getAuthority());
                        set.add(securityConfig);
                    });
                }
            }
        }
        if (ObjectUtils.isEmpty(set)) {
            return SecurityConfig.createList("ROLE_LOGIN");
        }
        return set;
    }
    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }
    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

这里,主要是实现Collection getAttributes(Object object)方法,动态的从数据库加载菜单权限

  • 首先,从数据库中查询出来所有的菜单,然后再过滤找到满足当前请求URL的,匹配方式有完全匹配,或者是菜单配置中已** 结尾的,标识模糊匹配路径,只要满足前面匹配的都需要权限控制
  • 然后在根据过滤后满足的菜单URL,去查询其角色,将需要控制的菜单的角色返回,这样,就完成了对当前访问的请求URL的动态配置了
  • 最后,如果当前请求URL没有配置对应的角色,即set集合是空的,则返回一个默认的角色,这个可以自定义,主要是用来标识当前请求的URL的默认角色,如果不给默认角色的话,默认系统会给一个匿名的用户可以访问所有接口,请求就不能进入到权限判断的实现类进行权限控制,就算不登录,也能访问所有接口,这样就不合理了,这点需要注意
  • 其他两个方法的实现,和我这样写就可以了,不用管

这里需要注意的是,菜单权限是每次都要全量查询数据库,如果数据多的话,可能会影响性能,大家可以在这里改造读取缓存,但是新增修改菜单时,记得更新缓存数据

动态权限判断

创建类MenuAccessDecisionManager 实现接口AccessDecisionManager

public class MenuAccessDecisionManager implements AccessDecisionManager {

    @Autowired
    private RoleMapper roleMapper;
    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
// 当前请求需要的权限
        log.info("collection:{}", collection);
        log.info("principal:{} authorities:{}", authentication.getPrincipal().toString());
        Object principal = authentication.getPrincipal();
        if(principal instanceof String){
            throw new BadCredentialsException("未登录");
        }

        List<Role> roleList=null;
        for (ConfigAttribute configAttribute : collection) {
            // 当前请求需要的权限
            String needRole = configAttribute.getAttribute();
            if ("ROLE_LOGIN".equals(needRole)) {
                return;
            }

            // 当前用户所具有的权限
            if(roleList==null){
                User loginUser= (User) authentication.getPrincipal();
                roleList = roleMapper.findRolesByUserId(loginUser.getId());
            }
            for (GrantedAuthority grantedAuthority : roleList) {
                // 包含其中一个角色即可访问
                if (grantedAuthority.getAuthority().equals(needRole)) {
                    return;
                }
            }
        }
        throw new AccessDeniedException("SimpleGrantedAuthority!!");
    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return true;
    }
}

这里,主要是实现方法:decide(Authentication authentication, Object object, Collection collection)

  • collection 就是当前请求URL的权限集合
  • 首先判断如用户是否已经登录,如果没有登录,默认会是一个匿名的用户,authentication.getPrincipal()得到的是字符串,而我们登录后应该是得到User对象的,这里判断如果没有登录,则抛出异常,告诉前端当前用户没有登录,前端爱干嘛干嘛
  • 其次循环遍历collection ,取出权限
  • 如果是默认权限,则直接通过,不需要控制
  • 从数据库查询出来当前登录用户的权限,这样做的目的是避免当用户权限有变动时,能及时取出来最新的用户权限数据,确保权限控制的及时性
  • 如果是菜单配置权限,则判断当前登录用户是否具有该权限,如果有,则跳过
  • 如果当前登录用户都没有权限,则抛出异常,表示没有该接口的权限,拒绝访问

配置SecurityConfig

 protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                        object.setSecurityMetadataSource(menuFilterInvocationSecurityMetadataSource); //动态获取url权限配置
                        object.setAccessDecisionManager(menuAccessDecisionManager); //权限判断
                        return object;
                    }
                })
                .and().formLogin()
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailureHandler)
        .permitAll()
                .and().exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .and().logout().logoutSuccessHandler(logoutSuccessHandler)
                .and().csrf().disable()
        ;
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

    }

这里主要是配置关联动态权限实现类

这样,我们基本上就完成了所有的配置,其他接口定义还是上一篇《循序渐进学spring security 第十篇 如何用token登录?JWT闪亮登场 》 的相同,变动的部分上述已有说明了,接下来我们就开始测试看效果了

启动测试

启动项目,用mike登录,访问有权限的接口,能正常访问
在这里插入图片描述

访问没有权限的接口,不能正常访问
在这里插入图片描述

此时,我们模拟公司员工岗位调整,在数据库中,添加一条角色用户记录,就是给mike增加超级管理员的权限
INSERT INTO h_role_user (role_id, user_id) VALUES (1, 2);

然后再来访问,访问成功了,这说明我们已经完成了动态权限的控制
在这里插入图片描述
然后将上述添加的角色用户记录删除,删除后就没有权限,然后再用harry用户登录,都和预期的效果一致

其实,大家会发现,我写的文章主要是偏向于前后端分离,关于动态增删改数据,都是手动插入数据到数据库的,毕竟没有前端帮衬,大家可以结合实际应用场景实现,比如用户数据怎么来?可以增加一个注册接口,结合注册页面交互,完成用户数据的添加,菜单,角色等也是一样的,这些都不在本文内容中,请自己结合实际业务需求自己添加了

OK,就这样,我们就已经完成了动态权限的控制,即使是上市公司,每天有成千上万员工加入,成千上万员工离职,我们也只需要维护角色用户表就就可以完成权限的控制了

源码下载

  • 6
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
很高兴你有兴趣Spring SecuritySpring Security是一个强大且广泛使用的身份验证和授权框架,用于保护Java应用程序。下面是一个循序渐进Spring Security的步骤: 1. 了解基本概念:开始习之前,先了解一些基本概念,如认证、授权、角色、权限等。这将为你理解Spring Security的工作原理提供基础。 2. 添加依赖:在你的项目中添加Spring Security的依赖。你可以在Maven或Gradle配置文件中添加相应的依赖项。 3. 配置Spring Security:在你的应用程序中,配置Spring Security以启用各种安全功能。可以通过XML配置文件或Java配置类来完成配置。 4. 用户认证:配置用户认证机制,例如使用数据库存储用户信息,或者LDAP服务器进行认证。你可以选择适合你需求的认证方式。 5. 授权:一旦用户通过认证,就需要定义权限和角色,并将其分配给用户。这样可以限制用户对应用程序中特定功能的访问。 6. 表单登录:配置表单登录机制,以便用户可以通过用户名和密码进行登录。你可以在登录页面上定义自定义字段并进行验证。 7. 安全注解:使用安全注解来保护你的应用程序中的特定方法或URL。通过在方法或控制器上添加注解,你可以指定谁可以访问该方法或URL。 8. CSRF保护:配置跨站请求伪造(CSRF)保护,以防止恶意用户利用用户的身份进行不良操作。 9. 自定义登录:如果需要,可以自定义登录页面和流程。这允许你根据你的需求进行更多的个性化定制。 10. 测试和调试:最后,在你的应用程序中进行测试和调试,确保Spring Security的各项功能都正常工作。 以上是一个循序渐进Spring Security的基本步骤。当然,这只是一个简要的介绍,你可以根据项目需求进行更深入的习和实践。祝你习愉快!如果你有其他问题,我愿意继续帮助你。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值