Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
970bf43
Logs for Ignored Client Certificates
hrishikesh-nalawade Sep 10, 2025
9d14b4c
Optimizing Code and adding Tests
hrishikesh-nalawade Dec 10, 2025
cf8e372
Merge branch 'v3.x.x' into hrishikesh-nalawade/GH4164/log-ignored-cli…
hrishikesh-nalawade Dec 10, 2025
9258c5e
Implemented CertificateLoggingUtils utility class so that logging cou…
hrishikesh-nalawade Dec 10, 2025
5dd882c
Removing Comments
hrishikesh-nalawade Dec 10, 2025
c8bda34
Merge branch 'v3.x.x' into hrishikesh-nalawade/GH4164/log-ignored-cli…
hrishikesh-nalawade Dec 22, 2025
7ee33f4
Merge branch 'v3.x.x' into hrishikesh-nalawade/GH4164/log-ignored-cli…
hrishikesh-nalawade Jan 6, 2026
b006e54
Merge branch 'v3.x.x' into hrishikesh-nalawade/GH4164/log-ignored-cli…
hrishikesh-nalawade Jan 7, 2026
d7cfaa8
Restructured the CertificateLoggingUtils class and enhanced Categoriz…
hrishikesh-nalawade Jan 7, 2026
6d5b5d0
removing unused Imports
hrishikesh-nalawade Jan 7, 2026
6e5656f
Merge branch 'v3.x.x' into hrishikesh-nalawade/GH4164/log-ignored-cli…
pablocarle Jan 13, 2026
7571743
Merge branch 'v3.x.x' into hrishikesh-nalawade/GH4164/log-ignored-cli…
pablocarle Jan 14, 2026
b69e310
Merge branch 'v3.x.x' into hrishikesh-nalawade/GH4164/log-ignored-cli…
pablocarle Jan 14, 2026
1fb9a6d
Merge branch 'v3.x.x' into hrishikesh-nalawade/GH4164/log-ignored-cli…
pablocarle Jan 22, 2026
7963cad
updates
hrishikesh-nalawade Jan 23, 2026
21da760
Merge branch 'v3.x.x' into hrishikesh-nalawade/GH4164/log-ignored-cli…
hrishikesh-nalawade Jan 23, 2026
a1a1254
test corrections
hrishikesh-nalawade Jan 28, 2026
1b7bca2
Merge branch 'v3.x.x' into hrishikesh-nalawade/GH4164/log-ignored-cli…
hrishikesh-nalawade Jan 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import java.security.cert.X509Certificate;
import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import static org.zowe.apiml.security.common.filter.CategorizeCertsFilter.*;

Expand Down Expand Up @@ -97,6 +98,10 @@ private ServerWebExchange categorizeCerts(ServerWebExchange exchange) {
new X509Certificate[]{clientCertFromHeader.get()},
certificateForClientAuth
);

// Log ignored certificates
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the comments needed? The method name is self-explanatory

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, will remove most of the comments after after review and before merging.

logIgnoredCertificates(new X509Certificate[]{clientCertFromHeader.get()}, clientAuthCerts);

exchange.getAttributes().put(ATTR_NAME_CLIENT_AUTH_X509_CERTIFICATE, clientAuthCerts);
log.debug(LOG_FORMAT_FILTERING_CERTIFICATES, ATTR_NAME_CLIENT_AUTH_X509_CERTIFICATE, Arrays.toString(clientAuthCerts));

Expand All @@ -108,6 +113,10 @@ private ServerWebExchange categorizeCerts(ServerWebExchange exchange) {

} else {
X509Certificate[] clientAuthCerts = selectCerts(certsFromTls, certificateForClientAuth);

// Log ignored certificates
logIgnoredCertificates(certsFromTls, clientAuthCerts);

exchange.getAttributes().put(ATTR_NAME_CLIENT_AUTH_X509_CERTIFICATE, clientAuthCerts);
log.debug(LOG_FORMAT_FILTERING_CERTIFICATES, ATTR_NAME_CLIENT_AUTH_X509_CERTIFICATE, Arrays.toString(clientAuthCerts));

Expand All @@ -127,6 +136,64 @@ private ServerWebExchange categorizeCerts(ServerWebExchange exchange) {
return exchange.mutate().request(requestBuilder.build()).build();
}

/**
* Logs information about certificates that were ignored during authentication.
* Compares the original set of certificates with the filtered set to identify ignored certificates.
* Uses Base64-encoded public keys for reliable comparison instead of X509Certificate object equality.
*
* @param originalCerts The original array of certificates before filtering
* @param filteredCerts The array of certificates after filtering for authentication
*/
private void logIgnoredCertificates(X509Certificate[] originalCerts, X509Certificate[] filteredCerts) {
if (originalCerts == null || originalCerts.length == 0) return;

Set<String> originalKeys = Arrays.stream(originalCerts)
.map(CategorizeCertsWebFilter::base64EncodePublicKey)
.collect(Collectors.toSet());

Set<String> filteredKeys = filteredCerts != null
? Arrays.stream(filteredCerts)
.map(CategorizeCertsWebFilter::base64EncodePublicKey)
.collect(Collectors.toSet())
: new HashSet<>();

// Finding ignored certificate keys (in original but not in filtered)
Set<String> ignoredKeys = new HashSet<>(originalKeys);
ignoredKeys.removeAll(filteredKeys);

if (!ignoredKeys.isEmpty()) {
// Find the actual certificate objects for the ignored keys
List<X509Certificate> ignoredCerts = Arrays.stream(originalCerts)
.filter(cert -> ignoredKeys.contains(base64EncodePublicKey(cert)))
.collect(Collectors.toList());

// Log summary of ignored certificates
log.debug("Certificates ignored/not used for authentication: {}",
ignoredCerts.stream()
.map(cert -> {
String subjectDN = cert.getSubjectX500Principal().getName();
String issuerDN = cert.getIssuerX500Principal().getName();
String publicKeyBase64 = base64EncodePublicKey(cert);
return String.format("[Subject: %s, Issuer: %s, Public Key (first 20 chars): %s...]",
subjectDN, issuerDN, publicKeyBase64.substring(0, Math.min(20, publicKeyBase64.length())));
})
.collect(Collectors.joining(", ")));

// For each ignored certificate, log why it was ignored
ignoredCerts.forEach(cert -> {
String publicKeyBase64 = base64EncodePublicKey(cert);
boolean isApimlCert = getPublicKeyCertificatesBase64().contains(publicKeyBase64);
if (isApimlCert) {
log.debug("Certificate with subject '{}' was ignored because it is an APIML Gateway certificate (not used for client authentication)",
cert.getSubjectX500Principal().getName());
} else {
log.debug("Certificate with subject '{}' was ignored for unknown reason (not in APIML cert set, but filtered by predicate)",
cert.getSubjectX500Principal().getName());
}
});
}
}

/**
* Extracts and decodes an X.509 certificate from the CLIENT_CERT_HEADER.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@

package org.zowe.apiml.filter;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.SslInfo;
Expand All @@ -33,6 +39,7 @@
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.*;
import java.util.stream.Collectors;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
Expand Down Expand Up @@ -63,6 +70,8 @@ class CategorizeCertsWebFilterTest {
private static X509Certificate gatewayCert;
private static X509Certificate clientCert;
private static X509Certificate headerCert;
private Logger logger;
private ListAppender<ILoggingEvent> logAppender;

@BeforeAll
static void init() throws Exception {
Expand Down Expand Up @@ -90,6 +99,20 @@ void setUp() {
when(mockRequestBuilder.headers(any())).thenReturn(mockRequestBuilder);
when(mockRequestBuilder.build()).thenReturn(mockRequest);

// Setup log capturing
logger = (Logger) LoggerFactory.getLogger(CategorizeCertsWebFilter.class);
logAppender = new ListAppender<>();
logAppender.start();
logger.addAppender(logAppender);
logger.setLevel(Level.DEBUG); // Ensure DEBUG level is enabled
}

@AfterEach
void tearDown() {
// Clean up log appender
if (logger != null && logAppender != null) {
logger.detachAppender(logAppender);
}
}


Expand Down Expand Up @@ -220,5 +243,132 @@ private static X509Certificate loadCertificateFromKeystore(String alias, String
}
}

@Test
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move these tests into a nested class, if it's only verifying logging.

Have you checked if these same scenarios are already covered in previous tests? (so you can only add the assertion to the existing test about logging)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank You, Removed most of the tests, added assertions to the existing ones, and kept one test for a scenario that was not covered.

void logIgnoredCertificates_whenGatewayCertIsIgnored_logsCorrectly() {
Map<String, Object> attributes = new HashMap<>();
X509Certificate[] certChain = {gatewayCert};

when(mockRequest.getSslInfo()).thenReturn(mockSslInfo);
when(mockSslInfo.getPeerCertificates()).thenReturn(certChain);
when(mockExchange.getAttributes()).thenReturn(attributes);
when(mockFilterChain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
when(mockRequest.getHeaders()).thenReturn(mockHeaders);
when(mockHeaders.getFirst(CLIENT_CERT_HEADER)).thenReturn("");
when(mockCertificateValidator.isForwardingEnabled()).thenReturn(false);

StepVerifier.create(filter.filter(mockExchange, mockFilterChain)).verifyComplete();

List<ILoggingEvent> logsList = logAppender.list;

List<ILoggingEvent> ignoredCertLogs = logsList.stream()
.filter(event -> event.getMessage().contains("ignored"))
.collect(Collectors.toList());

assertFalse(ignoredCertLogs.isEmpty(), "Should have logged information about ignored certificates");

boolean hasSummaryLog = logsList.stream()
.anyMatch(event -> event.getMessage().contains("Certificates ignored/not used for authentication"));
assertTrue(hasSummaryLog, "Should have summary log about ignored certificates");

boolean hasDetailedLog = logsList.stream()
.anyMatch(event -> event.getFormattedMessage().contains("is an APIML Gateway certificate"));
assertTrue(hasDetailedLog, "Should explain that certificate IS an APIML Gateway certificate");

String gatewayCertSubject = gatewayCert.getSubjectX500Principal().getName();
boolean mentionsSubject = logsList.stream()
.anyMatch(event -> event.getFormattedMessage().contains(gatewayCertSubject));
assertTrue(mentionsSubject, "Should mention the gateway certificate subject: " + gatewayCertSubject);
}

@Test
void logIgnoredCertificates_whenOnlyClientCert_noIgnoredLogs() {
Map<String, Object> attributes = new HashMap<>();
X509Certificate[] certChain = {clientCert};

when(mockRequest.getSslInfo()).thenReturn(mockSslInfo);
when(mockSslInfo.getPeerCertificates()).thenReturn(certChain);
when(mockExchange.getAttributes()).thenReturn(attributes);
when(mockFilterChain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
when(mockRequest.getHeaders()).thenReturn(mockHeaders);
when(mockHeaders.getFirst(CLIENT_CERT_HEADER)).thenReturn("");
when(mockCertificateValidator.isForwardingEnabled()).thenReturn(false);

StepVerifier.create(filter.filter(mockExchange, mockFilterChain)).verifyComplete();

List<ILoggingEvent> logsList = logAppender.list;

boolean hasIgnoredLog = logsList.stream()
.anyMatch(event -> event.getMessage().contains("Certificates ignored/not used for authentication"));
assertFalse(hasIgnoredLog, "Should NOT log ignored certificates when only client cert is present");
}

@Test
void logIgnoredCertificates_whenMixedCertChain_logsOnlyIgnoredOnes() {
Map<String, Object> attributes = new HashMap<>();
X509Certificate[] certChain = {clientCert, gatewayCert}; // Mixed chain

when(mockRequest.getSslInfo()).thenReturn(mockSslInfo);
when(mockSslInfo.getPeerCertificates()).thenReturn(certChain);
when(mockExchange.getAttributes()).thenReturn(attributes);
when(mockFilterChain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());
when(mockRequest.getHeaders()).thenReturn(mockHeaders);
when(mockHeaders.getFirst(CLIENT_CERT_HEADER)).thenReturn("");
when(mockCertificateValidator.isForwardingEnabled()).thenReturn(false);

StepVerifier.create(filter.filter(mockExchange, mockFilterChain)).verifyComplete();

List<ILoggingEvent> logsList = logAppender.list;

// Should log ignored certificates
boolean hasIgnoredLog = logsList.stream()
.anyMatch(event -> event.getMessage().contains("Certificates ignored/not used for authentication"));
assertTrue(hasIgnoredLog, "Should log ignored certificates in mixed chain");

String gatewayCertSubject = gatewayCert.getSubjectX500Principal().getName();
boolean mentionsGatewayCert = logsList.stream()
.anyMatch(event -> event.getFormattedMessage().contains(gatewayCertSubject));
assertTrue(mentionsGatewayCert, "Should mention ignored gateway certificate");

// Should NOT mention client certificate as ignored
String clientCertSubject = clientCert.getSubjectX500Principal().getName();
long clientCertIgnoredMentions = logsList.stream()
.filter(event -> event.getMessage().contains("ignored"))
.filter(event -> event.getFormattedMessage().contains(clientCertSubject))
.count();
assertEquals(0, clientCertIgnoredMentions, "Should NOT log client certificate as ignored");
}

@Test
void logIgnoredCertificates_whenForwardingModeWithGatewayCertInHeader_logsIgnored() throws CertificateEncodingException {
Map<String, Object> attributes = new HashMap<>();
X509Certificate[] tlsChain = {clientCert, gatewayCert};
// Using gatewayCert in header (which should be ignored since it's an APIML cert)
String headerCertBase64 = Base64.getEncoder().encodeToString(gatewayCert.getEncoded());

when(mockRequest.getSslInfo()).thenReturn(mockSslInfo);
when(mockSslInfo.getPeerCertificates()).thenReturn(tlsChain);
when(mockRequest.getHeaders()).thenReturn(mockHeaders);
when(mockHeaders.getFirst(CLIENT_CERT_HEADER)).thenReturn(headerCertBase64);
when(mockExchange.getAttributes()).thenReturn(attributes);
when(mockFilterChain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());

when(mockCertificateValidator.isForwardingEnabled()).thenReturn(true);
when(mockCertificateValidator.hasGatewayChain(tlsChain)).thenReturn(true);

StepVerifier.create(filter.filter(mockExchange, mockFilterChain)).verifyComplete();

List<ILoggingEvent> logsList = logAppender.list;

// Should log that gateway cert from header was ignored
boolean hasIgnoredLog = logsList.stream()
.anyMatch(event -> event.getMessage().contains("Certificates ignored/not used for authentication"));
assertTrue(hasIgnoredLog, "Should log that header certificate was ignored");

// Should explain it's an APIML certificate
boolean explainsReason = logsList.stream()
.anyMatch(event -> event.getFormattedMessage().contains("is an APIML Gateway certificate"));
assertTrue(explainsReason, "Should explain why header certificate was ignored");
}


}
Loading