Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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 @@ -25,6 +25,7 @@
import org.springframework.web.filter.OncePerRequestFilter;
import org.zowe.apiml.message.log.ApimlLogger;
import org.zowe.apiml.product.logging.annotations.InjectApimlLogger;
import org.zowe.apiml.security.common.util.CertificateLoggingUtils;
import org.zowe.apiml.security.common.verify.CertificateValidator;

import java.io.ByteArrayInputStream;
Expand Down Expand Up @@ -57,6 +58,23 @@ public class CategorizeCertsFilter extends OncePerRequestFilter {

private final CertificateValidator certificateValidator;

/**
* Logs information about certificates that were ignored during authentication.
* Delegates to {@link CertificateLoggingUtils} for the actual logging implementation.
*
* @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) {
CertificateLoggingUtils.logIgnoredCertificates(
originalCerts,
filteredCerts,
publicKeyCertificatesBase64,
log,
CategorizeCertsFilter::base64EncodePublicKey
Copy link
Contributor

Choose a reason for hiding this comment

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

please remove this as an argument, simply call the method or if not visible, move to a utility class that's visible in apiml-common

Copy link
Contributor

Choose a reason for hiding this comment

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

@hrishikesh-nalawade can you confirm if you see this comment? There's some weird behaviour in the github view

Copy link
Member Author

Choose a reason for hiding this comment

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

Done, Kindly check.

);
}

/**
* Get certificates from request (if exists), separate them (to use only APIML certificate to request sign and
* other for authentication) and store again into request.
Expand All @@ -78,7 +96,12 @@ private void categorizeCerts(ServletRequest request) {
// add the client certificate to the certs array
String subjectDN = ((X509Certificate) clientCert.get()).getSubjectX500Principal().getName();
log.debug("Found client certificate in header, adding it to the request. Subject DN: {}", subjectDN);
httpServletRequest.setAttribute(ATTR_NAME_CLIENT_AUTH_X509_CERTIFICATE, selectCerts(new X509Certificate[]{(X509Certificate) clientCert.get()}, certificateForClientAuth));

X509Certificate[] headerCerts = new X509Certificate[]{(X509Certificate) clientCert.get()};
X509Certificate[] clientAuthCerts = selectCerts(headerCerts, certificateForClientAuth);
logIgnoredCertificates(headerCerts, clientAuthCerts);

httpServletRequest.setAttribute(ATTR_NAME_CLIENT_AUTH_X509_CERTIFICATE, clientAuthCerts);
return;
} else if (isClientCertificateIgnored(httpServletRequest)) {
log.debug("Client certificate is ignored.");
Expand All @@ -88,7 +111,10 @@ private void categorizeCerts(ServletRequest request) {
}
}

httpServletRequest.setAttribute(ATTR_NAME_CLIENT_AUTH_X509_CERTIFICATE, selectCerts(certs, certificateForClientAuth));
X509Certificate[] clientAuthCerts = selectCerts(certs, certificateForClientAuth);
logIgnoredCertificates(certs, clientAuthCerts);

httpServletRequest.setAttribute(ATTR_NAME_CLIENT_AUTH_X509_CERTIFICATE, clientAuthCerts);
httpServletRequest.setAttribute(ATTR_NAME_JAKARTA_SERVLET_REQUEST_X509_CERTIFICATE, selectCerts(certs, apimlCertificate));

log.debug(LOG_FORMAT_FILTERING_CERTIFICATES, ATTR_NAME_CLIENT_AUTH_X509_CERTIFICATE, httpServletRequest.getAttribute(ATTR_NAME_CLIENT_AUTH_X509_CERTIFICATE));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*/

package org.zowe.apiml.security.common.util;

import lombok.experimental.UtilityClass;
import org.slf4j.Logger;

import java.security.cert.X509Certificate;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
* Utility class for logging certificate-related operations, particularly for logging
* certificates that were ignored/filtered during client authentication.
*/
@UtilityClass
Copy link
Contributor

Choose a reason for hiding this comment

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

This annotation doesn't provide much value here honestly, it's preferable to remove it and add a private constructor

Copy link
Member Author

Choose a reason for hiding this comment

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

Done, Kindly Check

public class CertificateLoggingUtils {

private static final String UNKNOWN = "Unknown";

/**
* 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
* @param publicKeyCertificatesBase64 Set of Base64-encoded public keys of known APIML certificates
* @param logger The logger to use for output
* @param base64Encoder Function to encode certificate public key to Base64
*/
public static void logIgnoredCertificates(
Copy link
Contributor

Choose a reason for hiding this comment

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

Try to refactor a bit this method, it's too long, you can extract smaller blocks into private methods

Copy link
Member Author

Choose a reason for hiding this comment

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

Done, Thank You for pointing that out.

X509Certificate[] originalCerts,
X509Certificate[] filteredCerts,
Set<String> publicKeyCertificatesBase64,
Logger logger,
Function<X509Certificate, String> base64Encoder
) {
if (originalCerts == null || originalCerts.length == 0) return;

List<X509Certificate> ignoredCerts = identifyIgnoredCertificates(
originalCerts, filteredCerts, base64Encoder
);

if (!ignoredCerts.isEmpty()) {
logCertificateSummary(ignoredCerts, base64Encoder, logger);
logCertificateDetails(ignoredCerts, publicKeyCertificatesBase64, base64Encoder, logger);
}
}

/**
* Identifies certificates that were ignored by comparing original and filtered certificate arrays.
*/
private static List<X509Certificate> identifyIgnoredCertificates(
X509Certificate[] originalCerts,
X509Certificate[] filteredCerts,
Function<X509Certificate, String> base64Encoder
) {
Set<String> originalKeys = Arrays.stream(originalCerts)
.map(base64Encoder)
.collect(Collectors.toSet());

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

Set<String> ignoredKeys = new HashSet<>(originalKeys);
ignoredKeys.removeAll(filteredKeys);
Comment on lines 76 to 87
Copy link
Contributor

Choose a reason for hiding this comment

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

This can be one method

Copy link
Member Author

Choose a reason for hiding this comment

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

Done, Thank You for pointing that out.


return Arrays.stream(originalCerts)
.filter(cert -> ignoredKeys.contains(base64Encoder.apply(cert)))
.toList();
}

/**
* Logs a summary of all ignored certificates with their key details.
*/
private static void logCertificateSummary(
List<X509Certificate> ignoredCerts,
Function<X509Certificate, String> base64Encoder,
Logger logger
) {
logger.debug("Certificates ignored/not used for authentication: {}",
ignoredCerts.stream()
.map(cert -> formatCertificateInfo(cert, base64Encoder))
.collect(Collectors.joining(", ")));
}

/**
* Formats certificate information for logging.
*/
private static String formatCertificateInfo(
X509Certificate cert,
Function<X509Certificate, String> base64Encoder
) {
String subjectDN = cert.getSubjectX500Principal() != null
? cert.getSubjectX500Principal().getName()
: UNKNOWN;
String issuerDN = cert.getIssuerX500Principal() != null
? cert.getIssuerX500Principal().getName()
: UNKNOWN;
String publicKeyBase64 = base64Encoder.apply(cert);
return String.format("[Subject: %s, Issuer: %s, Public Key (first 20 chars): %s...]",
subjectDN, issuerDN, publicKeyBase64.substring(0, Math.min(20, publicKeyBase64.length())));
}

/**
* Logs detailed information about each ignored certificate including the reason for ignoring.
*/
private static void logCertificateDetails(
List<X509Certificate> ignoredCerts,
Set<String> publicKeyCertificatesBase64,
Function<X509Certificate, String> base64Encoder,
Logger logger
) {
ignoredCerts.forEach(cert -> {
String publicKeyBase64 = base64Encoder.apply(cert);
boolean isApimlCert = publicKeyCertificatesBase64.contains(publicKeyBase64);
String subjectDN = cert.getSubjectX500Principal() != null
? cert.getSubjectX500Principal().getName()
: UNKNOWN;
if (isApimlCert) {
logger.debug("Certificate with subject '{}' was ignored because it is an APIML Gateway certificate (not used for client authentication)",
subjectDN);
} else {
logger.debug("Certificate with subject '{}' was ignored for unknown reason (not in APIML cert set, but filtered by predicate)",
subjectDN);
}
});
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@

package org.zowe.apiml.security.common.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 jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
Expand All @@ -32,6 +38,7 @@
import java.util.Arrays;
import java.util.Base64;
import java.util.HashSet;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
Expand Down Expand Up @@ -72,6 +79,9 @@ class CategorizeCertsFilterTest {

private CertificateValidator certificateValidator;

private Logger logger;
private ListAppender<ILoggingEvent> logAppender;

@BeforeAll
public static void init() throws CertificateException {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Expand All @@ -87,6 +97,19 @@ public void setUp() {
certificateValidator = mock(CertificateValidator.class);
when(certificateValidator.isForwardingEnabled()).thenReturn(false);
when(certificateValidator.hasGatewayChain(any())).thenReturn(false);

logger = (Logger) LoggerFactory.getLogger(CategorizeCertsFilter.class);
logAppender = new ListAppender<>();
logAppender.start();
logger.addAppender(logAppender);
logger.setLevel(Level.DEBUG);
}

@AfterEach
void tearDown() {
if (logger != null && logAppender != null) {
logger.detachAppender(logAppender);
}
}

@Nested
Expand Down Expand Up @@ -165,6 +188,12 @@ void thenAllClientCertificates() throws IOException, ServletException {

assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER));
assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements());

// Verify no ignored certificate logs when only client certs are present
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
Expand Down Expand Up @@ -399,6 +428,33 @@ void thenCategorizedCertsWithReversedLogic() throws IOException, ServletExceptio
assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements());
}

@Test
void thenMixedCertChain_logsOnlyIgnoredOnes() throws IOException, ServletException {
X509Certificate[] mixedCerts = new X509Certificate[]{
X509Utils.getCertificate(X509Utils.correctBase64("foreignCert1")),
X509Utils.getCertificate(X509Utils.correctBase64("apimlCert1"))
};
request.setAttribute("jakarta.servlet.request.X509Certificate", mixedCerts);

filter.doFilter(request, response, chain);
HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest();
assertNotNull(nextRequest);

X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("jakarta.servlet.request.X509Certificate");
assertEquals(1, apimlCerts.length);

X509Certificate[] clientCertsFromAttr = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate");
assertNotNull(clientCertsFromAttr);
assertEquals(1, clientCertsFromAttr.length);

// Verify logging for ignored certificates in mixed chain
List<ILoggingEvent> logsList = logAppender.list;
assertTrue(logsList.stream().anyMatch(event -> event.getMessage().contains("Certificates ignored/not used for authentication")),
"Should log ignored certificates in mixed chain");
assertTrue(logsList.stream().anyMatch(event -> event.getFormattedMessage().contains("is an APIML Gateway certificate")),
"Should mention ignored APIML certificate");
}

@Nested
class WhenCertificateInHeaderAndForwardingEnabled {

Expand Down Expand Up @@ -452,6 +508,16 @@ void givenNotTrustedCerts_thenClientCertHeaderIgnored() throws ServletException,

assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER));
assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements());

// Verify logging for ignored certificates
List<ILoggingEvent> logsList = logAppender.list;
boolean hasIgnoredLog = logsList.stream()
.anyMatch(event -> event.getMessage().contains("Certificates ignored/not used for authentication"));
assertTrue(hasIgnoredLog, "Should log ignored certificates when APIML certs are categorized as such");

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");
}
}

Expand Down Expand Up @@ -548,4 +614,5 @@ void whenClientCertHeaderNotDefined_thenReturnFalse() throws ServletException, I
assertNotNull(chain.getRequest(), "Filter chain should continue normally");
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.springframework.web.server.WebFilterChain;
import org.zowe.apiml.message.log.ApimlLogger;
import org.zowe.apiml.product.logging.annotations.InjectApimlLogger;
import org.zowe.apiml.security.common.util.CertificateLoggingUtils;
import org.zowe.apiml.security.common.verify.CertificateValidator;
import reactor.core.publisher.Mono;

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

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 +112,9 @@ private ServerWebExchange categorizeCerts(ServerWebExchange exchange) {

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

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 +134,23 @@ private ServerWebExchange categorizeCerts(ServerWebExchange exchange) {
return exchange.mutate().request(requestBuilder.build()).build();
}

/**
* Logs information about certificates that were ignored during authentication.
* Delegates to {@link CertificateLoggingUtils} for the actual logging implementation.
*
* @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) {
CertificateLoggingUtils.logIgnoredCertificates(
originalCerts,
filteredCerts,
publicKeyCertificatesBase64,
log,
CategorizeCertsWebFilter::base64EncodePublicKey
Copy link
Contributor

Choose a reason for hiding this comment

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

This could be moved to a utility class, there's no reason to have it as a parameter everywhere

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 it is, I have created a separate utility class CertificateLoggingUtils to handle the certificate filtering logic

);
}

/**
* Extracts and decodes an X.509 certificate from the CLIENT_CERT_HEADER.
*
Expand Down
Loading
Loading