最近在做 TienChin 项目,用的是 RuoYi-Vue 脚手架,在这个脚手架中,访问某个接口需要什么权限,这个是在代码中硬编码的,具体怎么实现的,松哥下篇文章来和大家分析,有的小伙伴可能希望能让这个东西像 vhr 一样,可以在数据库中动态配置,因此这篇文章和小伙伴们简单介绍下 Spring Security 中的动态权限方案,以便于小伙伴们更好的理解 TienChin 项目中的权限方案。
通过代码来配置 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 文件内容如下:
org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-web org.mybatis.spring.boot mybatis-spring-boot-starter 2.1.3 mysql mysql-connector-java runtime
项目创建完成后,接下来在 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 Listroles; //省略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 Listroles; @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 { ListgetUserRoleByUid(Integer uid); User loadUserByUsername(String username); }
UserMapper.xml:
再来看 MenuService,该类只需要提供一个方法,就是查询出所有的 Menu 数据,代码如下:
@Service public class MenuService { @Autowired MenuMapper menuMapper; public List
MenuMapper:
@Mapper public interface MenuMapper { List
MenuMapper.xml:
需要注意,由于每一个 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 CollectiongetAttributes(Object object) throws IllegalArgumentException { String requestURI = ((FilterInvocation) object).getRequest().getRequestURI(); List
自定义 CustomSecurityMetadataSource 类并实现 FilterInvocationSecurityMetadataSource 接口,然后重写它里边的三个方法:
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() { @Override public O postProcess(O object) { object.setSecurityMetadataSource(customSecurityMetadataSource); return object; } }); http.formLogin() .and() .csrf().disable(); } }
关于用户的配置无需多说,我们重点来看 configure(HttpSecurity) 方法。
由于访问路径规则和所需要的权限之间的映射关系已经保存在数据库中,所以我们就没有必要在 Java 代码中配置映射关系了,同时这里的权限对比也不会用到权限表达式,所以我们通过 UrlAuthorizationConfigurer 来进行配置。
在配置的过程中,通过 withObjectPostProcessor 方法调用 ObjectPostProcessor 对象后置处理器,在对象后置处理器中,将 FilterSecurityInterceptor 中的 SecurityMetadataSource 对象替换为我们自定义的 customSecurityMetadataSource 对象即可。
接下来创建 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() { @Override public O postProcess(O object) { object.setSecurityMetadataSource(customSecurityMetadataSource); object.setRejectPublicInvocations(true); return object; } });
通过设置 FilterSecurityInterceptor 中的 rejectPublicInvocations 属性为 true,就可以关闭URL的公开访问,所有 URL 必须具备对应的权限才能访问。