SpringSecurity(08)——动态权限

授权流程

SpringSecurity(08)——动态权限_第1张图片SpringSecurity的授权流程如下:

  1. 拦截请求,已认证用户访问受保护的web资源将被SecurityFilterChain中的 FilterSecurityInterceptor 的子类拦截。
  2. 获取资源访问策略,FilterSecurityInterceptor会从 SecurityMetadataSource 的子类 DefaultFilterInvocationSecurityMetadataSource 获取要访问当前资源所需要的权限 Collection

SecurityMetadataSource其实就是读取访问策略的抽象,而读取的内容其实就是我们配置的访问规则, 读取访问策略如下:

http.authorizeRequests()
	.antMatchers("/r/r1").hasAuthority("p1")
	.antMatchers("/r/r2").hasAuthority("p2")
	...
  1. FilterSecurityInterceptor会调用 AccessDecisionManager 进行授权决策,若决策通过,则允许访问资源,否则将禁止访问

权限资源

要实现动态的权限验证,首先要有对应的访问权限资源,Spring Security是通过SecurityMetadataSource来加载访问时所需要的具体权限

public interface SecurityMetadataSource extends AopInfrastructureBean {
    //根据提供的受保护对象的信息(URI),获取该URI配置的所有角色
    Collection<ConfigAttribute> getAttributes(Object var1) throws IllegalArgumentException;
    
    //获取全部角色,如果返回了所有定义的权限资源,Spring Security会在启动时
    //校验每个ConfigAttribute是否配置正确,不需要校验直接返回null
    Collection<ConfigAttribute> getAllConfigAttributes();
    
	//对特定的安全对象是否提供ConfigAttribute支持
    //web项目一般使用FilterInvocation来判断,或者直接返回true
    boolean supports(Class<?> var1);
}

SecurityMetadataSource是一个接口,同时还有一个接口FilterInvocationSecurityMetadataSource继承于它,但其只是一个标识接口,对应于FilterInvocation,本身并无任何内容

public interface FilterInvocationSecurityMetadataSource extends SecurityMetadataSource {
}

权限决策管理器

有了权限资源,知道了当前访问的url需要的具体权限,接下来就是决策当前的访问是否能通过权限验证了,AccessDecisionManager中包含的一系列AccessDecisionVoter将会被用来对Authentication 是否有权访问受保护对象进行投票,AccessDecisionManager根据投票结果做出最终决策

public interface AccessDecisionManager {
    // 决策主要通过其持有的 AccessDecisionVoter 来进行投票决策
    void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException;
    
    // 以确定AccessDecisionManager是否可以处理传递的ConfigAttribute
    boolean supports(ConfigAttribute var1);
	
    // 以确保配置的AccessDecisionManager支持安全拦截器将呈现的安全 object 类型。
    boolean supports(Class<?> var1);
}
  1. authentication:要访问资源的访问者的身份
  2. object:要访问的受保护资源,web请求对应的FilterInvocation
  3. configAttributes:受保护资源的访问策略,通过SecurityMetadataSource获取

权限决策投票器

public interface AccessDecisionVoter<S> {
    // 赞成
	int ACCESS_GRANTED = 1; 

    // 弃权
	int ACCESS_ABSTAIN = 0;

    // 否决
	int ACCESS_DENIED = -1;

    // 用于判断对于当前ConfigAttribute访问规则是否支持
	boolean supports(ConfigAttribute attribute);

	boolean supports(Class<?> clazz);

    // 如果支持的情况下,vote方法对其进行判断投票返回对应的授权结果
	int vote(Authentication authentication, S object, Collection<ConfigAttribute> attributes);
}

投票返回值

  1. ACCESS_GRANTED:同意
  2. ACCESS_DENIED:拒绝
  3. ACCESS_ABSTAIN:弃权

Spring Security内置了三个基于投票的AccessDecisionManager实现类如下,它们分别是 AffirmativeBased、ConsensusBased和UnanimousBased

AffirmativeBased

基于肯定的决策器,用户持有一个同意访问的角色就能通过

  • 只要有AccessDecisionVoter的投票为ACCESS_GRANTED则同意用户进行访问;
  • 如果全部弃权也表示通过;
  • 如果没有一个人投赞成票,但是有人投反对票,则将抛出AccessDeniedException。 Spring Security默认使用的是AffirmativeBased

ConsensusBased

基于共识的决策器,用户持有同意的角色数量多于禁止的角色数量

  • 如果赞成票多于反对票则表示通过;如果反对票多于赞成票则将抛出AccessDeniedException
  • 如果赞成票与反对票相同且不等于0,并且属性allowIfEqualGrantedDeniedDecisions的值为true(默认为true),则表示通过,否则将抛出异常AccessDeniedException
  • 如果所有的AccessDecisionVoter都弃权了,则将视参数allowIfAllAbstainDecisions的值而定(默认为false),如果该值为true则表示通过,否则将抛出异常AccessDeniedException

UnanimousBased

基于一致的决策器,用户持有的所有角色都同意访问才能放行

  • 如果受保护对象配置的某一个ConfigAttribute被任意的AccessDecisionVoter反对了,则将抛出 AccessDeniedException。
  • 如果没有反对票,但是有赞成票,则表示通过。
  • 如果全部弃权了,则将视参数allowIfAllAbstainDecisions的值而定,true则通过,false则抛出 AccessDeniedException。

相关组件

  1. 自定义FilterSecurityInterceptor,可仿写FilterSecurityInterceptor,实现抽象类AbstractSecurityInterceptor以及Filter接口,其主要的是把自定义的SecurityMetadataSource与自定义accessDecisionManager配置到自定义FilterSecurityInterceptor的拦截器中
  2. 自定义SecurityMetadataSource,实现接口FilterInvocationSecurityMetadataSource,实现从数据库或者其他数据源中加载ConfigAttribute(即是从数据库或者其他数据源中加载资源权限)
  3. 自定义AccessDecisionManager,可使用基于AccessDecisionVoter实现权限认证的官方UnanimousBased
  4. 自定义AccessDecisionVoter
  5. 自定义MyFilterSecurityInterceptor
  • 加载自定义的SecurityMetadataSource到自定义的FilterSecurityInterceptor中;
  • 加载自定义的AccessDecisionManager到自定义的FilterSecurityInterceptor中;
  • 重写invoke方法

自定义动态权限控制

基于用户的权限控制

@Service
public class UmsAdminServiceImpl implements UserDetailsService {
    
    @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把用户拥有的权限值和接口上注解定义的权限值进行比对,如果包含则可以访问,反之就不可以访问;但是这样做会带来一些问题,我们需要在每个接口上都定义好访问该接口的权限值,而且只能挨个控制接口的权限,无法批量控制

基于路径的动态权限控制

/**
 * 动态权限相关业务类
 */
public interface DynamicSecurityService {
    /**
     * 加载资源ANT通配符和资源对应MAP
     */
    Map<String, ConfigAttribute> loadDataSource();
}
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MallSecurityConfig {

    @Autowired
    private UmsResourceService resourceService;

    @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.getName()));
                }
                return map;
            }
        };
    }
}
/**
 * 动态权限数据源,用于获取动态权限规则
 */
@Component
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;
    }
}

流程如下

  1. 从数据库中查询出来所有的菜单,然后再过滤找到满足当前请求URL的,只要满足前面匹配的都需要权限控制
  2. 由于我们的后台资源规则被缓存在了一个Map对象之中,所以当后台资源发生变化时,我们需要清空缓存的数据,然后下次查询时就会被重新加载进来,需要调用clearDataSource方法来清空缓存的数据
  3. 之后我们需要实现AccessDecisionManager接口来实现权限校验,对于没有配置资源的接口我们直接允许访问,对于配置了资源的接口,我们把访问所需资源和用户拥有的资源进行比对,如果匹配则允许访问

注意:菜单权限是每次都要全量查询数据库,如果数据多的话,可能会影响性能,这里改造读取缓存,但是新增修改菜单时,记得更新缓存数据

/**
 * 动态权限决策管理器,用于判断用户是否有访问权限
 */
@Component
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;
    }
}
/**
 * JWT登录授权过滤器
 */
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    @Value("${jwt.tokenHeader}")
    private String tokenHeader;
    @Value("${jwt.tokenHead}")
    private String tokenHead;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String authHeader = request.getHeader(this.tokenHeader);
        if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
            String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
            String username = jwtTokenUtil.getUserNameFromToken(authToken);
            LOGGER.info("checking username:{}", username);
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
                if (jwtTokenUtil.validateToken(authToken, userDetails)) {
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    LOGGER.info("authenticated user:{}", username);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        chain.doFilter(request, response);
    }
}
/**
 * 动态权限过滤器,用于实现基于路径的动态权限过滤
 */
public class DynamicSecurityFilter extends AbstractSecurityInterceptor implements Filter {

    @Autowired
    private DynamicSecurityMetadataSource dynamicSecurityMetadataSource;
    @Autowired
    private IgnoreUrlsConfig ignoreUrlsConfig;
    @Autowired
    private DynamicAccessDecisionManager dynamicAccessDecisionManager;

    @Autowired
    public void setMyAccessDecisionManager() {
        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;
    }
}

接下来我们需要修改Spring Security的配置类SecurityConfig,当有动态权限业务类时在FilterSecurityInterceptor过滤器前添加我们的动态权限过滤器。这里在创建动态权限相关对象时,还使用了@ConditionalOnBean这个注解,当没有动态权限业务类时就不会创建动态权限相关对象,实现了有动态权限控制和没有这两种情况的兼容,FilterInvocation会进行参数校验,可以安全的拿到HttpServletRequest 和 HttpServletResponse对象

@Configuration
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();
        // 不需要保护的资源路径允许访问
        for (String url : ignoreUrlsConfig().getUrls()) {
            registry.antMatchers(url).permitAll();
        }
        // 允许跨域的OPTIONS请求
        registry.antMatchers(HttpMethod.OPTIONS)
                .permitAll();
        // 其他任何请求都需要身份认证
        registry.and()
                .authorizeRequests()
                .anyRequest()
                .authenticated()
                // 关闭跨站请求防护及不使用session
                .and()
                .csrf()
                .disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                // 自定义权限拒绝处理类
                .and()
                .exceptionHandling()
                .accessDeniedHandler(restfulAccessDeniedHandler())
                .authenticationEntryPoint(restAuthenticationEntryPoint())
                // 自定义权限拦截器JWT过滤器
                .and()
                .addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
        //有动态权限配置时添加动态权限校验过滤器
        if(dynamicSecurityService!=null){
            registry.and().addFilterBefore(dynamicSecurityFilter(), FilterSecurityInterceptor.class);
        }
    }

	//....
}

当前端跨域访问没有权限的接口时,会出现跨域问题,只需要在没有权限访问的处理类RestfulAccessDeniedHandler中添加允许跨域访问的响应头即可

/**
 * 自定义返回结果:没有权限访问时
 */
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();
    }
}

你可能感兴趣的:(SpringSecurity,java,spring)