SpringBoot实战电商项目mall(30k+star)地址: https://github.com/macrozheng/mall
权限控管理做为后台管理系统中必要的功能,mall项目中结合Spring Security实现了基于路径的动态权限控制,能够对后台接口访问进行细粒度的控制,今天咱们来说下它的后端实现原理。前端
学习本文须要一些Spring Security的知识,对Spring Security不太了解的朋友能够看下如下文章。
权限管理相关表已经从新设计,将原来的权限拆分红了菜单和资源,菜单管理用于控制前端菜单的显示和隐藏,资源管理用来控制后端接口的访问权限。
其中ums_admin
、ums_role
、ums_admin_role_relation
为原来的表,其余均为新增表。
接下来咱们将对每张表的用途作个详细介绍。
后台用户表,定义了后台用户的一些基本信息。
create table ums_admin ( id bigint not null auto_increment, username varchar(64) comment '用户名', password varchar(64) comment '密码', icon varchar(500) comment '头像', email varchar(100) comment '邮箱', nick_name varchar(200) comment '昵称', note varchar(500) comment '备注信息', create_time datetime comment '建立时间', login_time datetime comment '最后登陆时间', status int(1) default 1 comment '账号启用状态:0->禁用;1->启用', primary key (id) );
后台用户角色表,定义了后台用户角色的一些基本信息,经过给后台用户分配角色来实现菜单和资源的分配。
create table ums_role ( id bigint not null auto_increment, name varchar(100) comment '名称', description varchar(500) comment '描述', admin_count int comment '后台用户数量', create_time datetime comment '建立时间', status int(1) default 1 comment '启用状态:0->禁用;1->启用', sort int default 0, primary key (id) );
后台用户和角色关系表,多对多关系表,一个角色能够分配给多个用户。
create table ums_admin_role_relation ( id bigint not null auto_increment, admin_id bigint, role_id bigint, primary key (id) );
后台菜单表,用于控制后台用户能够访问的菜单,支持隐藏、排序和更更名称、图标。
create table ums_menu ( id bigint not null auto_increment, parent_id bigint comment '父级ID', create_time datetime comment '建立时间', title varchar(100) comment '菜单名称', level int(4) comment '菜单级数', sort int(4) comment '菜单排序', name varchar(100) comment '前端名称', icon varchar(200) comment '前端图标', hidden int(1) comment '前端隐藏', primary key (id) );
后台资源表,用于控制后台用户能够访问的接口,使用了Ant路径的匹配规则,可使用通配符定义一系列接口的权限。
create table ums_resource ( id bigint not null auto_increment, category_id bigint comment '资源分类ID', create_time datetime comment '建立时间', name varchar(200) comment '资源名称', url varchar(200) comment '资源URL', description varchar(500) comment '描述', primary key (id) );
后台资源分类表,在细粒度进行权限控制时,可能资源会比较多,因此设计了个资源分类的概念,便于给角色分配资源。
create table ums_resource_category ( id bigint not null auto_increment, create_time datetime comment '建立时间', name varchar(200) comment '分类名称', sort int(4) comment '排序', primary key (id) );
后台角色菜单关系表,多对多关系,能够给一个角色分配多个菜单。
create table ums_role_menu_relation ( id bigint not null auto_increment, role_id bigint comment '角色ID', menu_id bigint comment '菜单ID', primary key (id) );
后台角色资源关系表,多对多关系,能够给一个角色分配多个资源。
create table ums_role_resource_relation ( id bigint not null auto_increment, role_id bigint comment '角色ID', resource_id bigint comment '资源ID', primary key (id) );
实现动态权限是在原mall-security
模块的基础上进行改造完成的,原实现有不清楚的能够自行参照前置知识
中的文档来学习。
之前的权限控制是采用Spring Security的默认机制实现的,下面咱们以商品模块的代码为例来说讲实现原理。
@PreAuthorize
注解定义好须要的权限;/** * 商品管理Controller * Created by macro on 2018/4/26. */ @Controller @Api(tags = "PmsProductController", description = "商品管理") @RequestMapping("/product") public class PmsProductController { @Autowired private PmsProductService productService; @ApiOperation("建立商品") @RequestMapping(value = "/create", method = RequestMethod.POST) @ResponseBody @PreAuthorize("hasAuthority('pms:product:create')") public CommonResult create(@RequestBody PmsProductParam productParam, BindingResult bindingResult) { int count = productService.create(productParam); if (count > 0) { return CommonResult.success(count); } else { return CommonResult.failed(); } } }
/** * UmsAdminService实现类 * Created by macro on 2018/4/26. */ @Service public class UmsAdminServiceImpl implements UmsAdminService { @Override public UserDetails loadUserByUsername(String username){ //获取用户信息 UmsAdmin admin = getAdminByUsername(username); if (admin != null) { List<UmsPermission> permissionList = getPermissionList(admin.getId()); return new AdminUserDetails(admin,permissionList); } throw new UsernameNotFoundException("用户名或密码错误"); } }
接下来咱们详细介绍下如何使用Spring Security实现基于路径的动态权限。
首先咱们须要建立一个过滤器,用于实现动态权限控制,这里须要注意的是doFilter
方法,对于OPTIONS请求直接放行,不然前端调用会出现跨域问题。对于配置在IgnoreUrlsConfig
中的白名单路径我也须要直接放行,全部的鉴权操做都会在super.beforeInvocation(fi)
中进行。java
/** * 动态权限过滤器,用于实现基于路径的动态权限过滤 * Created by macro on 2020/2/7. */ public class DynamicSecurityFilter extends AbstractSecurityInterceptor implements Filter { @Autowired private DynamicSecurityMetadataSource dynamicSecurityMetadataSource; @Autowired private IgnoreUrlsConfig ignoreUrlsConfig; @Autowired public void setMyAccessDecisionManager(DynamicAccessDecisionManager dynamicAccessDecisionManager) { super.setAccessDecisionManager(dynamicAccessDecisionManager); } @Override public void init(FilterConfig filterConfig) throws ServletException { } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; FilterInvocation fi = new FilterInvocation(servletRequest, servletResponse, filterChain); //OPTIONS请求直接放行 if(request.getMethod().equals(HttpMethod.OPTIONS.toString())){ fi.getChain().doFilter(fi.getRequest(), fi.getResponse()); return; } //白名单请求直接放行 PathMatcher pathMatcher = new AntPathMatcher(); for (String path : ignoreUrlsConfig.getUrls()) { if(pathMatcher.match(path,request.getRequestURI())){ fi.getChain().doFilter(fi.getRequest(), fi.getResponse()); return; } } //此处会调用AccessDecisionManager中的decide方法进行鉴权操做 InterceptorStatusToken token = super.beforeInvocation(fi); try { fi.getChain().doFilter(fi.getRequest(), fi.getResponse()); } finally { super.afterInvocation(token, null); } } @Override public void destroy() { } @Override public Class<?> getSecureObjectClass() { return FilterInvocation.class; } @Override public SecurityMetadataSource obtainSecurityMetadataSource() { return dynamicSecurityMetadataSource; } }
在DynamicSecurityFilter中调用super.beforeInvocation(fi)方法时会调用AccessDecisionManager中的decide方法用于鉴权操做,而decide方法中的configAttributes参数会经过SecurityMetadataSource中的getAttributes方法来获取,configAttributes其实就是配置好的访问当前接口所须要的权限,下面是简化版的beforeInvocation源码。git
public abstract class AbstractSecurityInterceptor implements InitializingBean, ApplicationEventPublisherAware, MessageSourceAware { protected InterceptorStatusToken beforeInvocation(Object object) { //获取元数据 Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource() .getAttributes(object); Authentication authenticated = authenticateIfRequired(); //进行鉴权操做 try { this.accessDecisionManager.decide(authenticated, object, attributes); } catch (AccessDeniedException accessDeniedException) { publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, accessDeniedException)); throw accessDeniedException; } } }
知道了鉴权的原理,接下来咱们须要本身实现SecurityMetadataSource接口的getAttributes方法,用于获取当前访问路径所需资源。github
/** * 动态权限数据源,用于获取动态权限规则 * Created by macro on 2020/2/7. */ public class DynamicSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { private static Map<String, ConfigAttribute> configAttributeMap = null; @Autowired private DynamicSecurityService dynamicSecurityService; @PostConstruct public void loadDataSource() { configAttributeMap = dynamicSecurityService.loadDataSource(); } public void clearDataSource() { configAttributeMap.clear(); configAttributeMap = null; } @Override public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException { if (configAttributeMap == null) this.loadDataSource(); List<ConfigAttribute> configAttributes = new ArrayList<>(); //获取当前访问的路径 String url = ((FilterInvocation) o).getRequestUrl(); String path = URLUtil.getPath(url); PathMatcher pathMatcher = new AntPathMatcher(); Iterator<String> iterator = configAttributeMap.keySet().iterator(); //获取访问该路径所需资源 while (iterator.hasNext()) { String pattern = iterator.next(); if (pathMatcher.match(pattern, path)) { configAttributes.add(configAttributeMap.get(pattern)); } } // 未设置操做请求权限,返回空集合 return configAttributes; } @Override public Collection<ConfigAttribute> getAllConfigAttributes() { return null; } @Override public boolean supports(Class<?> aClass) { return true; } }
因为咱们的后台资源规则被缓存在了一个Map对象之中,因此当后台资源发生变化时,咱们须要清空缓存的数据,而后下次查询时就会被从新加载进来。这里咱们须要修改UmsResourceController类,注入DynamicSecurityMetadataSource,当修改后台资源时,须要调用clearDataSource方法来清空缓存的数据。spring
/** * 后台资源管理Controller * Created by macro on 2020/2/4. */ @Controller @Api(tags = "UmsResourceController", description = "后台资源管理") @RequestMapping("/resource") public class UmsResourceController { @Autowired private UmsResourceService resourceService; @Autowired private DynamicSecurityMetadataSource dynamicSecurityMetadataSource; @ApiOperation("添加后台资源") @RequestMapping(value = "/create", method = RequestMethod.POST) @ResponseBody public CommonResult create(@RequestBody UmsResource umsResource) { int count = resourceService.create(umsResource); dynamicSecurityMetadataSource.clearDataSource(); if (count > 0) { return CommonResult.success(count); } else { return CommonResult.failed(); } } }
以后咱们须要实现AccessDecisionManager接口来实现权限校验,对于没有配置资源的接口咱们直接容许访问,对于配置了资源的接口,咱们把访问所需资源和用户拥有的资源进行比对,若是匹配则容许访问。sql
/** * 动态权限决策管理器,用于判断用户是否有访问权限 * Created by macro on 2020/2/7. */ public class DynamicAccessDecisionManager implements AccessDecisionManager { @Override public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException { // 当接口未被配置资源时直接放行 if (CollUtil.isEmpty(configAttributes)) { return; } Iterator<ConfigAttribute> iterator = configAttributes.iterator(); while (iterator.hasNext()) { ConfigAttribute configAttribute = iterator.next(); //将访问所需资源或用户拥有资源进行比对 String needAuthority = configAttribute.getAttribute(); for (GrantedAuthority grantedAuthority : authentication.getAuthorities()) { if (needAuthority.trim().equals(grantedAuthority.getAuthority())) { return; } } } throw new AccessDeniedException("抱歉,您没有访问权限"); } @Override public boolean supports(ConfigAttribute configAttribute) { return true; } @Override public boolean supports(Class<?> aClass) { return true; } }
咱们以前在DynamicSecurityMetadataSource中注入了一个DynamicSecurityService对象,它是我自定义的一个动态权限业务接口,其主要用于加载全部的后台资源规则。数据库
/** * 动态权限相关业务类 * Created by macro on 2020/2/7. */ public interface DynamicSecurityService { /** * 加载资源ANT通配符和资源对应MAP */ Map<String, ConfigAttribute> loadDataSource(); }
接下来咱们须要修改Spring Security的配置类SecurityConfig,当有动态权限业务类时在FilterSecurityInterceptor过滤器前添加咱们的动态权限过滤器。这里在建立动态权限相关对象时,还使用了@ConditionalOnBean这个注解,当没有动态权限业务类时就不会建立动态权限相关对象,实现了有动态权限控制和没有这两种状况的兼容。json
/** * 对SpringSecurity的配置的扩展,支持自定义白名单资源路径和查询用户逻辑 * Created by macro on 2019/11/5. */ public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired(required = false) private DynamicSecurityService dynamicSecurityService; @Override protected void configure(HttpSecurity httpSecurity) throws Exception { ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity .authorizeRequests(); //有动态权限配置时添加动态权限校验过滤器 if(dynamicSecurityService!=null){ registry.and().addFilterBefore(dynamicSecurityFilter(), FilterSecurityInterceptor.class); } } @ConditionalOnBean(name = "dynamicSecurityService") @Bean public DynamicAccessDecisionManager dynamicAccessDecisionManager() { return new DynamicAccessDecisionManager(); } @ConditionalOnBean(name = "dynamicSecurityService") @Bean public DynamicSecurityFilter dynamicSecurityFilter() { return new DynamicSecurityFilter(); } @ConditionalOnBean(name = "dynamicSecurityService") @Bean public DynamicSecurityMetadataSource dynamicSecurityMetadataSource() { return new DynamicSecurityMetadataSource(); } }
这里还有个问题须要提下,当前端跨域访问没有权限的接口时,会出现跨域问题,只须要在没有权限访问的处理类RestfulAccessDeniedHandler中添加容许跨域访问的响应头便可。后端
/** * 自定义返回结果:没有权限访问时 * Created by macro on 2018/4/26. */ public class RestfulAccessDeniedHandler implements AccessDeniedHandler{ @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException { response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Cache-Control","no-cache"); response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); response.getWriter().println(JSONUtil.parse(CommonResult.forbidden(e.getMessage()))); response.getWriter().flush(); } }
当咱们其余模块须要动态权限控制时,只要建立一个DynamicSecurityService对象就好了,好比在mall-admin
模块中咱们启用了动态权限功能。跨域
/** * mall-security模块相关配置 * Created by macro on 2019/11/9. */ @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class MallSecurityConfig extends SecurityConfig { @Autowired private UmsAdminService adminService; @Autowired private UmsResourceService resourceService; @Bean public UserDetailsService userDetailsService() { //获取登陆用户信息 return username -> adminService.loadUserByUsername(username); } @Bean public DynamicSecurityService dynamicSecurityService() { return new DynamicSecurityService() { @Override public Map<String, ConfigAttribute> loadDataSource() { Map<String, ConfigAttribute> map = new ConcurrentHashMap<>(); List<UmsResource> resourceList = resourceService.listAll(); for (UmsResource resource : resourceList) { map.put(resource.getUrl(), new org.springframework.security.access.SecurityConfig(resource.getId() + ":" + resource.getName())); } return map; } }; } }
https://github.com/macrozheng/mall
mall项目全套学习教程连载中,关注公众号第一时间获取。