Spring Security动态权限的实现方法详解


Posted in Java/Android onJune 16, 2022

最近在做 TienChin 项目,用的是 RuoYi-Vue 脚手架,在这个脚手架中,访问某个接口需要什么权限,这个是在代码中硬编码的,具体怎么实现的,松哥下篇文章来和大家分析,有的小伙伴可能希望能让这个东西像 vhr 一样,可以在数据库中动态配置,因此这篇文章和小伙伴们简单介绍下 Spring Security 中的动态权限方案,以便于小伙伴们更好的理解 TienChin 项目中的权限方案。

1. 动态管理权限规则

通过代码来配置 URL 拦截规则和请求 URL 所需要的权限,这样就比较死板,如果想要调整访问某一个 URL 所需要的权限,就需要修改代码。

动态管理权限规则就是我们将 URL 拦截规则和访问 URL 所需要的权限都保存在数据库中,这样,在不改变源代码的情况下,只需要修改数据库中的数据,就可以对权限进行调整。

1.1 数据库设计

简单起见,我们这里就不引入权限表了,直接使用角色表,用户和角色关联,角色和资源关联,设计出来的表结构如图 13-9 所示。

Spring Security动态权限的实现方法详解

图13-9  一个简单的权限数据库结构

menu 表是相当于我们的资源表,它里边保存了访问规则,如图 13-10 所示。

Spring Security动态权限的实现方法详解

图13-10  访问规则

role 是角色表,里边定义了系统中的角色,如图 13-11 所示。

Spring Security动态权限的实现方法详解

图13-11  用户角色表

user 是用户表,如图 13-12 所示。

Spring Security动态权限的实现方法详解

图13-12  用户表

user_role 是用户角色关联表,用户具有哪些角色,可以通过该表体现出来,如图 13-13 所示。

Spring Security动态权限的实现方法详解

图13-13  用户角色关联表

menu_role 是资源角色关联表,访问某一个资源,需要哪些角色,可以通过该表体现出来,如图 13-14 所示。

Spring Security动态权限的实现方法详解

图13-14  资源角色关联表

至此,一个简易的权限数据库就设计好了(在本书提供的案例中,有SQL脚本)。

1.2 实战

项目创建

创建 Spring Boot 项目,由于涉及数据库操作,这里选用目前大家使用较多的 MyBatis 框架,所以除了引入 Web、Spring Security 依赖之外,还需要引入 MyBatis 以及 MySQL 依赖。

最终的 pom.xml 文件内容如下:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.1.3</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

项目创建完成后,接下来在 application.properties 中配置数据库连接信息:

spring.datasource.username=root
spring.datasource.password=123
spring.datasource.url=jdbc:mysql:///security13?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai

配置完成后,我们的准备工作就算完成了。

创建实体类

根据前面设计的数据库,我们需要创建三个实体类。

首先来创建角色类 Role:

public class Role {
    private Integer id;
    private String name;
    private String nameZh;
       //省略getter/setter
}

然后创建菜单类 Menu:

public class Menu {
    private Integer id;
    private String pattern;
    private List<Role> roles;
    //省略getter/setter
}

菜单类中包含一个 roles 属性,表示访问该项资源所需要的角色。

最后我们创建 User 类:

public class User implements UserDetails {
    private Integer id;
    private String password;
    private String username;
    private boolean enabled;
    private boolean locked;
    private List<Role> roles;
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream()
                        .map(r -> new SimpleGrantedAuthority(r.getName()))
                        .collect(Collectors.toList());
    }
    @Override
    public String getPassword() {
        return password;
    }
    @Override
    public String getUsername() {
        return username;
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return !locked;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return enabled;
    }
    //省略其他getter/setter
}

由于数据库中有 enabled 和 locked 字段,所以 isEnabled() 和 isAccountNonLocked() 两个方法如实返回,其他几个账户状态方法默认返回 true 即可。在 getAuthorities() 方法中,我们对 roles 属性进行遍历,组装出新的集合对象返回即可。

创建Service

接下来我们创建 UserService 和 MenuService,并提供相应的查询方法。

先来看 UserService:

@Service
public class UserService implements UserDetailsService {
    @Autowired
    UserMapper userMapper;
    @Override
    public UserDetails loadUserByUsername(String username) 
                                             throws UsernameNotFoundException {
        User user = userMapper.loadUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }
        user.setRoles(userMapper.getUserRoleByUid(user.getId()));
        return user;
    }
}

这段代码应该不用多说了,不熟悉的读者可以参考本书 2.4 节。

对应的 UserMapper 如下:

@Mapper
public interface UserMapper {
    List<Role> getUserRoleByUid(Integer uid);
    User loadUserByUsername(String username);
}

UserMapper.xml:

<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.javaboy.base_on_url_dy.mapper.UserMapper">
    <select id="loadUserByUsername" 
                         resultType="org.javaboy.base_on_url_dy.model.User">
        select * from user where username=#{username};
    </select>
    <select id="getUserRoleByUid" 
                         resultType="org.javaboy.base_on_url_dy.model.Role">
        select r.* from role r,user_role ur where ur.uid=#{uid} and ur.rid=r.id
    </select>
</mapper>

再来看 MenuService,该类只需要提供一个方法,就是查询出所有的 Menu 数据,代码如下:

@Service
public class MenuService {
    @Autowired
    MenuMapper menuMapper;
    public List<Menu> getAllMenu() {
        return menuMapper.getAllMenu();
    }
}

MenuMapper:

@Mapper
public interface MenuMapper {
    List<Menu> getAllMenu();
}

MenuMapper.xml:

<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.javaboy.base_on_url_dy.mapper.MenuMapper">
    <resultMap id="MenuResultMap" 
                                type="org.javaboy.base_on_url_dy.model.Menu">
        <id property="id" column="id"/>
        <result property="pattern" column="pattern"></result>
        <collection property="roles" 
                              ofType="org.javaboy.base_on_url_dy.model.Role">
            <id column="rid" property="id"/>
            <result column="rname" property="name"/>
            <result column="rnameZh" property="nameZh"/>
        </collection>
    </resultMap>
    <select id="getAllMenu" resultMap="MenuResultMap">
        select m.*,r.id as rid,r.name as rname,r.nameZh as rnameZh from menu m left join menu_role mr on m.`id`=mr.`mid` left join role r on r.`id`=mr.`rid`
    </select>
</mapper>

需要注意,由于每一个 Menu 对象都包含了一个 Role 集合,所以这个查询是一对多,这里通过 resultMap 来进行查询结果映射。

至此,所有基础工作都完成了,接下来配置 Spring Security。

配置Spring Security

回顾 13.3.6 小节的内容,SecurityMetadataSource 接口负责提供受保护对象所需要的权限。在本案例中,受保护对象所需要的权限保存在数据库中,所以我们可以通过自定义类继承自 FilterInvocationSecurityMetadataSource,并重写 getAttributes 方法来提供受保护对象所需要的权限,代码如下:

@Component
public class CustomSecurityMetadataSource 
                         implements FilterInvocationSecurityMetadataSource {
    @Autowired
    MenuService menuService;
    AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) 
                                               throws IllegalArgumentException {
        String requestURI = 
                   ((FilterInvocation) object).getRequest().getRequestURI();
        List<Menu> allMenu = menuService.getAllMenu();
        for (Menu menu : allMenu) {
            if (antPathMatcher.match(menu.getPattern(), requestURI)) {
                String[] roles = menu.getRoles().stream()
                               .map(r -> r.getName()).toArray(String[]::new);
                return SecurityConfig.createList(roles);
            }
        }
        return null;
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

自定义 CustomSecurityMetadataSource 类并实现 FilterInvocationSecurityMetadataSource 接口,然后重写它里边的三个方法:

  • getAttributes:该方法的参数是受保护对象,在基于 URL 地址的权限控制中,受保护对象就是 FilterInvocation;该方法的返回值则是访问受保护对象所需要的权限。在该方法里边,我们首先从受保护对象 FilterInvocation 中提取出当前请求的 URL 地址,例如 /admin/hello,然后通过 menuService 对象查询出所有的菜单数据(每条数据中都包含访问该条记录所需要的权限),遍历查询出来的菜单数据,如果当前请求的 URL 地址和菜单中某一条记录的 pattern 属性匹配上了(例如 /admin/hello 匹配上 /admin/**),那么我们就可以获取当前请求所需要的权限。从 menu 对象中获取 roles 属性,并将其转为一个数组,然后通过 SecurityConfig.createList 方法创建一个 Collection<ConfigAttribute> 对象并返回。如果当前请求的 URL 地址和数据库中 menu 表的所有项都匹配不上,那么最终返回 null。如果返回 null,那么受保护对象到底能不能访问呢?这就要看 AbstractSecurityInterceptor 对象中的 rejectPublicInvocations 属性了,该属性默认为 false,表示当 getAttributes 方法返回 null 时,允许访问受保护对象(回顾 13.4.4 小节中关于 AbstractSecurityInterceptor#beforeInvocation 的讲解)。
  • getAllConfigAttributes:该方法可以用来返回所有的权限属性,以便在项目启动阶段做校验,如果不需要校验,则直接返回 null 即可。
  • supports:该方法表示当前对象支持处理的受保护对象是 FilterInvocation。

CustomSecurityMetadataSource 类配置完成后,接下来我们要用它来代替默认的 SecurityMetadataSource 对象,具体配置如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    CustomSecurityMetadataSource customSecurityMetadataSource;
    @Autowired
    UserService userService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) 
                                                                throws Exception {
        auth.userDetailsService(userService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ApplicationContext applicationContext = 
                              http.getSharedObject(ApplicationContext.class);
        http.apply(new UrlAuthorizationConfigurer<>(applicationContext))
                .withObjectPostProcessor(new 
                           ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O 
                                                            postProcess(O object) {
           object.setSecurityMetadataSource(customSecurityMetadataSource);
                        return object;
                    }
                });
        http.formLogin()
                .and()
                .csrf().disable();
    }
}

关于用户的配置无需多说,我们重点来看 configure(HttpSecurity) 方法。

由于访问路径规则和所需要的权限之间的映射关系已经保存在数据库中,所以我们就没有必要在 Java 代码中配置映射关系了,同时这里的权限对比也不会用到权限表达式,所以我们通过 UrlAuthorizationConfigurer 来进行配置。

在配置的过程中,通过 withObjectPostProcessor 方法调用 ObjectPostProcessor 对象后置处理器,在对象后置处理器中,将 FilterSecurityInterceptor 中的 SecurityMetadataSource 对象替换为我们自定义的 customSecurityMetadataSource 对象即可。

2. 测试

接下来创建 HelloController,代码如下:

@RestController
public class HelloController {
    @GetMapping("/admin/hello")
    public String admin() {
        return "hello admin";
    }
    @GetMapping("/user/hello")
    public String user() {
        return "hello user";
    }
    @GetMapping("/guest/hello")
    public String guest() {
        return "hello guest";
    }
    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}

最后启动项目进行测试。

首先使用 admin/123 进行登录,该用户具备 ROLE_ADMIN 角色,ROLE_ADMIN 可以访问 /admin/hello/user/hello 以及 /guest/hello 三个接口。

接下来使用 user/123 进行登录,该用户具备 ROLE_USER 角色,ROLE_USER 可以访问 /user/hello 以及 /guest/hello 两个接口。

最后使用 javaboy/123 进行登录,该用户具备 ROLE_GUEST 角色,ROLE_GUEST 可以访问 /guest/hello 接口。

由于 /hello 接口不包含在 URL-权限 映射关系中,所以任何用户都可以访问 /hello 接口,包括匿名用户。如果希望所有的 URL 地址都必须在数据库中配置 URL-权限 映射关系后才能访问,那么可以通过如下配置实现:

http.apply(new UrlAuthorizationConfigurer<>(applicationContext))
        .withObjectPostProcessor(new  
                           ObjectPostProcessor<FilterSecurityInterceptor>() {
            @Override
            public <O extends FilterSecurityInterceptor> O 
                                                           postProcess(O object) {   
           object.setSecurityMetadataSource(customSecurityMetadataSource);
                object.setRejectPublicInvocations(true);
                return object;
            }
        });

通过设置 FilterSecurityInterceptor 中的 rejectPublicInvocations 属性为 true,就可以关闭URL的公开访问,所有 URL 必须具备对应的权限才能访问。

以上就是Spring Security动态权限的实现方法详解的详细内容,更多关于Spring Security动态权限的资料请关注三水点靠木其它相关文章!


Tags in this post...

Java/Android 相关文章推荐
基于Java的MathML转图片的方法(示例代码)
Jun 23 Java/Android
Java实现二维数组和稀疏数组之间的转换
Jun 27 Java/Android
Spring Boot 整合 Apache Dubbo的示例代码
Jul 04 Java/Android
Java使用jmeter进行压力测试
Jul 09 Java/Android
关于springboot 配置date字段返回时间戳的问题
Jul 25 Java/Android
一篇文章带你学习Mybatis-Plus(新手入门)
Aug 02 Java/Android
浅谈spring boot使用thymeleaf版本的问题
Aug 04 Java/Android
mybatis3中@SelectProvider传递参数方式
Aug 04 Java/Android
Java org.w3c.dom.Document 类方法引用报错
Aug 07 Java/Android
Mybatis-Plus进阶分页与乐观锁插件及通用枚举和多数据源详解
Mar 21 Java/Android
Spring Boot DevTools 全局配置学习指南
Mar 31 Java/Android
Java设计模式中的命令模式
Apr 28 Java/Android
Java实现注册登录跳转
Jun 16 #Java/Android
Java界面编程实现界面跳转
springboot实现string转json json里面带数组
Jun 16 #Java/Android
Android Gradle 插件自定义Plugin实现注意事项
Jun 16 #Java/Android
Java完整实现记事本代码
Jun 16 #Java/Android
Springboot中如何自动转JSON输出
Jun 16 #Java/Android
springboot为异步任务规划自定义线程池的实现
You might like
Php Mssql操作简单封装支持存储过程
2009/12/11 PHP
将博客园(cnblogs.com)数据导入到wordpress的代码
2013/01/06 PHP
php转换颜色为其反色的方法
2015/04/27 PHP
PHP使用逆波兰式计算工资的方法
2015/07/29 PHP
PHP测试成功的邮件发送案例
2015/10/26 PHP
Yii框架组件和事件行为管理详解
2016/05/20 PHP
php和redis实现秒杀活动的流程
2019/07/17 PHP
laravel请求参数校验方法
2019/10/10 PHP
jquery下jstree简单应用 - v1.0
2011/04/14 Javascript
关于JS控制代码暂停的实现方法分享
2012/10/11 Javascript
jQuery .tmpl() 用法示例介绍
2014/08/21 Javascript
JavaScript数组常用方法
2015/03/02 Javascript
JavaScript获取DOM元素的11种方法总结
2015/04/25 Javascript
Javascript中apply、call、bind的巧妙使用
2016/08/18 Javascript
js仿微信语音播放实现思路
2016/12/12 Javascript
Vue使用mixins实现压缩图片代码
2018/03/14 Javascript
纯js+css实现在线时钟
2020/08/18 Javascript
vue界面发送表情的实现代码
2020/09/11 Javascript
vue el-upload上传文件的示例代码
2020/12/21 Vue.js
使用Python给头像戴上圣诞帽的图像操作过程解析
2019/09/20 Python
Tensorflow实现将标签变为one-hot形式
2020/05/22 Python
Python无损压缩图片的示例代码
2020/08/06 Python
python自动从arxiv下载paper的示例代码
2020/12/05 Python
如何用Python徒手写线性回归
2021/01/25 Python
美国杂志订阅折扣与优惠网站:Magazines.com
2016/08/31 全球购物
瑜伽服装品牌:露露柠檬(lululemon athletica)
2017/06/04 全球购物
德国高尔夫商店:Par71.de
2020/11/29 全球购物
大整数数相乘的问题
2012/07/22 面试题
高级人员简历的自我评价分享
2013/11/03 职场文书
应届专科生个人的自我评价
2014/01/05 职场文书
机关干部四风问题自我剖析及整改措施
2014/10/26 职场文书
2015年世界粮食日演讲稿
2015/03/20 职场文书
老人与海读书笔记
2015/06/26 职场文书
暑假打工感想
2015/08/07 职场文书
信息技术教研组工作总结
2015/08/13 职场文书
2016大学生入党积极分子心得体会
2016/01/06 职场文书