从源码角度拆解SpringSecurity系列文章
从源码角度拆解SpringSecurity之核心流程
从源码角度拆解SpringSecurity之万能的SecurityBuilder
从源码角度拆解SpringSecurity之勤劳的FilterChainProxy
从源码角度拆解SpringSecurity之C位的AuthenticationManager
文章目录
前言
上一篇我们简单拆解了运行时的大致流程,知道了拦截动作都来自于FilterChainProxy,以及内部的Filter链怎么来的,顺序的重要性,以及如何分发的,还针对所学习内容介绍了一个简单但实际的小需求——如何自定义异常返回数据。本章我们将基于框架中处于C位的AuthenticationManager为切入点,对框架的认证和授权两个核心步骤进行拆解以及数据权限校验框架的实现。
注意
文章所摘源码版本为spring-boot-starter-security:5.0.6.RELEASE
、spring-security-oauth2:2.3.5.RELEASE
一、C位的AuthenticationManager
贯穿整个框架,我们随处可见各种Token,比如PreAuthenticatedAuthenticationToken、UsernamePasswordAuthenticationToken、RememberMeAuthenticationToken等等,顾名思义这就是框架运行过程中的各种凭证,而这些凭证其实都派生自Authentication,拿UsernamePasswordAuthenticationToken来举例。
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
...
}
public abstract class AbstractAuthenticationToken implements Authentication,
CredentialsContainer {
...
}
就像有钥匙就必然有锁具一样,不然就无法达到“筛选”的目的了,同样的既然有凭证很自然的就凭证校验器,也就是当之无愧的C位AuthenticationManager。
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
这个接口看起来很简单,就提供一个authenticate方法,用于对凭证authentication的校验(好像的确也不需要其他方法了)。就一个地方需要提一下,就是入参和出参都是Authentication对象,咋一看好像不好理解,但稍微一想就好理解了。就好比你拿张机票去坐飞机,登机的时候工作人员会对你的机票进行检验,验票通过后会在上面打上标记然后再把票给你,也就说你提供了“票”,通过后还是得到了“票”,但两个“票”实际上已经是不同的了。
另外在框架中还提供了一个接口,叫AuthenticationProvider
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
boolean supports(Class<?> authentication);
}
可以看到该接口完全覆盖了AuthenticationManager的方法(这里没太理解为什么不直接继承,不符合设计规范吗?),实际上常用的业务实现类都是实现的AuthenticationProvider,比如我们常用的DaoAuthenticationProvider。
该接口的另外一个方法supports很明显就是判断是否支持该凭证的校验,比如公交卡要公交闸机校验,地铁卡则要地铁闸机校验。既然涉及到支不支持就说明校验器不止一个,也就需要一个统一的管理端进行调度,也就是ProviderManager
public class ProviderManager implements AuthenticationManager, MessageSourceAware,
InitializingBean {
...
private List<AuthenticationProvider> providers = Collections.emptyList();
...
private AuthenticationManager parent;
...
public ProviderManager(List<AuthenticationProvider> providers,
AuthenticationManager parent) {
...
this.providers = providers;
this.parent = parent;
...
}
...
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
...
Authentication result = null;
...
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
...
result = provider.authenticate(authentication);
...
}
...
if (result == null && parent != null) {
...
result = parent.authenticate(authentication);
...
}
...
if (result != null) {
...
return result;
}
...
}
...
}
不出所料他实现了AuthenticationManager的authenticate方法,然后遍历providers,判断是否支持相应的凭证,若支持就进行凭证校验,并且得到校验结果。有一点需要注意的是,在所有的provider均未支持该凭证时,尝试让父级进行校验,当然父级可能也是ProviderManager,所以就不断递归向上,直到把所有的校验器都找个遍或者直到找到匹配的校验器。这个过程类似于ClassLoader的loadClass方法使用的双亲委派机制,不同的是loadClass是优先父级方法调用,好处是防止被自定义的ClassLoader篡改,避免出现满屏的ClassCastException。
最后我们再来看看常用的DaoAuthenticationProvider是如何定义的
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
...
}
public abstract class AbstractUserDetailsAuthenticationProvider implements
AuthenticationProvider, InitializingBean, MessageSourceAware {
...
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication));
}
...
}
看吧,DaoAuthenticationProvider正是支持UsernamePasswordAuthenticationToken,正所谓一个萝卜一个坑,他两就是互相配合工作的最佳拍档。
以上就是对SpringSecurity框架认证过程的简单拆解。
二、忠实的卫士AbstractSecurityInterceptor
认证通过成功后自然就是鉴权了,正如上文所说机票验证通过后,就需要对机票的等级进行验证了,经济舱的票肯定是无法在头等舱落座的(不考虑其他五花八门的因素)。
由从源码角度拆解SpringSecurity之勤劳的FilterChainProxy可知,框架会构建出一条Filter链,其中就有FilterSecurityInterceptor,前提是调用了类似“http.authorizeRequests()”的代码加入了ExpressionUrlAuthorizationConfigurer配置类。回忆下我们做安全配置的时候,不就是这么干的吗,比如促销活动接口无需鉴权,而订单列表接口则需要鉴权等等这样的需求。
那现在我们来看看构建出FilterSecurityInterceptor之后又是怎么来进行鉴权的呢
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements
Filter {
...
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}
...
public void invoke(FilterInvocation fi) throws IOException, ServletException {
...
InterceptorStatusToken token = super.beforeInvocation(fi);
...
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
...
super.afterInvocation(token, null);
}
...
}
不难看出,doFilter就表示通过拦截向我们的目标方法迈进一步,而beforeInvocation和afterInvocation就是我们的前置拦截器和后置拦截器(是不是感觉和AOP很像)
public abstract class AbstractSecurityInterceptor implements InitializingBean,
ApplicationEventPublisherAware, MessageSourceAware {
...
private AccessDecisionManager accessDecisionManager;
...
protected InterceptorStatusToken beforeInvocation(Object object) {
...
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);
if (attributes == null || attributes.isEmpty()) {
...
return null; // no further work post-invocation
}
...
Authentication authenticated = authenticateIfRequired();
// Attempt authorization
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
...
throw accessDeniedException;
}
...
}
...
}
核心的鉴权过程实际上就交给AccessDecisionManager来进行的,AccessDecisionManager是一个接口,常用的实现类是AffirmativeBased
public class AffirmativeBased extends AbstractAccessDecisionManager {
...
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);
...
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"));
}
...
}
}
又是一个类似代理的模式,代理者会遍历内部维护的voters,然后依次执行各个voter的vote方法。比较有趣的是,这里的vote就没有像AuthenticationProvider那样进行suppor方法来判定是否支持,也没有像FilterChainProxy来严格控制Filter的顺序,なんですが?因为这里压根不需要这样的机制,可以看到若vote的结果是ACCESS_GRANTED则直接就通过了,反之若是ACCESS_DENIED先暂时将deny标记进行累加,一旦后续的voter得到了ACCESS_GRANTED也会直接通过,同样的如果是ACCESS_ABSTAIN则什么都不做继续执行下一个voter。所以结论就是只要有一个voter认为通过,则通过,反之在没有通过的前提下,只要有一个认为不通过,则不通过,另外不支持该ConfigAttribute还可以返回ACCESS_ABSTAIN来表示什么都不做。
以上是通过HttpSecurity进行的鉴权配置,而框架还支持了注解的方式来进行鉴权配置,就是我们熟悉的@PreAuthorize、@PostAuthorize,使用这些注解需要先开启配置,即@EnableGlobalMethodSecurity(prePostEnabled = true)
...
@Import({
GlobalMethodSecuritySelector.class })
...
@Configuration
public @interface EnableGlobalMethodSecurity {
...
boolean prePostEnabled() default false;
...
AdviceMode mode() default AdviceMode.PROXY;
...
}
final class GlobalMethodSecuritySelector implements ImportSelector {
public final String[] selectImports(AnnotationMetadata importingClassMetadata) {
...
List<String> classNames = new ArrayList<>(4);
if(isProxy) {
classNames.add(MethodSecurityMetadataSourceAdvisorRegistrar.class.getName());
}
...
if (!skipMethodSecurityConfiguration) {
classNames.add(GlobalMethodSecurityConfiguration.class.getName());
}
...
return classNames.toArray(new String[0]);
}
}
使用这个注解后最终导入了MethodSecurityMetadataSourceAdvisorRegistrar和GlobalMethodSecurityConfiguration,而注解方式的权限配置主要就是通过这两进行注册的。
class MethodSecurityMetadataSourceAdvisorRegistrar implements
ImportBeanDefinitionRegistrar {
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata,
BeanDefinitionRegistry registry) {
BeanDefinitionBuilder advisor = BeanDefinitionBuilder
.rootBeanDefinition(MethodSecurityMetadataSourceAdvisor.class);
...
advisor.addConstructorArgValue("methodSecurityInterceptor");
...
registry.registerBeanDefinition("metaDataSourceAdvisor",
advisor.getBeanDefinition());
}
}
@Configuration
public class GlobalMethodSecurityConfiguration
implements ImportAware, SmartInitializingSingleton {
...
private MethodSecurityInterceptor methodSecurityInterceptor;
@Bean
public MethodInterceptor methodSecurityInterceptor() throws Exception {
//isAspectJ方法就是取@EnableGlobalMethodSecurity的mode来判断
this.methodSecurityInterceptor = isAspectJ()
? new AspectJMethodSecurityInterceptor()
: new MethodSecurityInterceptor();
methodSecurityInterceptor.setAccessDecisionManager(accessDecisionManager());
...
methodSecurityInterceptor
.setSecurityMetadataSource(methodSecurityMetadataSource());
...
return this.methodSecurityInterceptor;
}
...
protected AccessDecisionManager accessDecisionManager() {
List<AccessDecisionVoter<? extends Object>> decisionVoters = new ArrayList<AccessDecisionVoter<? extends Object>>();
...
if (prePostEnabled()) {
decisionVoters
.add(new PreInvocationAuthorizationAdviceVoter(expressionAdvice));
}
...
return new AffirmativeBased(decisionVoters);
}
...
@Bean
public MethodSecurityMetadataSource methodSecurityMetadataSource() {
List<MethodSecurityMetadataSource> sources = new ArrayList<>();
...
if (prePostEnabled()) {
sources.add(new PrePostAnnotationSecurityMetadataSource(attributeFactory));
}
...
return new DelegatingMethodSecurityMetadataSource(sources);
}
...
}
前面说什么来着,FilterSecurityInterceptor的invoke方法很像AOP是不是,这下《真》AOP来了。通过MethodSecurityMetadataSourceAdvisorRegistrar注册了MethodSecurityMetadataSourceAdvisor的BeanDefinition,同时将advice的beanName一同赋值进去,也就是GlobalMethodSecurityConfiguration中的methodSecurityInterceptor方法返回的bean。
可以看到实例化这个interceptor的时候,将鉴权需要的voter和source也都初始化进去了。但是发现没有,他们都做了一个判断,是否开启prePost设置,而这个值默认是false,所以要启用注解的鉴权配置需要将这个字段设置为true。
以上就是注解鉴权配置流程的简单拆解,注册好之后,运行时的维度和上一节的大体一样,就不再赘述了。
三、自定义数据权限鉴权框架
老规矩,既然熟悉了整个鉴权注册和运行过程,那是不是可以来搞一些定制化的需求呢,正好SpringSecurity原生是不支持数据权限的校验,需要在此基础上进行自定义开发,那么我们就针对这个小的需求点,看看如何实现的吧。
首先,数据权限是什么?咳咳,老实说我也没找到标准的定义,姑且认为是约束用户对数据的使用权力
吧,通俗的讲就是用户要访问某个表的某行数据,需要判断是否有这行数据的访问权限、编辑权限、删除权限,或者对这个表中某个字段值的新增权限
(可能不准确,望大佬指正)。举个简单例子就是用户要访问订单数据,就需要校验订单表中的用户ID是否和当前登录用户的ID一样,当然,实际使用还可能加入其他的校验方式,如是否是订单所属店铺的店主,是否是订单所在区域的管理员…完全可以根据业务灵活开发。
已经理解了数据权限的概念,接下来就简单说说如何实现。回顾上文,简单总结下权限校验的几个步骤:
- 获取权限配置的数据源
- 加入与之对应的voter
- 切入接口进行鉴权操作
大体上看就如上3个步骤,那我们就一一进行实现吧。
获取权限配置和数据源
对于FilterSecurityInterceptor它的数据源是HttpSecurity的配置信息,对于MethodSecurityInterceptor则是对应的注解,我们先简单实现一种,即注解的方式即可。
那么首先得定义一个注解
/**
* <p>
* 设置数据权限的注解
* </p>
*
* @author hoe
* @version 1.0
* @date 2022/4/28 14:07
*/
@Target({
ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface DataPermission {
/**
* 校验数据权限的执行方法名
* @return 方法名
*/
String funcName() default "defaultDataPermission";
/**
* 校验数据权限的参数名<br/>
* 支持SpEL表达式
* @return 参数名数组
*/
String[] keys();
/**
* 数据权限校验模式
* @return 默认为ANY模式
*/
Mode mode() default Mode.ANY;
/**
* 数据权限校验模式
*/
enum Mode {
/**
* 任意一个数据权限符合即通过
*/
ANY,
/**
* 所有数据权限均符合才通过
*/
ALL,
/**
* 所有数据权限都不符合才通过
*/
NONE
}
}
然后定义对应的source并在获取attribute时进行注解的解析
/**
* <p>
* 自定义数据权限数据源
* </p>
*
* @author hoe
* @version 1.0
* @date 2022/4/28 14:41
*/
public class DataPermissionMetadataSource extends AbstractMethodSecurityMetadataSource {
private final Object parserLock = new Object();
private ExpressionParser parser;
@Override
public Collection<ConfigAttribute> getAttributes(Method method, Class<?> targetClass) {
...
DataPermission dataPermission = findAnnotation(method, targetClass, DataPermission.class);
if (dataPermission == null) {
// There is no meta-data so return
logger.trace("No expression annotations found");
return Collections.emptyList();
}
String[] keys = dataPermission.keys();
Expression[] exps = null;
if (keys.length > 0) {
exps = new Expression[keys.length];
int idx = 0;
for (String key : keys) {
exps[idx++] = getParser().parseExpression(key);
}
}
return Arrays.asList(new DataPermissionInvocationAttribute(dataPermission.funcName(), exps, dataPermission.mode()));
}
...
}
加入与之对应的voter
数据源解析完毕,接下来就需要自定义voter对解析的数据进行鉴权操作
/**
* <p>
* 自定义数据权限voter
* </p>
*
* @author hoe
* @version 1.0
* @date 2022/4/28 14:41
*/
@Slf4j
public class DataPermissionVoter implements AccessDecisionVoter<MethodInvocation> {
private final LocalVariableTableParameterNameDiscoverer DISCOVERER = new LocalVariableTableParameterNameDiscoverer();
private final DataPermissionProperties properties;
private final DataPermissionExecutor executor;
private final Object lock = new Object();
private Field subjectIdentityField = null;
private Map<String, Method> exeMap = null;
public DataPermissionVoter(DataPermissionProperties properties, DataPermissionExecutor executor) {
this.properties = properties;
this.executor = executor;
}
@Override
public boolean supports(ConfigAttribute attribute) {
return attribute instanceof DataPermissionInvocationAttribute;
}
@Override
public boolean supports(Class<?> clazz) {
return MethodInvocation.class.isAssignableFrom(clazz);
}
@Override
public int vote(Authentication authentication, MethodInvocation object, Collection<ConfigAttribute> attributes) {
if (executor == null) {
log.debug("未配置任何数据权限处理器,跳过校验");
return ACCESS_ABSTAIN;
}
DataPermissionInvocationAttribute preAttr = findDataPermissionAttribute(attributes);
if (preAttr == null) {
return ACCESS_ABSTAIN;
}
Object principal = authentication.getPrincipal();
if (principal == null) {
log.info("用户未登录,直接认为数据权限校验不通过");
return ACCESS_DENIED;
}
Serializable serializable = getSubjectIdentity(principal);
EvaluationContext context = bindParam(object.getMethod(), object.getArguments());
Expression[] exps = preAttr.getExps();
Object[] args = new Object[exps.length];
int idx = 0;
for (Expression exp : exps) {
args[idx++] = exp.getValue(context);
}
String error = invoke(preAttr.getFuncName(), serializable, args);
return error == null || error.trim().length() == 0 ? ACCESS_GRANTED : ACCESS_DENIED;
}
...
}
逻辑很简单,一看就懂,就简单提几个点。如果未配置数据权限执行器,则认为不需要进行数据权限判断,直接返回ACCESS_ABSTAIN跳过投票。当然也可以实现为既然使用了@DataPermission注解就必须要配置数据权限执行器,这样的话就在这里抛出特定的自定义异常即可(需要异常解析)。另外就是如果没获取到登录信息则直接判定为校验不通过,反之就获取到用户的ID拿去做数据权限校验。至于具体如何做权限校验,需要定义一个鉴权接口,并定义一个默认的鉴权方法,开发者可以根据自身业务进行扩展。
/**
* <p>
* 数据权限校验执行者
* </p>
*
* @author hoe
* @version 1.0
* @date 2022/4/28
*/
public interface DataPermissionExecutor {
/**
* 一般小型项目都只会有一个默认的数据权限校验场景<br/>
* 例如业务所属校验,以实例来说明就是店主对店铺进行更新,就判断店铺是否属于该店主
* @param subjectId 用户主键
* @param keys 需要鉴权数据的ID
* @param mode 鉴权模式
* @return 异常信息,若为null表示鉴权通过
*/
String defaultDataPermission(Serializable subjectId, Object[] keys, DataPermission.Mode mode);
}
有了这个执行器,开发者的各种ServiceImpl就可以实现该接口,进行实际执行数据权限的校验。拿本demo来举例就是这样实现的
public interface IXXXService extends IService<XXX>, DataPermissionExecutor {
}
public class XXXServiceImpl extends ServiceImpl<XXXMapper, XXX> implements IXXXService {
@Override
public String defaultDataPermission(Serializable subjectId, Object[] keys, DataPermission.Mode mode) {
log.info("defaultDataPermission subjectId:{} keys:{} mode:{}", subjectId, JSON.toJSONString(keys), mode);
//TODO 根据主键ID查询具体表的数据权限
//TODO 通过特定的方法已经知道具体要查询什么表的数据权限,故直接可以进行查询
//TODO 查询到之后对用户的实际权限集合和要访问数据进行对比,再结合鉴权模式,最终得出鉴权结果
//TODO 现只是一个简单demo,后期还可以扩展访问类型,如C、R、U、D,进一步对权限粒度进行划分
return null;
}
}
好了,至此已经完成鉴权相关组件的准备工作了,最后一步就是对鉴权接口,即对定义了@DataPermission注解的接口进行切入。
切入接口进行鉴权操作
通过上一节对MethodSecurityInterceptor注册过程的拆解,我们不难得到解决办法。思路就是切入MethodSecurityInterceptor的实例化方法,在返回时加入我们的source和voter,即可无缝注入到原生注解鉴权框架中。
@Aspect
@Configuration
@EnableConfigurationProperties(value = DataPermissionProperties.class)
public class DataPermissionConfig {
static final String PREFIX = Const.PACKAGE + "data-permission";
@Autowired
private DataPermissionProperties properties;
@Autowired(required = false)
private DataPermissionExecutor executor;
@Pointcut("execution(* org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration.methodSecurityInterceptor(..))")
public void interceptorPointcut() {
}
@Around("interceptorPointcut()")
public Object interceptorAround(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSecurityInterceptor proceed = (MethodSecurityInterceptor) joinPoint.proceed();
AffirmativeBased manager = (AffirmativeBased) proceed.getAccessDecisionManager();
manager.getDecisionVoters().add(new DataPermissionVoter(properties, executor));
DelegatingMethodSecurityMetadataSource source = (DelegatingMethodSecurityMetadataSource) proceed.getSecurityMetadataSource();
source.getMethodSecurityMetadataSources().add(new DataPermissionMetadataSource());
return proceed;
}
}
可以看到,由于定义了切点,所以需要精确到方法级别,随着后期版本迭代很可能方法不是这个了,就会导致兼容问题,所以后期对Spring版本进行升级时需要注意。
另外,在数据权限框架中还可以完善一些配置来更好的控制整个鉴权过程的顺利进行。
@Data
@ConfigurationProperties(prefix = DataPermissionConfig.PREFIX)
public class DataPermissionProperties {
/**
* 超管是否直接授权
*/
private Boolean isSuperAdminGrant = true;
/**
* 主体标识字段名
*/
private String subjectIdentityName = "userId";
...
}
对应的配置文件
com.xx.xx:
...
#数据权限配置
data-permission:
isSuperAdminGrant: true
subjectIdentityName: userId
至此就完成自定义数据权限鉴权框架的开发。
PPPPPPPS:这只是一个Demo级实现,代码未进行设计和封装,还有很多属性、配置未考虑周详,如果要实现完备,还需从产品层面仔细设计。
简单总结本章内容,对SpringSecurity框架的认证和鉴权过程进行了拆解,了解了Authentication和AuthenticationManager是一对最佳拍档。鉴权操作分为HttpSecurity配置和接口注解配置两种,以及他们都是基于特定的voter进行实际的鉴权动作。最后还针对本章的学习借用一个简单的小需求——自定义数据权限鉴权框架进行实战
怎么样,你看懂了吗,有问题或者发现文章当中有谬误的地方,都欢迎留言评论哦。
至此我们已经完成了从源码角度拆解SpringSecurity系列文章的全部内容,感谢各位大佬能看到这里,谢谢支持。