文章目录
回顾
前面我们介绍了spring security 可以对于登录用户具有的权限进行授权哪些资源可以访问,哪些资源不可以访问,如果不了解如何配置资源访问权限的可以回去看之前的文章《循序渐进学spring security 第七篇,如何基于用户表和权限表配置权限?越学越简单了》,不知道大家是否有发现,我们之前介绍的都是将接口直接配置在配置文件上的,这对于很多实际业务场景还是不能够满足的,比如在一些企业,会有一个后台管理,企业管理员可以在后台配置前台页面的菜单,给哪些菜单授权,哪些用户身份具备权限的,就能访问到这些菜单,否则将访问不到。如果每次管理员在配置前台菜单权限时,都需要去改代码配置,这无疑是不现实的,那么,有没有办法动态的去实现配置呢?当然是有的,这就是本文的主题
在阅读本文之前,请移步阅读我之前的文章,有助于对本文的理解
- 面试不要在说不熟悉spring security了,一个demo让你使劲忽悠面试官
- 循序渐进学习spring security 第二篇,如何修改默认用户?
- 循序渐进学习spring security 第三篇,如何自定义登录页面?登录回调?
- 循序渐进雪spring security 第四篇,登录流程是怎样的?登录用户信息保存在哪里?
- 循序渐进学习spring security 第五篇,如何处理重定向和服务器跳转?登录如何返回JSON串?
- 循序渐进学spring security第六篇,手把手教你如何从数据库读取用户进行登录验证,mybatis集成
- 循序渐进学spring security 第七篇,如何基于用户表和权限表配置权限?越学越简单了
- 循序渐进学spring security 第八篇,如何配置密码加密?是否支持多种加密方案?
- 循序渐进学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>
这里,提供了两个查询,
- findRolesByUserId 是根据用户id查询用户具有的角色,
- 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>
这里也是提供了两个查询接口,
- findMenusByRoleId 方法是根据传入的角色id,查询该角色下的管理的菜单
- 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,就这样,我们就已经完成了动态权限的控制,即使是上市公司,每天有成千上万员工加入,成千上万员工离职,我们也只需要维护角色用户表就就可以完成权限的控制了