From 710794f5cd33e2abb4319d59acf57bd3e20a845f Mon Sep 17 00:00:00 2001 From: Phil Zampino Date: Fri, 8 Nov 2024 16:52:15 -0500 Subject: [PATCH] KNOX-3073 - Token verification fallback to Knox keys behavior should configurable (#949) --- .../filter/HadoopAuthFilterTest.java | 2 + .../jwt/filter/AbstractJWTFilter.java | 17 ++- .../federation/AbstractJWTFilterTest.java | 130 +++++++++++++++++- ...TAsHTTPBasicCredsFederationFilterTest.java | 34 +++++ .../federation/SSOCookieProviderTest.java | 28 ++++ 5 files changed, 208 insertions(+), 3 deletions(-) diff --git a/gateway-provider-security-hadoopauth/src/test/java/org/apache/knox/gateway/hadoopauth/filter/HadoopAuthFilterTest.java b/gateway-provider-security-hadoopauth/src/test/java/org/apache/knox/gateway/hadoopauth/filter/HadoopAuthFilterTest.java index c06876524c..934959f277 100644 --- a/gateway-provider-security-hadoopauth/src/test/java/org/apache/knox/gateway/hadoopauth/filter/HadoopAuthFilterTest.java +++ b/gateway-provider-security-hadoopauth/src/test/java/org/apache/knox/gateway/hadoopauth/filter/HadoopAuthFilterTest.java @@ -17,6 +17,7 @@ */ package org.apache.knox.gateway.hadoopauth.filter; +import static org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter.JWT_INSTANCE_KEY_FALLBACK; import static org.easymock.EasyMock.anyString; import static org.easymock.EasyMock.capture; import static org.easymock.EasyMock.captureInt; @@ -577,6 +578,7 @@ private HadoopAuthFilter testIfJwtSupported(String supportJwt) throws Exception expect(filterConfig.getInitParameter("support.jwt")).andReturn(supportJwt).anyTimes(); expect(filterConfig.getInitParameter("hadoop.auth.unauthenticated.path.list")).andReturn(null).anyTimes(); expect(filterConfig.getInitParameter("clusterName")).andReturn("topology1").anyTimes(); + expect(filterConfig.getInitParameter(JWT_INSTANCE_KEY_FALLBACK)).andReturn("false").anyTimes(); final boolean isJwtSupported = Boolean.parseBoolean(supportJwt); if (isJwtSupported) { expect(filterConfig.getInitParameter(JWTFederationFilter.KNOX_TOKEN_AUDIENCES)).andReturn(null).anyTimes(); diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java index fb1517a735..5668066a4c 100644 --- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java +++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java @@ -31,6 +31,7 @@ import java.util.Arrays; import java.util.Date; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; @@ -102,6 +103,9 @@ public abstract class AbstractJWTFilter implements Filter { public static final String JWT_EXPECTED_SIGALG = "jwt.expected.sigalg"; public static final String JWT_DEFAULT_SIGALG = "RS256"; + public static final String JWT_INSTANCE_KEY_FALLBACK = "jwt.instance.key.fallback"; + public static final boolean JWT_INSTANCE_KEY_FALLBACK_DEFAULT = false; + static JWTMessages log = MessagesFactory.get( JWTMessages.class ); private static AuditService auditService = AuditServiceFactory.getAuditService(); @@ -116,13 +120,14 @@ public abstract class AbstractJWTFilter implements Filter { private String expectedIssuer; private String expectedSigAlg; protected String expectedPrincipalClaim; - protected Set expectedJWKSUrls = new HashSet(); + protected Set expectedJWKSUrls = new LinkedHashSet(); protected Set allowedJwsTypes; private TokenStateService tokenStateService; private TokenMAC tokenMAC; protected long idleTimeoutSeconds = -1; protected String topologyName; + protected boolean isJwtInstanceKeyFallback = JWT_INSTANCE_KEY_FALLBACK_DEFAULT; @Override public abstract void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) @@ -158,6 +163,9 @@ public void init( FilterConfig filterConfig ) throws ServletException { // Setup the verified tokens cache topologyName = context != null ? (String) context.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE) : null; signatureVerificationCache = SignatureVerificationCache.getInstance(topologyName, filterConfig); + + String fallbackConfig = filterConfig.getInitParameter(JWT_INSTANCE_KEY_FALLBACK); + isJwtInstanceKeyFallback = fallbackConfig != null ? Boolean.parseBoolean(fallbackConfig) : JWT_INSTANCE_KEY_FALLBACK_DEFAULT; } protected void configureExpectedParameters(FilterConfig filterConfig) { @@ -512,17 +520,22 @@ protected boolean verifyTokenSignature(final JWT token) { // If it has not yet been verified, then perform the verification now if (!verified) { try { + boolean attemptedPEMVerification = false; + boolean attemptedJWKSVerification = false; + if (publicKey != null) { + attemptedPEMVerification = true; verified = authority.verifyToken(token, publicKey); log.pemVerificationResultMessage(verified); } if (!verified && expectedJWKSUrls != null && !expectedJWKSUrls.isEmpty()) { + attemptedJWKSVerification = true; verified = authority.verifyToken(token, expectedJWKSUrls, expectedSigAlg, allowedJwsTypes); log.jwksVerificationResultMessage(verified); } - if(!verified) { + if(!verified && ((!attemptedPEMVerification && !attemptedJWKSVerification) || isJwtInstanceKeyFallback)) { verified = authority.verifyToken(token); log.signingKeyVerificationResultMessage(verified); } diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java index bb255a19c7..0b2dcbc7a7 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AbstractJWTFilterTest.java @@ -75,6 +75,8 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; +import static org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter.JWT_INSTANCE_KEY_FALLBACK; +import static org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.JWKS_URL; import static org.junit.Assert.fail; public abstract class AbstractJWTFilterTest { @@ -627,6 +629,9 @@ public void testSignatureVerificationChain() throws Exception { /* Add a failing PEM */ props.put(getVerificationPemProperty(), failingPem); + /* Turn fallback to signing key on */ + props.put(JWT_INSTANCE_KEY_FALLBACK, "true"); + /* This handler is setup with a publicKey, corresponding privateKey is used to sign the JWT below */ handler.init(new TestFilterConfig(props)); @@ -660,7 +665,7 @@ public void testSignatureVerificationChain() throws Exception { * This will test the signature verification chain. * Specifically the flow when provided PEM is not invalid and * knox signing key is valid. - * + * AND JWT_INSTANCE_KEY_FALLBACK is true * NOTE: here valid means can validate JWT. * @throws Exception */ @@ -668,6 +673,7 @@ public void testSignatureVerificationChain() throws Exception { public void testSignatureVerificationChainWithPEMandSignature() throws Exception { try { Properties props = getProperties(); + props.put(JWT_INSTANCE_KEY_FALLBACK, "true"); KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); kpg.initialize(2048); @@ -709,6 +715,128 @@ public void testSignatureVerificationChainWithPEMandSignature() throws Exception } } + @Test + public void testNoPEMOrJwksWithoutFallback() throws Exception { + // Test fallback disabled, but not PEM configured. + // You can't disable key fallback without specifying an explicit verification method. + boolean verified = doTestSignatureVerificationChain(null, null, false); + Assert.assertTrue("Token should have been verified.", verified); + } + + @Test + public void testNoPEMOrJwksWithFallback() throws Exception { + boolean verified = doTestSignatureVerificationChain(null, null, true); + Assert.assertTrue("Token should have been verified by falling back to keys.", verified); + } + + @Test + public void testInvalidPEMNoJwksWithFallback() throws Exception { + boolean verified = doTestSignatureVerificationChain(pem, null, true); + Assert.assertTrue("Token should have been verified by falling back to keys.", verified); + } + + @Test + public void testInvalidPEMNoJwksWithoutFallback() throws Exception { + String invalidPEM = generateInvalidPEM(); + boolean verified = doTestSignatureVerificationChain(invalidPEM, null, false); + Assert.assertFalse("Token should NOT have been verified.", verified); + } + + @Test + public void testNoPEMInvalidJwksWithoutFallback() throws Exception { + boolean verified = doTestSignatureVerificationChain(null, "https://localhost/nonesense", false); + Assert.assertFalse("Token should have NOT been verified.", verified); + } + + @Test + public void testNoPEMInvalidJwksWithFallback() throws Exception { + boolean verified = doTestSignatureVerificationChain(null, "https://localhost/nonesense", true); + Assert.assertTrue("Token should have been verified by falling back to keys.", verified); + } + + @Test + public void testInvalidPEMInvalidJwksWithoutFallback() throws Exception { + String invalidPEM = generateInvalidPEM(); + boolean verified = doTestSignatureVerificationChain(invalidPEM, "https://localhost/nonesense", false); + Assert.assertFalse("Token should NOT have been verified.", verified); + } + + @Test + public void testInvalidPEMInvalidJwksWithFallback() throws Exception { + String invalidPEM = generateInvalidPEM(); + boolean verified = doTestSignatureVerificationChain(invalidPEM, "https://localhost/nonesense", true); + Assert.assertTrue("Token should have been verified by falling back to keys.", verified); + } + + protected String generateInvalidPEM() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + + KeyPair KPair = kpg.generateKeyPair(); + String dn = buildDistinguishedName(InetAddress.getLocalHost().getHostName()); + Certificate cert = X509CertificateUtil.generateCertificate(dn, KPair, 365, "SHA1withRSA"); + byte[] data = cert.getEncoded(); + Base64 encoder = new Base64( 76, "\n".getBytes( StandardCharsets.US_ASCII ) ); + return new String(encoder.encodeToString( data ).getBytes( StandardCharsets.US_ASCII ), StandardCharsets.US_ASCII).trim(); + } + + /** + * This will test the signature verification chain in the following order + * 1. PEM - check if PEM is configured and signature is validated + * 2. JWKS - check if endpoint id configured if not skip + * 3. Knox signing key - if the above two fail try to validate using knox signing cert + * @throws Exception + */ + public boolean doTestSignatureVerificationChain(final String testPEM, + final String testJwks, + final boolean fallbackToKeys) throws Exception { + boolean isVerified = false; + + try { + Properties props = getProperties(); + props.put(getAudienceProperty(), "bar"); + + if (testPEM != null) { + // Add a test PEM + props.put(getVerificationPemProperty(), testPEM); + } + + if (testJwks != null) { + // Add the test JWKS URL + props.put(JWKS_URL, testJwks); + } + + // Configure fallback to signing key on + props.put(JWT_INSTANCE_KEY_FALLBACK, String.valueOf(fallbackToKeys)); + + // This handler is setup with a publicKey, corresponding privateKey is used to sign the JWT below + handler.init(new TestFilterConfig(props)); + + SignedJWT jwt = getJWT(AbstractJWTFilter.JWT_DEFAULT_ISSUER, "alice", + new Date(new Date().getTime() + TimeUnit.MINUTES.toMillis(10)), privateKey); + + HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class); + setTokenOnRequest(request, jwt); + + EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes(); + EasyMock.expect(request.getPathInfo()).andReturn("resource").anyTimes(); + EasyMock.expect(request.getQueryString()).andReturn(null); + HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); + EasyMock.expect(response.encodeRedirectURL(SERVICE_URL)).andReturn(SERVICE_URL); + EasyMock.expect(response.getOutputStream()).andAnswer(DummyServletOutputStream::new).anyTimes(); + EasyMock.replay(request, response); + + TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response, chain); + isVerified = chain.doFilterCalled; + + } catch (ServletException se) { + fail("Should NOT have thrown a ServletException."); + } + + return isVerified; + } + @Test public void testInvalidIssuer() throws Exception { try { diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTAsHTTPBasicCredsFederationFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTAsHTTPBasicCredsFederationFilterTest.java index a5549ade3c..0041ab3d0a 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTAsHTTPBasicCredsFederationFilterTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTAsHTTPBasicCredsFederationFilterTest.java @@ -111,5 +111,39 @@ public void testAlternativeCaseUsername() throws Exception { } } + @Override + @Test + public void testNoPEMInvalidJwksWithoutFallback() throws Exception { + // No-op: This filter does not appear to support the JWKS URL(s) config like the + // JWTFederationFilter does, so this test does not apply + } + + @Override + @Test + public void testNoPEMInvalidJwksWithFallback() throws Exception { + // No-op: This filter does not appear to support the JWKS URL(s) config like the + // JWTFederationFilter does, so this test does not apply + } + + @Override + @Test + public void testInvalidPEMNoJwksWithoutFallback() throws Exception { + // No-op: This filter does not appear to support the JWKS URL(s) config like the + // JWTFederationFilter does, so this test does not apply + } + + @Override + @Test + public void testInvalidPEMInvalidJwksWithoutFallback() throws Exception { + // No-op: This filter does not appear to support the JWKS URL(s) config like the + // JWTFederationFilter does, so this test does not apply + } + + @Override + @Test + public void testInvalidPEMInvalidJwksWithFallback() throws Exception { + // No-op: This filter does not appear to support the JWKS URL(s) config like the + // JWTFederationFilter does, so this test does not apply + } } diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/SSOCookieProviderTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/SSOCookieProviderTest.java index e38e6a871f..f5f1a6e5de 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/SSOCookieProviderTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/SSOCookieProviderTest.java @@ -330,6 +330,34 @@ public void testProxiedDefaultAuthenticationProviderURLWithoutMismatchInXForward Assert.assertEquals(loginURL, "https://remotehost/notgateway/knoxsso/api/v1/websso?originalUrl=" + "https://remotehost/resource"); } + @Override + @Test + public void testNoPEMInvalidJwksWithoutFallback() throws Exception { + // No-op: The SSOCookieProvider does not appear to support the JWKS URL(s) config like the + // JWTFederationFilter does, so this test does not apply + } + + @Override + @Test + public void testNoPEMInvalidJwksWithFallback() throws Exception { + // No-op: The SSOCookieProvider does not appear to support the JWKS URL(s) config like the + // JWTFederationFilter does, so this test does not apply + } + + @Override + @Test + public void testInvalidPEMInvalidJwksWithoutFallback() throws Exception { + // No-op: The SSOCookieProvider does not appear to support the JWKS URL(s) config like the + // JWTFederationFilter does, so this test does not apply + } + + @Override + @Test + public void testInvalidPEMInvalidJwksWithFallback() throws Exception { + // No-op: The SSOCookieProvider does not appear to support the JWKS URL(s) config like the + // JWTFederationFilter does, so this test does not apply + } + @Test public void testIdleTimoutExceeded() throws Exception { final TokenStateService tokenStateService = EasyMock.createNiceMock(TokenStateService.class);