Spring Security 动态权限实现方案!

2022-06-16 13:15:35 浏览数 (1)

松哥最近正在录制 TienChin 项目视频~采用 Spring Boot Vue3 技术栈,里边会涉及到各种好玩的技术,小伙伴们来和松哥一起做一个完成率超 90% 的项目,戳戳戳这里-->TienChin 项目配套视频来啦


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

本文节选自松哥之前出版的《深入浅出Spring Security》一书第 13 章,这套动态权限实现方案比 vhr 中的动态权限更优雅一些。

1. 动态管理权限规则

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

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

1.1 数据库设计

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

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

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

图13-10  访问规则

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

图13-11  用户角色表

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

图13-12  用户表

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

图13-13  用户角色关联表

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

图13-14  资源角色关联表

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

1.2 实战

项目创建

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

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

代码语言:javascript复制
<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 中配置数据库连接信息:

代码语言:javascript复制
spring.datasource.username=root
spring.datasource.password=123
spring.datasource.url=jdbc:mysql:///security13?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai

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

创建实体类

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

首先来创建角色类 Role:

代码语言:javascript复制
public class Role {
    private Integer id;
    private String name;
    private String nameZh;
       //省略getter/setter
}

然后创建菜单类 Menu:

代码语言:javascript复制
public class Menu {
    private Integer id;
    private String pattern;
    private List<Role> roles;
    //省略getter/setter
}

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

最后我们创建 User 类:

代码语言:javascript复制
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:

代码语言:javascript复制
@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 如下:

代码语言:javascript复制
@Mapper
public interface UserMapper {
    List<Role> getUserRoleByUid(Integer uid);
    User loadUserByUsername(String username);
}

UserMapper.xml:

代码语言:javascript复制
<!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 数据,代码如下:

代码语言:javascript复制
@Service
public class MenuService {
    @Autowired
    MenuMapper menuMapper;
    public List<Menu> getAllMenu() {
        return menuMapper.getAllMenu();
    }
}

MenuMapper:

代码语言:javascript复制
@Mapper
public interface MenuMapper {
    List<Menu> getAllMenu();
}

MenuMapper.xml:

代码语言:javascript复制
<!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 方法来提供受保护对象所需要的权限,代码如下:

代码语言:javascript复制
@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 对象,具体配置如下:

代码语言:javascript复制
@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,代码如下:

代码语言:javascript复制
@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-权限 映射关系后才能访问,那么可以通过如下配置实现:

代码语言:javascript复制
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 必须具备对应的权限才能访问。


松哥最近正在录制 TienChin 项目视频~采用 Spring Boot Vue3 技术栈,里边会涉及到各种好玩的技术,小伙伴们来和松哥一起做一个完成率超 90% 的项目,戳戳戳这里-->TienChin 项目配套视频来啦

0 人点赞