Spring Security(二)权限管理


在另一篇文章 Spring Security(一)身份认证 中我们讲到了 Spring SecurityAuthentication 身份认证过程,当一个用户的身份被系统识别,我们会给这个用户赋予一定的权限,这些权限决定用户能够访问系统中的哪些资源,Spring Security 中的 Authorization 为我们提供了系统权限管理的基本模型。Spring Security 基于 AOP 切面编程的思想提供了方法级别的拦截,同时利用注解驱动大幅简化开发者在权限校验层面的逻辑判断。当然 Spring Security 默认的权限管理方式未必能够满足我们的需求,但是它为我们提供了灵活的扩展方式,我们可以方便地将自己的实现嵌入到 Spring Security 的校验流程中。

Spring Security 中的鉴权设计


AccessDecisionManager 访问控制管理

Spring Security 中的把身份认证过程抽象为 AuthenticationManager,同样的,也有一个鉴权流程的抽象叫做 AccessDecisionManager,用于判定用户对于某资源的访问是否拥有权限。

public interface AccessDecisionManager {

	void decide(Authentication authentication, Object object,
			Collection<ConfigAttribute> configAttributes) throws AccessDeniedException,
			InsufficientAuthenticationException;

	boolean supports(ConfigAttribute attribute);

	boolean supports(Class<?> clazz);

}

其中 decide 方法包含了鉴权的核心逻辑,它接收三个参数,分别为 authentication 用户身份信息(一般包含了用户所拥有的角色或权限),object 代表用户需要访问的目标资源(例如一个 web 接口或是 Java 中的一个 method),configAttributes 代表目前资源所设置的配置属性元信息,一般是访问该资源的权限要求,会作为用户是否对该资源拥有访问权限的判断依据。

AccessDecisionManager 的实现

Spring Security 内置了 AccessDecisionManager 的三种实现,他们都继承自抽象类 AbstractAccessDecisionManager,其中持有了一个 AccessDecisionVoter 的集合,每一个 voter 都代表了一种鉴权的规则。Spring Security 为什么要为我们提供多种鉴权规则组合的方式呢?其实这是考虑到部分系统鉴权的复杂性,在某些系统中,对于某一资源的访问权限可能是多种规则共同决定的,因此我们可以自定义多个 voter 组合起来构建鉴权系统,当然如果系统的鉴权逻辑较为单一,一般定义单个 voter 就足够了,甚至仅利用框架内置的 voter 即可。

AccessDecisionManager

AffirmativeBased

public class AffirmativeBased extends AbstractAccessDecisionManager {

	public AffirmativeBased(List<AccessDecisionVoter<?>> decisionVoters) {
		super(decisionVoters);
	}

	public void decide(Authentication authentication, Object object,
			Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
		int deny = 0;

		for (AccessDecisionVoter voter : getDecisionVoters()) {
			int result = voter.vote(authentication, object, configAttributes);

			if (logger.isDebugEnabled()) {
				logger.debug("Voter: " + voter + ", returned: " + result);
			}

			switch (result) {
			case AccessDecisionVoter.ACCESS_GRANTED:
				return;

			case AccessDecisionVoter.ACCESS_DENIED:
				deny++;

				break;

			default:
				break;
			}
		}

		if (deny > 0) {
			throw new AccessDeniedException(messages.getMessage(
					"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
		}

		// To get this far, every AccessDecisionVoter abstained
		checkAllowIfAllAbstainDecisions();
	}
}

AffirmativeBased 作为 AccessDecisionManager 的默认实现,只要 voter 集合中任意一个 voter 认为用户具备访问权限,则判定为用户具有访问该资源的权限,一般来说这种规则是最符合我们的常规需求的,我们系统中可能存在多种鉴权方式,一般来说用户只需要满足任意一种即可。

ConsensusBased

public class ConsensusBased extends AbstractAccessDecisionManager {
	
	private boolean allowIfEqualGrantedDeniedDecisions = true;

	public ConsensusBased(List<AccessDecisionVoter<?>> decisionVoters) {
		super(decisionVoters);
	}

	public void decide(Authentication authentication, Object object,
			Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
		int grant = 0;
		int deny = 0;

		for (AccessDecisionVoter voter : getDecisionVoters()) {
			int result = voter.vote(authentication, object, configAttributes);

			if (logger.isDebugEnabled()) {
				logger.debug("Voter: " + voter + ", returned: " + result);
			}

			switch (result) {
			case AccessDecisionVoter.ACCESS_GRANTED:
				grant++;

				break;

			case AccessDecisionVoter.ACCESS_DENIED:
				deny++;

				break;

			default:
				break;
			}
		}

		if (grant > deny) {
			return;
		}

		if (deny > grant) {
			throw new AccessDeniedException(messages.getMessage(
					"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
		}

		if ((grant == deny) && (grant != 0)) {
			if (this.allowIfEqualGrantedDeniedDecisions) {
				return;
			}
			else {
				throw new AccessDeniedException(messages.getMessage(
						"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
			}
		}

		// To get this far, every AccessDecisionVoter abstained
		checkAllowIfAllAbstainDecisions();
	}

	public boolean isAllowIfEqualGrantedDeniedDecisions() {
		return allowIfEqualGrantedDeniedDecisions;
	}

	public void setAllowIfEqualGrantedDeniedDecisions(
			boolean allowIfEqualGrantedDeniedDecisions) {
		this.allowIfEqualGrantedDeniedDecisions = allowIfEqualGrantedDeniedDecisions;
	}
}

ConsensusBased 实现更符合选举机制,当用户获得过半数 voter 的授权,再能获取访问资源的权限,不过个人认为这种方式在权限控制上具有较高的复杂性,不太符合大部分的业务场景,建议谨慎使用。

UnanimousBased

public class UnanimousBased extends AbstractAccessDecisionManager {

	public UnanimousBased(List<AccessDecisionVoter<?>> decisionVoters) {
		super(decisionVoters);
	}

	public void decide(Authentication authentication, Object object,
			Collection<ConfigAttribute> attributes) throws AccessDeniedException {

		int grant = 0;

		List<ConfigAttribute> singleAttributeList = new ArrayList<>(1);
		singleAttributeList.add(null);

		for (ConfigAttribute attribute : attributes) {
			singleAttributeList.set(0, attribute);

			for (AccessDecisionVoter voter : getDecisionVoters()) {
				int result = voter.vote(authentication, object, singleAttributeList);

				if (logger.isDebugEnabled()) {
					logger.debug("Voter: " + voter + ", returned: " + result);
				}

				switch (result) {
				case AccessDecisionVoter.ACCESS_GRANTED:
					grant++;

					break;

				case AccessDecisionVoter.ACCESS_DENIED:
					throw new AccessDeniedException(messages.getMessage(
							"AbstractAccessDecisionManager.accessDenied",
							"Access is denied"));

				default:
					break;
				}
			}
		}

		// To get this far, there were no deny votes
		if (grant > 0) {
			return;
		}

		// To get this far, every AccessDecisionVoter abstained
		checkAllowIfAllAbstainDecisions();
	}
}

UnanimousBased 实现要求用户获得所有 voter 的授权,这种方式对权限的校验比较严格,如果我们的系统中定义了多种权限校验的方式,那么用户在访问资源时需要同时满足这些要求。当然除了框架内置的三种实现以外,我们完全可以根据自身的需求自定义 AccessDecisionManager 的实现,整体上来说我认为 Spring Security 中将多个 voter 组合起来共同完成鉴权的设计模式还有很有意思的,值得我们借鉴。

针对 WEB 请求的鉴权


Spring Security 的过滤器链 SecurityFilterChain 中存在一个关键性的过滤器 FilterSecurityInterceptor,它负责为 Web 请求对象 HttpServletRequest 提供权限校验,如果过滤器判定当前请求拥有访问该资源的权限则放行,否则抛出特定异常,进入异常处理流程。

本图摘自官方文档

  1. 首先 FilterSecurityInterceptor 会从 SecurityContextHolder 中获取 Authentication 对象,如果不存在,则会再尝试一次身份认证流程。

  2. 构建 FilterInvocation 对象,其中包含当前的 requestresponsefilterChain 信息。

  3. 根据当前的请求对象从 SecurityMetadataSource 中获取配置属性元信息。

  4. 将鉴权所需要的信息传递给 AccessDecisionManager 判定当前用户是否拥有访问该资源的权限。

  5. 若判定为有权限,则放行,执行过滤器链后续流程,若无权限则抛出 AccessDeniedException 异常。

整体来讲 Spring Security 在过滤器层面的鉴权是全局层面的鉴权,因为此时请求还没有到达 Spring MVC 的层面执行业务代码,鉴权的依据是上下文的配置元信息,而针对具体业务代码的鉴权往往需要依赖于 AOP 方法拦截。

针对 Java Method 方法级别的鉴权


Spring Security 针对方法级别的鉴权主要依赖于一个 AOP 切面 MethodSecurityInterceptor,当我们使用 @EnableGlobalMethodSecurity 注解启用全局方法拦截后,在项目的启动阶段,负责权限拦截的切面别初始化,而被鉴权注解标记的方法都会成为鉴权切面的切入点,此时我们就可以利用注解驱动方便地完成方法粒度的权限控制。

package priv.just.framework.security.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import priv.just.framework.security.domain.Order;
import priv.just.framework.security.service.OrderService;

import javax.annotation.Resource;

@RestController
@RequestMapping("order")
public class OrderController {

    @Resource
    private OrderService orderService;

    @GetMapping("getTimestamp")
    public long getTimestamp() {
        return System.currentTimeMillis();
    }

    @PreAuthorize("hasAuthority('QUERY_ORDER')")
    @GetMapping("queryOrder")
    public Order queryOrder(long orderId) {
        return orderService.queryOrder(orderId);
    }

    @PreAuthorize("hasAuthority('CREATE_ORDER')")
    @PostMapping("createOrder")
    public long createOrder(@RequestBody Order order) {
        return orderService.createOrder(order);
    }

    @PreAuthorize("hasAuthority('DELETE_ORDER')")
    @PostMapping("deleteOrder")
    public void deleteOrder(@RequestParam("orderId") long orderId) {
        orderService.deleteOrder(orderId);
    }

}

Controller 层面的权限控制为例,@PreAuthorize("hasAuthority('QUERY_ORDER')") 注解表示当用户拥有名称为 QUERY_ORDER 的权限时才能调用目标方法,否则会抛出 AccessDeniedException,触发鉴权失败处理,一般在业务层面上,此时前端会告知用户无权执行该操作。

CORS 跨域资源共享


一般来说所有 WEB 系统都需要考虑跨域问题,同源策略是 WEB 安全的基石,是其他安全策略的前提。Spring Security 内置了 CorsFilter 为我们提供了这样的支持。

public class CorsFilter extends OncePerRequestFilter {

	private final CorsConfigurationSource configSource;

	private CorsProcessor processor = new DefaultCorsProcessor();

	public CorsFilter(CorsConfigurationSource configSource) {
		Assert.notNull(configSource, "CorsConfigurationSource must not be null");
		this.configSource = configSource;
	}

	public void setCorsProcessor(CorsProcessor processor) {
		Assert.notNull(processor, "CorsProcessor must not be null");
		this.processor = processor;
	}


	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
			FilterChain filterChain) throws ServletException, IOException {

		CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
		boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
		if (!isValid || CorsUtils.isPreFlightRequest(request)) {
			return;
		}
		filterChain.doFilter(request, response);
	}

}

当前如果内置的 CorsFilter 不能满足我们的需求,我们也可以自定义过滤器完成跨域相关的处理。一般来说我们只需要自定义 CorsConfigurationSource 类型的 bean 并装配到上下文,结合内置的 CorsFilter 即可完成我们的需求。

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableRedisHttpSession(redisNamespace = "just:session:")
public class MyWebSecurityConfigurer extends WebSecurityConfigurerAdapter {

	@Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // ...
                // 跨域处理
                .cors().and()
                // ...
    }
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        return request -> {
            CorsConfiguration corsConfiguration = new CorsConfiguration();
            corsConfiguration.setAllowedOrigins(Collections.singletonList("localhost"));
            corsConfiguration.setAllowedMethods(Arrays.stream(HttpMethod.values()).map(HttpMethod::toString).collect(Collectors.toList()));
            return corsConfiguration;
        };
    }

}

需要注意的是,当我们选择自定义 CorsFilter 时,应该保证跨域处理的过滤器位于我们用户身份认证过滤器以及权限校验过滤器之前(Spring Security 默认可以保证这样的顺序),因为跨域预检请求一般来讲不会携带 cookie 信息,而我们的 sessionId 又往往是通过 cookie 携带的,因此为了避免跨域预检请求报错为用户造成不必要的困扰,我们需要关注各个过滤器的顺序造成的影响。


文章作者: Ethan Zhang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Ethan Zhang !
评论
 上一篇
记一次 Redis 上线事故 记一次 Redis 上线事故
Redis 作为最被广泛使用的缓存中间件之一,虽然市面上大量开源的 Redis 开源客户端提供的 API 是非常简洁的,但如果我们需要将它用得游刃有余,使其真正高效地服务于我们的应用服务,那么我们就需要深入理解 Redis 的存储结构和运
2020-12-23 Ethan Zhang
下一篇 
Spring Security(一)身份认证 Spring Security(一)身份认证
随着互联网技术的蓬勃发展,正所谓道高一尺,魔高一丈,黑客技术也得到了迅猛的发展。黑客们利用各大网站的安全漏洞实施着各种出人意料的攻击,例如常见的 XSS 跨站脚本攻击,CSRF 跨站请求伪造等方式。众所周知,数据是互联网公司最重要的核心资
2020-11-27 Ethan Zhang