在 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 是否被启用,通过跟进源代码发现,只要满足下面的条件之一,就会启用:

  1. server.use-forward-headers 为 true
  2. server.tomcat.remote-ip-header 不为空
  3. 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

https://www.cnblogs.com/duanxz/p/3750725.html

此条目发表在折腾 CAS, 搬砖杂记分类目录,贴了, , , 标签。将固定链接加入收藏夹。

发表评论

您的电子邮箱地址不会被公开。

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据