权限框架对请求的处理分为两个阶段:身份验证和授权验证,身份验证判定请求发起者是否是系统的合法账户,而授权验证则是判定请求发起者是否具备访问当前资源的权限。本文主要讲述后者的基本原理及其扩展实现。

        SpringSecurity框架(后文简称ss)中,身份验证由多种类型的登录请求过滤器依次处理完成,授权验证则是由 FilterSecurityInterceptor 过滤器完成。作为 ss 框架中最重要的过滤器之一,FilterSecurityInterceptor 提供了众多切面方法,并内置了 ss 框架的大部分关键组件:AuthenticationManager、AccessDecisionManager、SecurityMetadataSource等,这些都极大提升了授权验证的扩展性,同时为框架使用者设计复杂的鉴权方案提供了便利。

        本文首先讲解 FilterSecurityInterceptor 的处理流程以及可扩展功能点。然后通过样例介绍一个扩展性较高的鉴权方案。

        本文以JDK1.8,SpringSecurity 5.7.11 作为开发环境进行讲解。

一、源码概述

        FilterSecurityInterceptor 继承自 AbstractSecurityInterceptor 抽象类,位于 ss 过滤器链的末尾,承担了权限控制的最后一步重任:授权验证。

        AbstractSecurityInterceptor 定义了授权校验的主流程,并提供了多个扩展点方法。大部分应用场景下,开发人员都无须重写整个鉴权流程,继承 AbstractSecurityInterceptor 或 FilterSecurityInterceptor 是更好的选择。下图描述了鉴权过滤器处理的完整流程。

        上图中的授权验证(标黄进程)是 FilterSecurityInterceptor 的核心步骤,授权验证须要两类数据:用户已授予权限(GrantedAuthority)、访问资源所需权限(ConfigAttribute),RBAC模型中可以简单的理解为用户已分配角色和访问资源所须角色。授权处理器(AccessDecisionManager)采用特定策略比对这两项数据,如满足条件则请求放行。

        GrantedAuthority 和 ConfigAttribute 分别由令牌(Authentication)和权限元数据(SecurityMetadataSource)两个组件提供,而令牌通常是在用户身份验证时创建的,由此看来,授权验证虽然是在最后一个过滤器中进行,但相关验证数据的获取要追溯到更早的过滤器组件。

        通过下图可以了解 ss 框架鉴权过程中校验数据的产生和走向。

二、实现目标

        下面是本文要实现的功能列表,后面章节会详细讲述各项功能的特性:

  • 支持动态刷新权限配置;
  • 支持用户身份模拟;
  • 支持角色 + 组的组合授权策略;
  • 自定义 AccessDecisionManager 及 Voter

        下面是本文例子的数据库表设计(仅截取关键字段),从设计模型可知,这是一个以角色+组为验证对象的鉴权方案,请求用户必须既符合特定角色又从属于特定组才能授权成功。

        须要说明的是,开发过程中应当根据实际业务选择验证对象。如果系统的角色、组线上编辑很少,则可以采用角色或组作为验证对象;反之,则应选择其它变化较少的模型(例如资源)作为验证对象。这是因为 ss 框架的默认实现中,权限配置是一次性加载的,如果权限配置发生了变动,要么重新启动服务,要么采用动态刷新权限元数据的技术,前者不适合大多数生产环境,后者设计实现较为复杂。

        如果以资源作为验证对象,意味着令牌组件和权限元数据组件提供的不再是角色或组,而是资源,投票器也应修改为以资源为比对目标的业务逻辑。读者在掌握了第三章的内容后,就可以轻松的实现以资源为验证对象的鉴权方案。

三、功能实现讲解

        在单点服务中,在线修改并动态刷新权限配置没有太多意义,因为如果功能发生了变动,服务也要重新编译部署。而采用分布式或微服务架构的系统,业务接口服务和权限配置服务通常是不同的服务节点,借助动态路由和微前端技术,完全可以实现动态刷新权限配置,从而降低授权验证功能对权限配置服务的依赖。

1. 基础组件改造

        改造授权过滤器,首先要定制化 GrantedAuthority 和 ConfigAttribute,既然本文中的权限配置对象分为两类:角色、组,那就分别创建对应的扩展类。

package net.kebin.auth.model.bo;

import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.core.GrantedAuthority;

/**
 * 组策略
 */
public class AuthGroup implements GrantedAuthority, ConfigAttribute {
	
	private Long groupId;		// 对应组数据表中的主键id
	
	public AuthGroup(Long groupId) {
		if (groupId == null) {
			throw new IllegalArgumentException("AuthGroup创建失败, 参数为空");
		}
		this.groupId = groupId;
	}

	@Override
	public String getAttribute() {
		return String.valueOf(groupId);
	}

	@Override
	public String getAuthority() {
		return String.valueOf(groupId);
	}
	
	public boolean equals(Object obj) {
		return obj instanceof AuthGroup && getAttribute().equals(((AuthGroup)obj).getAttribute());
	}
}
package net.kebin.auth.model.bo;

import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.core.GrantedAuthority;

/**
 * 角色策略
 */
public class AuthRole implements GrantedAuthority, ConfigAttribute {

	private Long roleId;	// 对应角色数据表中的主键id

	public AuthRole(Long roleId) {
		if (roleId == null) {
			throw new IllegalArgumentException("AuthRole创建失败, 参数为空");
		}
		this.roleId = roleId;
	}

	public String getAuthority() {
		return String.valueOf(roleId);
	}

	public String getAttribute() {
		return String.valueOf(roleId);
	}

	@Override
	public boolean equals(Object obj) {
		return obj instanceof AuthRole && getAttribute().equals(((AuthRole)obj).getAttribute());
	}
}

注: 上述两个类同时继承了 GrantedAuthority 和 ConfigAttribute 接口,并重写了equals方法,这是为了方便后续流程中鉴权对象的比对。

        UserDetails 作为提供用户已授权数据的接口,也需要做一些改动——增加了从用户VO对象中解析并封装角色配置、组配置的过程(这里假设系统实现的 UserDetailsService.loadUserByUsername 方法已经查询出了用户关联的角色及组信息,这些内容本文不做展现)。

package net.kebin.auth.model.bo;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

import org.apache.commons.collections4.CollectionUtils;
import org.joda.time.DateTime;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import net.kebin.auth.model.vo.UserVO;

/**
 * 登录账户信息
 */
public class Licence implements UserDetails {

	private final UserVO account;    // 用户VO对象

	private List<GrantedAuthority> userAuthorityList = new ArrayList<>();    // 权限配置

	public Licence(UserVO user) {
		if (user == null) {
			throw new IllegalArgumentException("Licence创建失败, 参数为空");
		}
		this.account = user;
		initAuthority();
	}

    ...

	@Override
	public Collection<GrantedAuthority> getAuthorities() {
		return userAuthorityList;
	}

    // 初始化权限配置, 遍历用户VO对象中的角色、组列表封装为权限配置
	private void initAuthority() {
		if (CollectionUtils.isNotEmpty(account.getRoleList())) {
			userAuthorityList.addAll(account.getRoleList().stream().map(role -> new AuthRole(role.getId())).collect(Collectors.toList()));
		}
		if (CollectionUtils.isNotEmpty(account.getGroupList())) {
			userAuthorityList.addAll(account.getGroupList().stream().map(group -> new AuthGroup(group.getId())).collect(Collectors.toList()));
		}
	}
}

        最后须要改造的是令牌实现类,令牌作为 AccessDecisionMananger 处理的对象之一,已提供了访问用户权限配置的方法,所以只要简单的封装 Licence 就可以了。

package net.kebin.auth.model.bo;

import java.util.Collection;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

/**
 * 令牌实现
 */
public class LicenceAuthentication extends AbstractAuthenticationToken {

	private Licence licence;    // 用户信息
	
	public LicenceAuthentication(Licence licence) {
		super(licence.getAuthorities());    // 初始化权限配置
		
		this.licence = licence;
	}
	
	public Licence getLicence() {
		return licence;
	}

	@Override
	public Object getCredentials() {
		return licence.getPassword();
	}

	@Override
	public Object getPrincipal() {
		return licence.getUsername();
	}
}

        除了上面这些基础组件,还须提供查询资源以及资源对应角色、组的业务接口,用于后续 ss 框架初始化时加载全部权限配置。这项功能与系统具体业务牵涉较深,因此本文不展示具体实现类,只展示接口及其方法,下面代码中的注释说明了每个接口方法的用途。

package net.kebin.auth.api;

import java.util.List;
import java.util.Map;

import net.kebin.auth.model.request.PermissionListRequest;
import net.kebin.auth.model.vo.PermissionVO;

/**
 * 资源管理接口
 */
public interface PermissionApi {
	
	/**
	 * 查询全部资源列表
	 * 
	 * @param request
	 * @return
	 */
	List<PermissionVO> listDetail(PermissionListRequest request);
	
	/**
	 * 查询角色资源映射关系
	 * 
	 * @param request
	 * @return Map<资源id: List<访问所须角色id>>
	 */
	Map<Long, List<Long>> listRoleMapping(PermissionListRequest request);
	
	/**
	 * 查询组资源映射关系
	 * 
	 * @param request
	 * @return Map<资源id: List<访问所须组id>>
	 */
	Map<Long, List<Long>> listGroupMapping(PermissionListRequest request);
}

        自此,基础组件和接口的定制就完成了,从下一节开始,逐步讲述核心组件的定制化。

2. 权限元数据

        SecurityMetadataSource 是用来装载权限配置的组件,框架中的默认实现为ExpressionBasedFilterInvocationSecurityMetadataSource,这是一种通过表达式来描述权限配置的方式,下面的例子描述了这种配置方式。        

http.authorizeHttpRequests()
	.antMatchers("/css/**", "js/**", "/images/**", "/font/**", "/favicon.ico", "/error", "/login").permitAll()
	.antMatchers("/admin/**").hasAnyRole("Admin")
	.anyRequest().authenticated();

        基于表达式的配置方式非常方便,但是它只提供了少量的表达式定语如hasRole、hasAnyRole、hasAuthority、permitAll、anonymous等,没法处理复杂的授权认证逻辑;此外,通过 ExpressionUrlAuthorizationConfigurer 创建的 SecurityMetadataSource 是一个只读对象,无法动态修改内部的权限配置项。

        下面是权限元数据的扩展实现(支持动态加载资源权限配置):

package net.kebin.admin.config.ss.component;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;

import javax.servlet.http.HttpServletRequest;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;

import lombok.extern.slf4j.Slf4j;
import net.kebin.auth.api.PermissionApi;
import net.kebin.auth.model.bo.AuthGroup;
import net.kebin.auth.model.bo.AuthRole;
import net.kebin.auth.model.enums.PermissionTypeEnum;
import net.kebin.auth.model.request.PermissionListRequest;
import net.kebin.auth.model.vo.PermissionVO;

@Slf4j
public class CompoundFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource, SecurityMetadataSourceReloader, InitializingBean {

	@Autowired
	private PermissionApi permissionApi;
	
	private AtomicBoolean reloading = new AtomicBoolean(false);
	
	private final AuthRole COMMON_ROLE = new AuthRole(-1L);		// 通用角色, 如资源赋予该角色则所有请求皆可访问
	
	private Map<RequestMatcher, Collection<ConfigAttribute>> roleAttributeMap;    // 资源角色映射关系
	
	private Map<RequestMatcher, Collection<ConfigAttribute>> groupAttributeMap;    // 资源组映射关系
	
    // 根据请求获取所须权限配置, 只需将匹配资源对应的组、角色配置项合并即可
	@Override
	public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
		final HttpServletRequest request = ((FilterInvocation) object).getRequest();
		Set<ConfigAttribute> attributes = new HashSet<>();
        // 此处须注意Map必须是有序的,且满足从特别到通配的优先级顺序 !!!
		for (Map.Entry<RequestMatcher, Collection<ConfigAttribute>> entry : this.roleAttributeMap.entrySet()) {
			if (entry.getKey().matches(request)) {
				attributes.addAll(entry.getValue());
				break;
			}
		}
		for (Map.Entry<RequestMatcher, Collection<ConfigAttribute>> entry : this.groupAttributeMap.entrySet()) {
			if (entry.getKey().matches(request)) {
				attributes.addAll(entry.getValue());
				break;
			}
		}
		if (attributes.size() == 0) {
			log.trace("未找到指定资源的许可权限配置, request={}", request.getRequestURI());
		}
		return attributes;
	}

	@Override
	public Collection<ConfigAttribute> getAllConfigAttributes() {
		Set<ConfigAttribute> allAttributes = new HashSet<>();
		this.roleAttributeMap.values().forEach(allAttributes::addAll);
		this.groupAttributeMap.values().forEach(allAttributes::addAll);
		return allAttributes;
	}

	@Override
	public boolean supports(Class<?> clazz) {
		return FilterInvocation.class.isAssignableFrom(clazz);
	}

	@Override
	public void afterPropertiesSet() throws Exception {
		reload();
	}
	
	public void reload() throws Exception {
		if (!reloading.compareAndSet(false, true)) {
			log.info("权限数据正在加载...");
			return;
		}
		try {
			loadAttruibutes();			
			log.info("权限数据加载成功.");
		}
		catch (Exception e) {
			log.error("权限数据加载失败.", e);
            // 初次加载失败, 应抛出异常		
			if (this.roleAttributeMap == null || this.groupAttributeMap == null) {
				throw e;
			}
		}
		finally {
			reloading.set(false);
		}
	}
	
    // 重新加载权限配置
	protected void loadAttruibutes() throws Exception {
        // 此处应初始化为有序Map
		Map<RequestMatcher, Collection<ConfigAttribute>> roleTmpMap = new LinkedHashMap<>();
		Map<RequestMatcher, Collection<ConfigAttribute>> groupTmpMap = new LinkedHashMap<>();
		// 公共资源处理, 统一赋予 COMMON_ROLE 角色
		String[] permits = new String[] { "/css/**", "js/**", "/images/**", "/font/**", "/favicon.ico", "/error", "/login" };
		if (permits != null && permits.length > 0) {
			Arrays.stream(permits).forEach(url -> {
				roleTmpMap.put(new AntPathRequestMatcher(url), Collections.singletonList(COMMON_ROLE));
			});
		}
		
		// 查询所有Url资源
		List<PermissionVO> permissionList = permissionApi.listDetail(PermissionListRequest.builder().type(PermissionTypeEnum.API).build());
		if (CollectionUtils.isNotEmpty(permissionList)) {
			PermissionListRequest mappingRequest = PermissionListRequest.builder().idList(permissionList.stream().map(PermissionVO::getId).collect(Collectors.toList())).build();
			// 查询角色映射关系
			Map<Long, List<Long>> roleMap = permissionApi.listRoleMapping(mappingRequest);
			// 查询组映射关系
			Map<Long, List<Long>> groupMap = permissionApi.listGroupMapping(mappingRequest);
			// 组装权限配置项
			for (PermissionVO vo : permissionList) {
				if (StringUtils.isBlank(vo.getUrl())) {
					continue;
				}
				RequestMatcher matcher = new AntPathRequestMatcher(vo.getUrl());
				// 添加角色设置
				List<ConfigAttribute> authList = new ArrayList<>();
				List<Long> roleIdList = roleMap.get(vo.getId());
				if (CollectionUtils.isNotEmpty(roleIdList)) {
					authList.addAll(roleIdList.stream().map(id -> new AuthRole(id)).collect(Collectors.toList()));
				}
				roleTmpMap.put(matcher, authList);
				// 添加组设置
				List<Long> groupIdList = groupMap.get(vo.getId());
				if (CollectionUtils.isNotEmpty(groupIdList)) {
					groupTmpMap.put(matcher, groupIdList.stream().map(id -> new AuthGroup(id)).collect(Collectors.toList()));
				}
			}
		}
		this.roleAttributeMap = Collections.unmodifiableMap(roleTmpMap);
		this.groupAttributeMap = Collections.unmodifiableMap(groupTmpMap);
	}
}

        下面是重载器接口的源码

package net.kebin.admin.config.ss.component;

/**
 * 权限配置重载器
 */
public interface SecurityMetadataSourceReloader {
	
	/**
	 * 重载权限配置
	 * 
	 * @throws Exception
	 */
	void reload() throws Exception;

}

注:须要着重强调的是,资源的加载顺序会影响到授权验证的结果。如果资源中存在易混淆的路径配置(如 /user/update 和 /user/**),就必须小心处理,应当将更通配的资源放置在后面。常用的做法是在数据库中设置优先级字段,查询时按url字符串和优先级两个字段进行排序,以此来保障加载顺序是准确的。

        下面是一个测试接口,用于在线刷新系统所有权限配置

package net.kebin.admin.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import net.kebin.admin.config.ss.component.SecurityMetadataSourceReloader;
import net.kebin.base.common.api.BaseResponse;
import net.kebin.base.common.api.builder.ResponseBuilder;

@ResponseBody
@RestController
@RequestMapping("/system")
public class SystemController {
	
	@Autowired
	private SecurityMetadataSourceReloader reloader;
	
	@PostMapping("/security-reload")
	public BaseResponse<Boolean> create() throws Exception {
		reloader.reload();
		return ResponseBuilder.create().build(true);
	}
}

        重新加载权限配置是一件非常危险的操作,一不小心就会影响所有已登录用户的校验,因此须要精心的设计和大量的测试。本文的例子比较简单,仅仅用作演示。实际开发中如需实现此类功能,应预先了解该功能对授权流程的影响范围,尽量选择操作影响范围较小的设计方案(例如:仅允许新增资源配置,不允许更新或删除等操作),还须避免拦截器链上多个过滤器之间共用权限元数据的同步问题(尽量将权限元数据的访问限制在 FilterSecurityInterceptor 一个过滤器内),最后须要解决的是因并发操作引起的问题。

3. 授权决策管理器和投票器

        本文第二章提到的授权检验策略,其具体需求如下:

  • 用户必须同时通过角色和组两种策略投票验证;
  • 如资源对应多个角色(即资源被授予多个角色)则只须满足任意一个即通过,组亦是如此;
  • 如资源对应角色为空,则角色检测默认通过,组亦是如此;
  • 如资源对应角色和组都为空,则整个检测不通过。

        上面的要求看起来是一个or、and组合的验证,ss 框架内置的三种授权决策管理器都无法实现这项检测,因此须要重新设计一种决策管理器,代码如下所示。

package net.kebin.admin.config.ss.component;

import java.util.Collection;
import java.util.List;

import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.vote.AbstractAccessDecisionManager;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;

public class DefaultAccessDecisionManager extends AbstractAccessDecisionManager {

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

	@Override
	@SuppressWarnings({ "rawtypes", "unchecked" })
	public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
        // 只要有一个投票器未通过,则授权验证失败
		for (AccessDecisionVoter voter : getDecisionVoters()) {
			int result = voter.vote(authentication, object, configAttributes);
			switch (result) {
			case AccessDecisionVoter.ACCESS_DENIED:
				throw new AccessDeniedException("请求被拒接");
			case AccessDecisionVoter.ACCESS_GRANTED:
				continue;
			default:
				continue;
			}
		}
	}

}

        下面是两个投票器(角色、组)的实现,两者的逻辑非常相似,只是角色投票器包含了公共角色的验证(公共资源的访问是通过一个虚拟角色来实现的,可参看上一节源码)

package net.kebin.admin.config.ss.component;

import java.util.Collection;
import java.util.stream.Collectors;

import org.apache.commons.collections4.CollectionUtils;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.FilterInvocation;

import net.kebin.auth.model.bo.AuthRole;

/**
 * 角色投票器
 */
public class DefaultRoleVoter implements AccessDecisionVoter<FilterInvocation> {

	@Override
	public boolean supports(ConfigAttribute attribute) {
		return attribute instanceof AuthRole;
	}

	@Override
	public boolean supports(Class<?> clazz) {
		return FilterInvocation.class.isAssignableFrom(clazz);
	}

	@Override
	public int vote(Authentication auth, FilterInvocation fi, Collection<ConfigAttribute> attributes) {
		if (auth == null || auth.getPrincipal() == null || CollectionUtils.isEmpty(attributes)) {
			return -1;
		}
		// 过滤非角色策略
		Collection<AuthRole> roleAttributes = attributes.stream().filter(attr -> attr instanceof AuthRole).map(attr -> (AuthRole)attr).collect(Collectors.toList());
		// 如果资源未设定任何可访问角色, 说明请求最终是由组投票器判断, 此时角色投票器投通过票
		if (roleAttributes.size() == 0) {
			return 1;
		}
		// 公共资源投通过票
		boolean existCommonRole = roleAttributes.stream().anyMatch(attr -> "-1".equals(attr.getAttribute()));
		if (existCommonRole) {
			return 1;
		}
        // 用户未分配任何角色或组, 直接投不通过
		if (CollectionUtils.isEmpty(auth.getAuthorities())) {
			return -1;
		}
		// 比对用户分配角色和资源所要求角色, 满足任意一项投通过票
		for (AuthRole caRole : roleAttributes) {
			for (GrantedAuthority ga : auth.getAuthorities()) {
				if (ga instanceof AuthRole) {
					AuthRole gaRole = (AuthRole)ga;
					if (gaRole.equals(caRole)) {
						return 1;
					}
				}
			}
		}
		return -1;
	}
}
package net.kebin.admin.config.ss.component;

import java.util.Collection;
import java.util.stream.Collectors;

import org.apache.commons.collections4.CollectionUtils;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.FilterInvocation;

import net.kebin.auth.model.bo.AuthGroup;

/**
 * 组投票器
 */
public class DefaultGroupVoter implements AccessDecisionVoter<FilterInvocation> {

	@Override
	public boolean supports(ConfigAttribute attribute) {
		return attribute instanceof AuthGroup;
	}

	@Override
	public boolean supports(Class<?> clazz) {
		return FilterInvocation.class.isAssignableFrom(clazz);
	}

	@Override
	public int vote(Authentication auth, FilterInvocation fi, Collection<ConfigAttribute> attributes) {
		if (auth == null || auth.getPrincipal() == null || CollectionUtils.isEmpty(attributes)) {
			return -1;
		}
		// 过滤非组策略
		Collection<AuthGroup> groupAttributes = attributes.stream().filter(attr -> attr instanceof AuthGroup).map(attr -> (AuthGroup)attr).collect(Collectors.toList());
		// 如果资源未设定任何可访问组, 说明请求最终是由角色投票器判断, 此时组投票器投通过票
		if (groupAttributes.size() == 0) {
			return 1;
		}
        // 用户未分配任何角色或组, 直接投不通过
		if (CollectionUtils.isEmpty(auth.getAuthorities())) {
			return -1;
		}
		// 比对用户分配组和资源所要求组, 满足任意一项投通过票
		for (AuthGroup caGroup : groupAttributes) {
			for (GrantedAuthority ga : auth.getAuthorities()) {
				if (ga instanceof AuthGroup) {
					AuthGroup gaGroup = (AuthGroup)ga;
					if (gaGroup.equals(caGroup)) {
						return 1;
					}
				}
			}
		}
		return -1;
	}
}

        授权决策管理器的配置Bean如下(须手动设置到 FilterSecurityInterceptor 过滤器中,完整配置在第四章)

    @Bean
    public AccessDecisionManager accessDecisionManager() {
        List<AccessDecisionVoter<?>> voterList = new ArrayList<AccessDecisionVoter<?>>();
        voterList.add(new DefaultRoleVoter());
        voterList.add(new DefaultGroupVoter());
        return new DefaultAccessDecisionManager(voterList);
    }
4. 用户身份模拟

        有时会遇到这样的需求:新注册的用户7日内自动加入“模拟组”,从而有机会去试用部分系统功能。常见的处理方法是直接修改用户登录时的业务逻辑,但这种做法的第一个不利之处是虚拟身份的作用范围太广,从登录直至注销,虚拟身份一直附加在令牌上,安全性不高;另一点则是侵入了登录的原有业务逻辑,一旦需求变化还得再次修改,代码的复用性不高。下面要介绍的 RunAsManager 就是专门用来应对这种情况的组件。

        RunAsManager 作为 FilterSecurityInterceptor 过滤器的内置组件,其本质是一个后置切面,它在 AccessDecisionMananger 后面执行,执行方法返回的令牌对象作为临时令牌替换当前上下文的原有令牌,这使得用户能够以新的身份去完成当前请求的后续处理。如果返回为空,说明当前用户无需做身份转换,仍持原有令牌完成后续处理。

        下面是模拟组身份转换器的实现:

package net.kebin.admin.config.ss.component;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import javax.servlet.http.HttpServletRequest;

import org.joda.time.DateTime;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.intercept.RunAsManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import net.kebin.auth.model.bo.AuthGroup;
import net.kebin.auth.model.bo.AuthRole;
import net.kebin.auth.model.bo.Licence;
import net.kebin.auth.model.bo.LicenceAuthentication;
import net.kebin.auth.model.constant.AuthConstants;
import net.kebin.auth.model.vo.UserVO;

/**
 * 身份转换器
 */
public class RunAsSimulatorManager implements RunAsManager {
	
	private List<AntPathRequestMatcher> matcherList = new ArrayList<>();
	
	public RunAsSimulatorManager() {
		matcherList.add(new AntPathRequestMatcher("/advertise/**"));
		matcherList.add(new AntPathRequestMatcher("/product/**"));
		matcherList.add(new AntPathRequestMatcher("/logistics/**"));
	}

	@Override
	public Authentication buildRunAs(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
        // 如果是付费用户, 不需要做身份转换
		boolean isSubscriber = authentication.getAuthorities().stream().anyMatch(granted -> granted instanceof AuthGroup && AuthConstants.SUBSCRIBER_GROUP_ID.toString().equals(granted.getAuthority()));
		if (isSubscriber) {
			return null;
		}
        final HttpServletRequest request = ((FilterInvocation) object).getRequest();
        // 判断当前请求是否支持试用,如不支持则不做身份转换
		boolean isSimulateSupported = matcherList.stream().anyMatch(matcher -> matcher.matches(request));
		if (!isSimulateSupported) {
			return null;
		}
        // 为获取用户注册时间,转换令牌类型
		if (!(authentication instanceof LicenceAuthentication)) {
		    throw new AccessDeniedException("非法令牌类型");
		}
		LicenceAuthentication token = (LicenceAuthentication)authentication;
		Licence licence = token.getLicence();
		UserVO user = licence.getAccount();
		// 判断用户注册是否已过7天
		boolean isExpired = new DateTime(user.getCreateTime()).plusDays(7).isBeforeNow();
		if (!isExpired) {
			UserVO simulator = BeanUtils.copy(user, UserVO.class);
			Licence newLicence = new Licence(simulator);
            // 添加模拟组身份,用于后续接口中判断是否调用模拟接口服务
			newLicence.getAuthorities().add(new AuthGroup(AuthConstants.SIMULATOR_GROUP_ID));
            // 由于请求结束后上下文会恢复原有令牌,因此不要在原有令牌上做修改,应创建一个新的令牌
			LicenceAuthentication newAuth = new LicenceAuthentication(newLicence);
			newAuth.setDetails(token.getDetails());
			return newAuth;
		}
        // 不是付费用户且过了试用器,抛出异常禁止访问
		throw new AccessDeniedException("试用期已过");
	}

	@Override
	public boolean supports(ConfigAttribute attribute) {
		return attribute instanceof AuthRole;
	}

	@Override
	public boolean supports(Class<?> clazz) {
		return true;
	}
}

        下面是一个测试接口,根据上下文环境中的令牌是否包含“模拟组”来选择接口服务。本文为了展示代码简便,直接在 Controller 中做了服务调用判断;在实际开发中,通过 Springmvc 拦截器统一处理是更好的方式;如果系统采用微服务架构,也可通过对请求添加相应头信息,再由网关服务针对请求头做统一处理。

package net.kebin.admin.controller;

import javax.validation.Valid;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import net.kebin.auth.model.bo.AuthGroup;
import net.kebin.auth.model.bo.LicenceAuthentication;
import net.kebin.auth.model.constant.AuthConstants;
import net.kebin.base.common.api.PageDataResponse;
import net.kebin.books.api.AdvLinkApi;
import net.kebin.books.model.po.AdvLink;
import net.kebin.books.model.request.AdvLinkListRequest;

@ResponseBody
@RestController
@RequestMapping("/advertise")
public class AdvertiseController {
	
	@Autowired
	private AdvLinkApi advLinkApi;
	
	@Autowired
	private AdvLinkApi simulatorAdvLinkApi;
	
	@PostMapping("/list")
	public PageDataResponse<AdvLink> list(@RequestBody @Valid AdvLinkListRequest request) {
		Authentication auth = SecurityContextHolder.getContext().getAuthentication();
		LicenceAuthentication licenceAuth = (LicenceAuthentication)auth;
        // 判断当前用户是否包含模拟组身份
		boolean isSimulator = licenceAuth.getAuthorities().stream().anyMatch(granted -> granted instanceof AuthGroup && AuthConstants.SIMULATOR_GROUP_ID.toString().equals(granted.getAuthority()));
		if (isSimulator) {
			return simulatorAdvLinkApi.list(request);
		}
		else {
			return advLinkApi.list(request);
		}
	}
}

        使用RunAsManager应注意以下几点

1. RunAsManager 执行时机在 AccessDecisionManager 执行后,且执行结果必须为成功(未抛出AccessDenyException),因此用户首先应具备访问该资源的权限,才有机会做身份转换处理,否则流程未走到这一步就抛出异常跳转到异常处理过滤器了;

2. 当前请求处理结束后,SecurityContextHolder 会重置当前上下文(SecurityContext)—— 新令牌也会随之复原为原令牌,因此用户的新身份维持的时间很短,仅在当前请求有效;

3. 身份转换的本质是生成新的令牌,不应当在原令牌(或其内置属性)上做修改然后直接返回,这样会破环框架本身的安全机制,导致令牌的改动一直持续到注销或令牌过期。

4. RunAsManager 作用在单次请求上,因此应尽量缩小请求适用范围,如上例所示,组件仅在满足三个路径匹配器的条件下才会发挥作用。

四、配置

        下面是配置类代码(仅截取了和本文相关的部分)

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

	@Bean
    @SuppressWarnings("unchecked")
	public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationConfiguration config) throws Exception {
		// 先通过Configurer的方式创建 FilterSecurityInterceptor, 然后替换SecurityMetadataSource实现
		http.authorizeRequests().anyRequest().denyAll().withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
			public FilterSecurityInterceptor postProcess(FilterSecurityInterceptor interceptor) {
				interceptor.setSecurityMetadataSource(securityMetadataSource());
				interceptor.setAccessDecisionManager(accessDecisionManager());
				interceptor.setRunAsManager(runAsManager());
				interceptor.setRejectPublicInvocations(true);    // 禁止访问公共资源
				return interceptor;
			}
		});
		
        ...
    }
    
    @Bean
    public CompoundFilterInvocationSecurityMetadataSource securityMetadataSource() {
    	return new CompoundFilterInvocationSecurityMetadataSource();
    }
    
    @Bean
    public RunAsManager runAsManager() {
    	return new RunAsSimulatorManager();
    }    
    
    @Bean
    public AccessDecisionManager accessDecisionManager() {
        List<AccessDecisionVoter<?>> voterList = new ArrayList<AccessDecisionVoter<?>>();
        voterList.add(new DefaultRoleVoter());
        voterList.add(new DefaultGroupVoter());
        return new DefaultAccessDecisionManager(voterList);
    }

    ...
}

注:代码中的 http.authorizeRequests().anyRequest().denyAll() 这一句没有太多意义,这是因为,尽管例子是通过 Configurer 的方式生成了 FilterSecurityInterceptor 过滤器实例及权限元数据组件(过滤器创建时默认初始化内置 securityMetadataSource 属性为 ExpressionBasedFilterInvocationSecurityMetadataSource 实例),但后置处理器中将其替换为自定义的权限元数据实现。之所以要加一个denyAll 方法,仅仅是因为 ss 框架要求至少设置一个表达式规则。

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐