Spring Security 如何进行权限验证

阅读本文之前,请投票支持这款 全新设计的脚手架 ,让 Java 再次伟大!

FilterSecurityInterceptor

FilterSecurityInterceptor 是负责权限验证的过滤器。一般来说,权限验证是一系列业务逻辑处理完成以后,最后需要解决的问题。所以默认情况下 security 会把和权限有关的过滤器,放在 VirtualFilter chains 的最后一位。

FilterSecurityInterceptor.doFilter 方法定义了两个权限验证逻辑。分别是 super.beforeInvocation 方法代表的权限前置验证与 super.afterInvocation 代表的后置权限验证。

java 复制代码
public void invoke(FilterInvocation fi) throws IOException, ServletException {
	if ((fi.getRequest() != null) && (fi.getRequest().getAttribute(FILTER_APPLIED) != null) && observeOncePerRequest) {
			// filter already applied to this request and user wants us to observe
			// once-per-request handling, so don't re-do security checking
		fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
	} else {
				// first time this request being called, so perform security checking
		if (fi.getRequest() != null && observeOncePerRequest) {
			fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
		}

		InterceptorStatusToken token = super.beforeInvocation(fi);

		try {
			fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
		}
		finally {
			super.finallyInvocation(token);
		}

		super.afterInvocation(token, null);
	}
}

和身份验证的设计类似,前置权限验证的业务处理也是参考模板方法设计模式的理念,将核心处理流程定义在父类(super) AbstractSecurityInterceptor 中的。

java 复制代码
InterceptorStatusToken token = super.beforeInvocation(fi);

再通过关联 AccessDecisionManager 的方式,把身份验证通过的认证对象委托给 AccessDecisionManager 执行。

java 复制代码
Authentication authenticated = authenticateIfRequired();
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);
		// Attempt authorization
try {
	this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
	publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
		accessDeniedException));

	throw accessDeniedException;
}

		// Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object,
	attributes);

if (runAs == null) {
	if (debug) {
		logger.debug("RunAsManager did not change Authentication object");
	}

			// no further work post-invocation
	return new InterceptorStatusToken(SecurityContextHolder.getContext(), false,
		attributes, object);
}
else {
	if (debug) {
		logger.debug("Switching to RunAs Authentication: " + runAs);
	}

	SecurityContext origCtx = SecurityContextHolder.getContext();
	SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
	SecurityContextHolder.getContext().setAuthentication(runAs);

			// need to revert to token.Authenticated post-invocation
	return new InterceptorStatusToken(origCtx, true, attributes, object);
}

在前置权限校验执行完毕后,会使用 this.runAsManager.buildRunAs 与 SecurityContextHolder.getContext().setAuthentication(runAs) 方法将当前上下文中的身份认证成功的对象备份,然后重新拷贝一个新的鉴权成功的对象到上下文的中。

java 复制代码
		// Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object,
	attributes);

	SecurityContext origCtx = SecurityContextHolder.getContext();
	SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
	SecurityContextHolder.getContext().setAuthentication(runAs);
	

这个处理初看会有点难以理解,但是仔细思考一下,这其实是一个友好且细腻的操作。

原因是 FilterSecurityInterceptor 在大部分情况下是最后一个默认过滤器,如果说还有后续过滤操作的话,那这些肯定是你的自定义的过滤器对象行为产生的。安全起见,security 将权限认证过后的 authenticated 对象拷贝了一份副本,方便用户在后续的自定义过滤器中使用。

即使自定义过滤器中不小心修改了全局上下文中的 authenticate 对象也无妨,因为 super.finallyInvocation 方法

java 复制代码
finally {
	super.finallyInvocation(token);
}

会保证自定义过滤器全部执行完毕以后,再把 copy source 「归还」到上下文中。这样,在后续的业务处理中就不用担心获取到一个不安全地 Authentication 对象了。

PrePostAnnotationSecurityMetadataSource.getAttributes

在 security 中使用的几种鉴权注解一共有以下四种:

  • PreFilter
  • PreAuthorize
  • PostFilter
  • PostAuthorize

其中最常用的是 PreAuthorize,它的含义是在执行目标方法前执行鉴权逻辑。前置过滤器执行的第一个逻辑,就是使用 getAttributes 方法获取目标方法上的注解。

java 复制代码
public Collection<ConfigAttribute> getAttributes(Method method, Class<?> targetClass) {
		if (method.getDeclaringClass() == Object.class) {
			return Collections.emptyList();
		}

		logger.trace("Looking for Pre/Post annotations for method '" + method.getName()
				+ "' on target class '" + targetClass + "'");
		PreFilter preFilter = findAnnotation(method, targetClass, PreFilter.class);
		PreAuthorize preAuthorize = findAnnotation(method, targetClass,
				PreAuthorize.class);
		PostFilter postFilter = findAnnotation(method, targetClass, PostFilter.class);
		// TODO: Can we check for void methods and throw an exception here?
		PostAuthorize postAuthorize = findAnnotation(method, targetClass,
				PostAuthorize.class);

		if (preFilter == null && preAuthorize == null && postFilter == null
				&& postAuthorize == null) {
			// There is no meta-data so return
			logger.trace("No expression annotations found");
			return Collections.emptyList();
		}

		String preFilterAttribute = preFilter == null ? null : preFilter.value();
		String filterObject = preFilter == null ? null : preFilter.filterTarget();
		String preAuthorizeAttribute = preAuthorize == null ? null : preAuthorize.value();
		String postFilterAttribute = postFilter == null ? null : postFilter.value();
		String postAuthorizeAttribute = postAuthorize == null ? null : postAuthorize
				.value();

		ArrayList<ConfigAttribute> attrs = new ArrayList<>(2);

		PreInvocationAttribute pre = attributeFactory.createPreInvocationAttribute(
				preFilterAttribute, filterObject, preAuthorizeAttribute);

		if (pre != null) {
			attrs.add(pre);
		}

		PostInvocationAttribute post = attributeFactory.createPostInvocationAttribute(
				postFilterAttribute, postAuthorizeAttribute);

		if (post != null) {
			attrs.add(post);
		}

		attrs.trimToSize();

		return attrs;
	}

当通过 getAttributes 获取到对应的鉴权注解后,通过 createPreInvocationAttribute 将注解的 value------一般是 el 表达式,解析成 Express 对象后封装起来等待后续取用。

java 复制代码
String preAuthorizeAttribute = preAuthorize == null ? null : preAuthorize.value();
PreInvocationAttribute pre = attributeFactory.createPreInvocationAttribute(
				preFilterAttribute, filterObject, preAuthorizeAttribute);
java 复制代码
public PreInvocationAttribute createPreInvocationAttribute(String preFilterAttribute,
			String filterObject, String preAuthorizeAttribute) {
		try {
			// TODO: Optimization of permitAll
			ExpressionParser parser = getParser();
			Expression preAuthorizeExpression = preAuthorizeAttribute == null ? parser
					.parseExpression("permitAll") : parser
					.parseExpression(preAuthorizeAttribute);
			Expression preFilterExpression = preFilterAttribute == null ? null : parser
					.parseExpression(preFilterAttribute);
			return new PreInvocationExpressionAttribute(preFilterExpression,
					filterObject, preAuthorizeExpression);
		}
		catch (ParseException e) {
			throw new IllegalArgumentException("Failed to parse expression '"
					+ e.getExpressionString() + "'", e);
		}
	}

AccessDecisionManager.decide -> AffirmativeBased.decide

说完了 getAttribute,让我们回到 beforeInvocation 方法的后续处理。你可能已经猜到了,和身份认证一样 filter 是不会实现实际的权限验证逻辑的。权限验证被交给了过滤器关联的 AccessDecisionManage.decide 方法来决定。

AccessDecisionManager 是一个接口,它的实现一共有 3 个类。AbstractAccessDecisionManager 是实现接口的抽象类,提供模板方法的定义。AffirmativeBased、ConsensusBased、UnanimousBased 是三个继承 AbstractAccessDecisionManager 的子类实现,分别代表了三种不同的记分策略。

以 AffirmativeBased 作为代表来说明的话,主要内容就是把 authentication 与封装的鉴权注解 el 表达式传递给关联的成员 decisionVoters 的 vote 方法进行处理。

java 复制代码
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();
	}

PreInvocationAuthorizationAdviceVoter.vote

和上面的架构设计一样, AccessDecisionVoter 的实现 PreInvocationAuthorizationAdviceVoter.vote 看起来像是要开始处理真正意义上的权限问题了,但是其实依然没到最核心的部分。

vote 方法会返回一个 preAdvice.before 的结果,这个结果会被转换成 ACCESS_GRANTED : ACCESS_DENIED 这两个数字。为什么 preAdvice.before 的调用不直接返回 boolean 类型的鉴权结果?想想之前的 AffirmativeBased.decide 方法的设计思路:根据多个不同的 voter 返回的结果,我们可以使用不同的投票策略来对分数进行判定。返回一个数字,而不是 boolean 更便于计算最终得分。

java 复制代码
public int vote(Authentication authentication, MethodInvocation method,
			Collection<ConfigAttribute> attributes) {

		// Find prefilter and preauth (or combined) attributes
		// if both null, abstain
		// else call advice with them

		PreInvocationAttribute preAttr = findPreInvocationAttribute(attributes);

		if (preAttr == null) {
			// No expression based metadata, so abstain
			return ACCESS_ABSTAIN;
		}

		boolean allowed = preAdvice.before(authentication, method, preAttr);

		return allowed ? ACCESS_GRANTED : ACCESS_DENIED;
	}

ExpressionBasedPreInvocationAdvice.before

这次我向你保证,ExpressionBasedPreInvocationAdvice.before 真的是专门负责判定权限校验是否通过的方法了。由于之前已经封装好了 el 表达式的 Express 对象,所以调用 ExpressionUtils.evaluateAsBoolean 就可以直接根据表达式与执行上下文 ctx -> WebSecurityExpressionRoot extends SecurityExpressionRoot 使用 getValue() 方法获取执行结果------即权限校验的结果。

java 复制代码
public boolean before(Authentication authentication, MethodInvocation mi,
			PreInvocationAttribute attr) {
		PreInvocationExpressionAttribute preAttr = (PreInvocationExpressionAttribute) attr;
		EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication,
				mi);
		Expression preFilter = preAttr.getFilterExpression();
		Expression preAuthorize = preAttr.getAuthorizeExpression();

		if (preFilter != null) {
			Object filterTarget = findFilterTarget(preAttr.getFilterTarget(), ctx, mi);

			expressionHandler.filter(filterTarget, preFilter, ctx);
		}

		if (preAuthorize == null) {
			return true;
		}

		return ExpressionUtils.evaluateAsBoolean(preAuthorize, ctx);
	}

举一反三一下,既然鉴权逻辑就是一个运行时的 el 表达式解析后的结果,那利用 el 表达式可以调用类方法的特性,是不是自定义一个专门的表达式处理程序就可以随心所欲地返回鉴权结果了呢?

自定义 EL 表达式

java 复制代码
    /**
     * sysdomain + edit permission
     */
    public static final String SYSDOMAIN_AND_EDITPERMISSION = SYSDOMAIN + " && " + EDIT_PERMISSION;

    public static final String EDIT_PERMISSION_MENU = "@authorizeAppService.hasPermission('EDIT_PERMISSION_MENU')";

自定义 EL 表达式处理方法

java 复制代码
public class AuthorizeAppService implements AuthorizeAppInterface {

    @Override
    public boolean hasDomain(String domain) throws IllegalArgumentException {
        if (StringUtils.isEmpty(domain)) {
            throw new IllegalArgumentException("hasDomain accepted a invalid express parameter");
        }
        AuthUser user = (AuthUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

        return StringUtils.equals(domain, user.getDomain().getCode());
    }

    @Override
    public boolean hasRole(String... role) throws IllegalArgumentException {
        if (ArrayUtils.isEmpty(role)) {
            throw new IllegalArgumentException("hasRole accepted a invalid express parameter\"");
        }

        AuthUser user = (AuthUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return user.getAuthRoles().stream().map(AuthRole::getCode).collect(Collectors.toList()).containsAll(CollectionUtils.arrayToList(role));
    }

    @Override
    public boolean hasPermission(String... permission) throws IllegalArgumentException {
        if (ArrayUtils.isEmpty(permission)) {
            throw new IllegalArgumentException("hasPermission accepted a invalid express parameter\"");
        }
        AuthUser user = (AuthUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        Set<String> permissions = user.getAllpermissions().stream().map(AuthPermission::getCode).collect(Collectors.toSet());
        return permissions.containsAll(CollectionUtils.arrayToList(permission));
    }
}

最后,我们来探讨一下 security 为什么要费尽周折,通过一长串的聚合和依赖把权限校验这个处理流程传递这么长的路径来实现?为什么不在一开始就是 make el Express and return el.value() 搞定所有的事情?

我想这是因为 security 是一个 architect first 的设计产物。架构优先的设计,高扩展性都特别的重要。这一系列的聚合和依赖,都是「用组合解决问题」思想的体现。尽量用组合来解决问题会带来了很多好处,比如通过 GlobalMethodSecurityConfiguration 类进行的高度可配置化。

java 复制代码
@Configuration(proxyBeanMethods = false)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class GlobalMethodSecurityConfiguration
implements ImportAware, SmartInitializingSingleton, BeanFactoryAware

{
	@Bean
	public MethodSecurityMetadataSource methodSecurityMetadataSource() {
		List<MethodSecurityMetadataSource> sources = new ArrayList<>();
		ExpressionBasedAnnotationAttributeFactory attributeFactory = new ExpressionBasedAnnotationAttributeFactory(
			getExpressionHandler());
		MethodSecurityMetadataSource customMethodSecurityMetadataSource = customMethodSecurityMetadataSource();
		if (customMethodSecurityMetadataSource != null) {
			sources.add(customMethodSecurityMetadataSource);
		}

		boolean hasCustom = customMethodSecurityMetadataSource != null;
		boolean isPrePostEnabled = prePostEnabled();
		boolean isSecuredEnabled = securedEnabled();
		boolean isJsr250Enabled = jsr250Enabled();

		if (!isPrePostEnabled && !isSecuredEnabled && !isJsr250Enabled && !hasCustom) {
			throw new IllegalStateException("In the composition of all global method configuration, " +
				"no annotation support was actually activated");
		}

		if (isPrePostEnabled) {
			sources.add(new PrePostAnnotationSecurityMetadataSource(attributeFactory));
		}
		if (isSecuredEnabled) {
			sources.add(new SecuredAnnotationSecurityMetadataSource());
		}
		if (isJsr250Enabled) {
			GrantedAuthorityDefaults grantedAuthorityDefaults =
			getSingleBeanOrNull(GrantedAuthorityDefaults.class);
			Jsr250MethodSecurityMetadataSource jsr250MethodSecurityMetadataSource = this.context.getBean(Jsr250MethodSecurityMetadataSource.class);
			if (grantedAuthorityDefaults != null) {
				jsr250MethodSecurityMetadataSource.setDefaultRolePrefix(
					grantedAuthorityDefaults.getRolePrefix());
			}
			sources.add(jsr250MethodSecurityMetadataSource);
		}
		return new DelegatingMethodSecurityMetadataSource(sources);
	}

}

异常处理

voter 与 advicer 只做他们的份内事------对鉴权结果投票并返回。不同的得分策略,决定了同样的分数在不同的策略下,可能会有不同的最终结果。

所以 AffirmativeBased.decide 如果得出了鉴权失败的结论的话就会抛出一个 AccessDeniedException 异常。这个异常会一直沿着调用链往上抛,直到抛到 FilterSecurityIntercptor 的调用者,ExceptionTranslationFilter 中。

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

ExceptionTranslationFilter 过滤器专门处理和异常相关的事情。它的 doFilter 中定义的 AccessDeniedException 捕获处理会将异常处理交由 accessDeniedHandler.handle 处理。同时得益于组合的设计思想,accessDeniedHandler.handle 也是可配置化的。

回忆之前的自定义身份验证异常处理类 GlobalExceptionHandler。现在只要增加一个新的接口实现并重写 handle 方法,就可以在一个类里面处理身份验证和鉴权异常了。

java 复制代码
private void handleSpringSecurityException(HttpServletRequest request,
	HttpServletResponse response, FilterChain chain, RuntimeException exception)
throws IOException, ServletException {
	if (exception instanceof AuthenticationException) {
		logger.debug(
			"Authentication exception occurred; redirecting to authentication entry point",
			exception);

		sendStartAuthentication(request, response, chain,
			(AuthenticationException) exception);
	}
	else if (exception instanceof AccessDeniedException) {
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
			logger.debug(
				"Access is denied (user is " + (authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point",
				exception);

			sendStartAuthentication(
				request,
				response,
				chain,
				new InsufficientAuthenticationException(
					messages.getMessage(
						"ExceptionTranslationFilter.insufficientAuthentication",
						"Full authentication is required to access this resource")));
		}
		else {
			logger.debug(
				"Access is denied (user is not anonymous); delegating to AccessDeniedHandler",
				exception);

			accessDeniedHandler.handle(request, response,
				(AccessDeniedException) exception);
		}
	}
}
java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler implements AuthenticationEntryPoint, AccessDeniedHandler {

    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
        log.error("spring security 认证发生异常。", e);
        HttpResponseWriter.sendError(httpServletResponse, HttpServletResponse.SC_UNAUTHORIZED, "身份认证失败");
    }

    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException {
        log.error("spring security 鉴权发生异常。", e);
        HttpResponseWriter.sendError(httpServletResponse, HttpServletResponse.SC_FORBIDDEN, "鉴权失败");
    }
}
相关推荐
知识分享小能手1 分钟前
Java学习教程,从入门到精通,Java switch语句语法知识点(14)
java·开发语言·python·学习·javaee·大数据开发·java大数据
是程序喵呀3 分钟前
idea 创建java文件增加注释
java·ide·intellij-idea
花心蝴蝶.5 分钟前
Thread类及线程的核心操作
java·jvm·windows
苹果醋311 分钟前
springboot-springboot官方文档架构
java·运维·spring boot·mysql·nginx
knoci17 分钟前
【Go】-基于Gin框架的博客项目
后端·学习·golang·gin
wrx繁星点点30 分钟前
原型模式:高效的对象克隆解决方案
数据结构·spring·spring cloud·java-ee·maven·intellij-idea·原型模式
sun金默41 分钟前
java后端把数据转换为树,map递归生成json树,返回给前端(后台转换)
spring·intellij-idea
Devil枫1 小时前
腾讯云云开发深度解读:云数据库、云模板与AI生成引用的魅力
数据库·人工智能·腾讯云
马剑威(威哥爱编程)1 小时前
Java如何实现PDF转高质量图片
java·开发语言·pdf·1024程序员节
Kanna_STELLA1 小时前
Oracle视频基础1.1.4练习
数据库·oracle