在 CAS 5.3 中自定义 Audit Log
最近接到一个需求,需要在 CAS 的 Audit Log 中添加几个字段(例如用户该次访问的 User-Agent),并且在 Log 里同时体现这次请求的 RemoteAddr 和网关通过 HTTP Headers 传过来的 IP。本来以为这个需求很简单,一下就能做完,结果搞了好几天。
自定义 Audit Log 字段
首先看看处于宇宙中心的类 org.apereo.cas.DefaultCentralAuthenticationService
,在这个类的方法上可以看到很多注解,其中有一个 @Audit
注解,注意到这个注解上写的字段我们很熟悉,在 Audit Log 中时长可以见到,于是猜想这个注解就是触发 CAS Audit Log 的关键。利用 IDEA 的 Find Usages 的功能,找到这个注解在 org.apereo.inspektr.audit.AuditTrailManagementAspect
使用了,分析代码 org.apereo.inspektr.audit.AuditTrailManagementAspect#executeAuditCode
,发现关键位置:
for (final AuditTrailManager manager : auditTrailManagers) {
manager.setAuditFormat(this.auditFormat);
manager.record(auditContext);
}
这个 AuditTrailManager 就是最终去记录 AuditLog 的地方,我们可以自己实现这个接口,并使用 org.apereo.cas.audit.AuditTrailExecutionPlanConfigurer
去注册这个类:
@Slf4j
@EnableConfigurationProperties
@Configuration
@RequiredArgsConstructor
public class CustomCasAuditConfiguration implements AuditTrailExecutionPlanConfigurer {
private final CustomSsoConfigurationProperties config;
@Override
public void configureAuditTrailExecutionPlan(AuditTrailExecutionPlan plan) {
plan.registerAuditTrailManager(new CustomizedAuditTrailManager(config.getAudit()));
}
}
只要在 CustomizedAuditTrailManager.record
中去记录 CAS Audit Log 即可。
获取请求者的 IP
一般来说,在开发时想要获取的一般是用户的真实 IP,而通向服务的TCP连接在访问过程中可能通过了好几层代理,用户访问第一层网关时的 IP 可能是由网关通过七层的 HTTP 头 X-Forwarded-For
传过来的,这是一个约定俗成的 Header,以至于 CAS 在 HTTP Request 中发现这个头的时候直接就以这个 Header 的 Value 作为 RemoteAddress。
我在自定义时想要把底层TCP连接的真实IP记录到 Audit Log 里,而 request.getRemoteAddr
拿到的却有可能是伪造的 X-Forwarded-For
带来的内容,CAS 的这个默认设置给我造成了不小的麻烦。我通过跟进源码找到了原因。 getRemoteAddr
的地址被换成七层的值是因为 Tomcat 添加了一个 RemoteIpValve
,那么什么情况下这个 Valve 会被启用呢?
server.tomcat.use-forward-headers=false
server.tomcat.remote-ip-header=
server.tomcat.protocol-header=
这三个配置决定了 RemoteIpValve
是否被启用,通过跟进源代码发现,只要满足下面的条件之一,就会启用:
- server.use-forward-headers 为 true
- server.tomcat.remote-ip-header 不为空
- server.tomcat.protocol-header 不为空
具体可以查看代码 org.springframework.boot.autoconfigure.web.ServerProperties.Tomcat#customizeRemoteIpValve
:
String protocolHeader = getProtocolHeader();
String remoteIpHeader = getRemoteIpHeader();
// For back compatibility the valve is also enabled if protocol-header is set
if (StringUtils.hasText(protocolHeader) || StringUtils.hasText(remoteIpHeader)
|| properties.getOrDeduceUseForwardHeaders()) {
RemoteIpValve valve = new RemoteIpValve();
valve.setProtocolHeader(StringUtils.hasLength(protocolHeader)
? protocolHeader : "X-Forwarded-Proto");
if (StringUtils.hasLength(remoteIpHeader)) {
valve.setRemoteIpHeader(remoteIpHeader);
}
// The internal proxies default to a white list of "safe" internal IP
// addresses
valve.setInternalProxies(getInternalProxies());
valve.setPortHeader(getPortHeader());
valve.setProtocolHeaderHttpsValue(getProtocolHeaderHttpsValue());
// ... so it's safe to add this valve by default.
factory.addEngineValves(valve);
}
那么这三个值在 CAS 里什么时候被设置的呢?在这个地方: https://github.com/apereo/cas/blob/4b89fab15ff580fc3481f4cbf3bb917bb9fe05c4/webapp/resources/application.properties#L12
要改变这个行为,我们只要在 CAS 的配置文件 properties 中设置对应的参数即可。
改变 ClientInfo 的产生逻辑
因为改变了刚刚在上面说的这个 getRemoteAddr
的逻辑,可能会导致 CAS 本身的一些安全机制受到影响,比如当用户把一个 Cookie 从一台机器复制到另一台机器上使用时,由于 IP 地址不同,CAS 会不认这个 TGC 。通过一通跟进,我们会发现 CAS 在做这些逻辑的时候基本上用到的是 ClientInfo 这个对象,只有改变这个对象的产生逻辑才能修复安全机制。
可以发现 ClientInfo 是在 org.apereo.inspektr.common.web.ClientInfoThreadLocalFilter
里生成并注册到 ClientInfoHolder 中的,那么我们只要继承这个 ClientInfoThreadLocalFilter 并且修改它的逻辑,最好把它注册到 FilterChain 里去就可以了。但是不能让原来的那个 Filter 存在在 FilterChain 上,因为这样相当于生成了两次 ClientInfo,而且我们生成的 ClientInfo 很可能被原生的覆盖掉。
但是很不幸的,在查看 org.apereo.cas.audit.spi.config.CasCoreAuditConfiguration#casClientInfoLoggingFilter
时发现,这个 Bean 的声明中,并没有像其他的 Bean 上面一样加上 @ConditionalOnMissingBean
注解,所以我们没办法直接用我们的 Filter 替换掉原生的 Filter。经过一通研究,我的同事发现这个问题可以转化成“如何在 Spring 初始化完成后把一个 Bean 从 Context 里干掉”。最终找到了以下方法:
@Bean
public static BeanFactoryPostProcessor removeClientInfoThreadLocalFilterRegistrationProcessor() {
return (ConfigurableListableBeanFactory beanFactory) -> {
final BeanDefinitionRegistry registry = (BeanDefinitionRegistry) beanFactory;
if (registry.containsBeanDefinition("casClientInfoLoggingFilter")) {
registry.removeBeanDefinition("casClientInfoLoggingFilter");
}
log.info("casClientInfoLoggingFilter is removed from bean factory");
};
}
只要使用 BeanFactoryPostProcessor 就可以在 Bean 组装完成后、程序开始跑之前把一个 Bean 干掉,我们只要用这个机制把 casClientInfoLoggingFilter 干掉就能完成目标了。
这些文章给了我们很大的帮助:
https://blog.andresteingress.com/2017/09/19/spring-bean-removal.html