![cover](https://i-blog.csdnimg.cn/direct/eecbe6146a70422aa249a3894b4d6053.png)
SpringSecurity定制化开发(三)授权验证
FilterSecurityInterceptor继承自AbstractSecurityInterceptor抽象类,位于ss过滤器链的末尾,承担了权限控制的最后一步重任:授权验证。AbstractSecurityInterceptor定义了授权校验的主流程,并提供了多个扩展点方法。大部分应用场景下,开发人员都无须重写整个鉴权流程,继承AbstractSecurityInterceptor或Fil
权限框架对请求的处理分为两个阶段:身份验证和授权验证,身份验证判定请求发起者是否是系统的合法账户,而授权验证则是判定请求发起者是否具备访问当前资源的权限。本文主要讲述后者的基本原理及其扩展实现。
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 框架要求至少设置一个表达式规则。
更多推荐
所有评论(0)