diff --git a/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/api/Utils.java b/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/api/Utils.java index c5d440f1b7..9e57c42757 100644 --- a/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/api/Utils.java +++ b/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/api/Utils.java @@ -92,6 +92,13 @@ static void populateRemoveAndProtectedHeaders(RequestContext requestContext) { return; } + // Choreo-API-Key is considered as a protected header, hence header value should be treated + // same as other security headers. + if (ConfigHolder.getInstance().getConfig().getApiKeyConfig().getApiKeyInternalHeader() != null) { + requestContext.getProtectedHeaders().add(ConfigHolder.getInstance().getConfig().getApiKeyConfig() + .getApiKeyInternalHeader().toLowerCase()); + } + // Internal-Key credential is considered to be protected headers, such that the // header would not be sent // to backend and traffic manager. diff --git a/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/AuthFilter.java b/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/AuthFilter.java index 0418fc7a47..aa12aa0f64 100644 --- a/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/AuthFilter.java +++ b/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/AuthFilter.java @@ -53,6 +53,14 @@ public class AuthFilter implements Filter { private List authenticators = new ArrayList<>(); private static final Logger log = LogManager.getLogger(AuthFilter.class); + private static boolean isAPIKeyEnabled = false; + + static { + if (System.getenv("API_KEY_ENABLED") != null) { + isAPIKeyEnabled = Boolean.parseBoolean(System.getenv("API_KEY_ENABLED")); + } + } + @Override public void init(APIConfig apiConfig, Map configProperties) { initializeAuthenticators(apiConfig); @@ -85,7 +93,8 @@ private void initializeAuthenticators(APIConfig apiConfig) { } else if (apiSecurityLevel.trim(). equalsIgnoreCase(APIConstants.API_SECURITY_OAUTH_BASIC_AUTH_API_KEY_MANDATORY)) { isOAuthBasicAuthMandatory = true; - } else if (apiSecurityLevel.trim().equalsIgnoreCase(APIConstants.SWAGGER_API_KEY_AUTH_TYPE_NAME)) { + } else if (isAPIKeyEnabled && + apiSecurityLevel.trim().equalsIgnoreCase(APIConstants.SWAGGER_API_KEY_AUTH_TYPE_NAME)) { isApiKeyProtected = true; } } diff --git a/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyAuthenticator.java b/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyAuthenticator.java index 496a477c54..caf7f8ff89 100644 --- a/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyAuthenticator.java +++ b/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyAuthenticator.java @@ -22,15 +22,18 @@ import net.minidev.json.JSONValue; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.wso2.choreo.connect.enforcer.common.CacheProvider; import org.wso2.choreo.connect.enforcer.commons.model.AuthenticationContext; import org.wso2.choreo.connect.enforcer.commons.model.RequestContext; import org.wso2.choreo.connect.enforcer.config.ConfigHolder; import org.wso2.choreo.connect.enforcer.constants.APIConstants; import org.wso2.choreo.connect.enforcer.constants.APISecurityConstants; import org.wso2.choreo.connect.enforcer.exception.APISecurityException; +import org.wso2.choreo.connect.enforcer.util.FilterUtils; import java.util.Base64; import java.util.Map; +import java.util.Optional; /** * API Key authenticator. @@ -39,14 +42,6 @@ public class APIKeyAuthenticator extends JWTAuthenticator { private static final Logger log = LogManager.getLogger(APIKeyAuthenticator.class); - private static boolean isAPIKeyEnabled = false; - - static { - if (System.getenv("API_KEY_ENABLED") != null) { - isAPIKeyEnabled = Boolean.parseBoolean(System.getenv("API_KEY_ENABLED")); - } - } - public APIKeyAuthenticator() { super(); log.debug("API key authenticator initialized."); @@ -55,17 +50,32 @@ public APIKeyAuthenticator() { @Override public boolean canAuthenticate(RequestContext requestContext) { - if (!isAPIKeyEnabled) { - return false; - } String apiKeyValue = getAPIKeyFromRequest(requestContext); - return apiKeyValue != null && apiKeyValue.startsWith(APIKeyConstants.API_KEY_PREFIX); + return apiKeyValue != null && apiKeyValue.startsWith(APIKeyConstants.API_KEY_PREFIX) && + apiKeyValue.length() > 10; } @Override public AuthenticationContext authenticate(RequestContext requestContext) throws APISecurityException { - return super.authenticate(requestContext); + AuthenticationContext authCtx = super.authenticate(requestContext); + // Drop the API key data from the API key header. + dropAPIKeyDataFromAPIKeyHeader(requestContext); + return authCtx; + } + + private void dropAPIKeyDataFromAPIKeyHeader(RequestContext requestContext) throws APISecurityException { + + String apiKeyHeaderValue = getAPIKeyFromRequest(requestContext).trim(); + String checksum = apiKeyHeaderValue.substring(apiKeyHeaderValue.length() - 6); + JSONObject jsonObject = getDecodedAPIKeyData(apiKeyHeaderValue); + jsonObject.remove(APIKeyConstants.API_KEY_JSON_KEY); + // Update the header with the new API key data. + String encodedKeyData = Base64.getEncoder().encodeToString(jsonObject.toJSONString().getBytes()); + String newAPIKeyHeaderValue = APIKeyConstants.API_KEY_PREFIX + encodedKeyData + checksum; + // Add the new header. + requestContext.addOrModifyHeaders(ConfigHolder.getInstance().getConfig().getApiKeyConfig() + .getApiKeyInternalHeader().toLowerCase(), newAPIKeyHeaderValue); } private String getAPIKeyFromRequest(RequestContext requestContext) { @@ -74,19 +84,14 @@ private String getAPIKeyFromRequest(RequestContext requestContext) { .getApiKeyInternalHeader().toLowerCase()); } - @Override - protected String retrieveTokenFromRequestCtx(RequestContext requestContext) throws APISecurityException { - + private JSONObject getDecodedAPIKeyData(String apiKeyHeaderValue) throws APISecurityException { try { - String apiKeyHeaderValue = getAPIKeyFromRequest(requestContext).trim(); // Skipping the prefix(`chk_`) and checksum. String apiKeyData = apiKeyHeaderValue.substring(4, apiKeyHeaderValue.length() - 6); // Base 64 decode key data. String decodedKeyData = new String(Base64.getDecoder().decode(apiKeyData)); // Convert data into JSON. - JSONObject jsonObject = (JSONObject) JSONValue.parse(decodedKeyData); - // Extracting the jwt token. - return jsonObject.getAsString(APIKeyConstants.API_KEY_JSON_KEY); + return (JSONObject) JSONValue.parse(decodedKeyData); } catch (Exception e) { throw new APISecurityException(APIConstants.StatusCodes.UNAUTHENTICATED.getCode(), APISecurityConstants.API_AUTH_INVALID_CREDENTIALS, @@ -94,6 +99,35 @@ protected String retrieveTokenFromRequestCtx(RequestContext requestContext) thro } } + @Override + protected String retrieveTokenFromRequestCtx(RequestContext requestContext) throws APISecurityException { + + String apiKey = getAPIKeyFromRequest(requestContext).trim(); + if (!APIKeyUtils.isValidAPIKey(apiKey)) { + throw new APISecurityException(APIConstants.StatusCodes.UNAUTHENTICATED.getCode(), + APISecurityConstants.API_AUTH_INVALID_CREDENTIALS, + APISecurityConstants.API_AUTH_INVALID_CREDENTIALS_MESSAGE); + } + String keyHash = APIKeyUtils.generateAPIKeyHash(apiKey); + Object cachedJWT = CacheProvider.getGatewayAPIKeyJWTCache().getIfPresent(keyHash); + if (cachedJWT != null && !APIKeyUtils.isJWTExpired((String) cachedJWT)) { + if (log.isDebugEnabled()) { + log.debug("Token retrieved from the cache. Token: " + FilterUtils.getMaskedToken(keyHash)); + } + return (String) cachedJWT; + } + // Exchange the API Key to a JWT token. + Optional jwt = APIKeyUtils.exchangeAPIKeyToJWT(keyHash); + if (jwt.isEmpty()) { + throw new APISecurityException(APIConstants.StatusCodes.UNAUTHENTICATED.getCode(), + APISecurityConstants.API_AUTH_INVALID_CREDENTIALS, + APISecurityConstants.API_AUTH_INVALID_CREDENTIALS_MESSAGE); + } + // Cache the JWT token. + CacheProvider.getGatewayAPIKeyJWTCache().put(keyHash, jwt.get()); + return jwt.get(); + } + @Override public String getChallengeString() { return ""; diff --git a/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyUtils.java b/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyUtils.java index a861fe162a..19351df21a 100644 --- a/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyUtils.java +++ b/enforcer-parent/enforcer/src/main/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyUtils.java @@ -35,6 +35,9 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.wso2.choreo.connect.enforcer.config.ConfigHolder; +import org.wso2.choreo.connect.enforcer.constants.APIConstants; +import org.wso2.choreo.connect.enforcer.constants.APISecurityConstants; +import org.wso2.choreo.connect.enforcer.exception.APISecurityException; import org.wso2.choreo.connect.enforcer.util.FilterUtils; import java.io.InputStream; @@ -80,18 +83,24 @@ public static boolean isValidAPIKey(String apiKey) { * @param apiKey API Key * @return key hash */ - public static String generateAPIKeyHash(String apiKey) { + public static String generateAPIKeyHash(String apiKey) throws APISecurityException { - // Skipping the prefix(`chp_`) and checksum. - String keyData = apiKey.substring(4, apiKey.length() - 6); - // Base 64 decode key data. - String decodedKeyData = new String(Base64.getDecoder().decode(keyData)); - // Convert data into JSON. - JSONObject jsonObject = (JSONObject) JSONValue.parse(decodedKeyData); - // Extracting the key. - String key = jsonObject.getAsString(APIKeyConstants.API_KEY_JSON_KEY); - // Return SHA256 hash of the key. - return DigestUtils.sha256Hex(key); + try { + // Skipping the prefix(`chp_`) and checksum. + String keyData = apiKey.substring(4, apiKey.length() - 6); + // Base 64 decode key data. + String decodedKeyData = new String(Base64.getDecoder().decode(keyData)); + // Convert data into JSON. + JSONObject jsonObject = (JSONObject) JSONValue.parse(decodedKeyData); + // Extracting the key. + String key = jsonObject.getAsString(APIKeyConstants.API_KEY_JSON_KEY); + // Return SHA256 hash of the key. + return DigestUtils.sha256Hex(key); + } catch (Exception e) { + throw new APISecurityException(APIConstants.StatusCodes.UNAUTHENTICATED.getCode(), + APISecurityConstants.API_AUTH_INVALID_CREDENTIALS, + APISecurityConstants.API_AUTH_INVALID_CREDENTIALS_MESSAGE); + } } /** diff --git a/enforcer-parent/enforcer/src/test/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyAuthenticatorTest.java b/enforcer-parent/enforcer/src/test/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyAuthenticatorTest.java new file mode 100644 index 0000000000..47371e7fd2 --- /dev/null +++ b/enforcer-parent/enforcer/src/test/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyAuthenticatorTest.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2024, WSO2 LLC. (https://www.wso2.com) + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.choreo.connect.enforcer.security.jwt; + +import com.google.common.cache.LoadingCache; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.wso2.carbon.apimgt.common.gateway.dto.JWTConfigurationDto; +import org.wso2.choreo.connect.enforcer.common.CacheProvider; +import org.wso2.choreo.connect.enforcer.commons.model.APIConfig; +import org.wso2.choreo.connect.enforcer.commons.model.RequestContext; +import org.wso2.choreo.connect.enforcer.config.ConfigHolder; +import org.wso2.choreo.connect.enforcer.config.EnforcerConfig; +import org.wso2.choreo.connect.enforcer.config.dto.APIKeyDTO; +import org.wso2.choreo.connect.enforcer.config.dto.CacheDto; +import org.wso2.choreo.connect.enforcer.exception.APISecurityException; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({APIKeyUtils.class, CacheProvider.class, ConfigHolder.class}) +public class APIKeyAuthenticatorTest { + + @Before + public void setup() { + PowerMockito.mockStatic(ConfigHolder.class); + ConfigHolder configHolder = PowerMockito.mock(ConfigHolder.class); + PowerMockito.when(ConfigHolder.getInstance()).thenReturn(configHolder); + EnforcerConfig enforcerConfig = PowerMockito.mock(EnforcerConfig.class); + PowerMockito.when(configHolder.getConfig()).thenReturn(enforcerConfig); + APIKeyDTO apiKeyDTO = PowerMockito.mock(APIKeyDTO.class); + PowerMockito.when(enforcerConfig.getApiKeyConfig()).thenReturn(apiKeyDTO); + PowerMockito.when(ConfigHolder.getInstance().getConfig().getApiKeyConfig() + .getApiKeyInternalHeader()).thenReturn("choreo-api-key"); + CacheDto cacheDto = Mockito.mock(CacheDto.class); + Mockito.when(cacheDto.isEnabled()).thenReturn(true); + Mockito.when(enforcerConfig.getCacheDto()).thenReturn(cacheDto); + JWTConfigurationDto jwtConfigurationDto = Mockito.mock(JWTConfigurationDto.class); + Mockito.when(jwtConfigurationDto.isEnabled()).thenReturn(false); + Mockito.when(enforcerConfig.getJwtConfigurationDto()).thenReturn(jwtConfigurationDto); + } + + @Test + public void retrieveTokenFromRequestCtxTest_invalidKey() { + + RequestContext.Builder requestContextBuilder = new RequestContext.Builder("/api-key"); + requestContextBuilder.matchedAPI(new APIConfig.Builder("Petstore") + .basePath("/test") + .apiType("REST") + .build()); + Map headersMap = new HashMap<>(); + headersMap.put("choreo-api-key", + "chk_eyJrZXkiOiJieTlpYXQ5d3MycDY0dWF6anFkbzQ4cnAyYnY3aWoxdWRuYmRzNzN6ZWx5OWNoZHJ2YiJ97JYpag"); + requestContextBuilder.headers(headersMap); + RequestContext requestContext = requestContextBuilder.build(); + + APIKeyAuthenticator apiKeyAuthenticator = new APIKeyAuthenticator(); + Assert.assertThrows(APISecurityException.class, () -> + apiKeyAuthenticator.retrieveTokenFromRequestCtx(requestContext)); + } + + @Test + public void retrieveTokenFromRequestCtxTest_cached_validKey() throws APISecurityException { + + String mockJWT = "eyJrZXkiOiJieTlpYXQ5d3MycDY0dWF6anFkbzQ4cnAyYnY3aWoxdWRuYmRzNzN6ZWx5OWNoZHJ2YiJ97JYPAg"; + PowerMockito.mockStatic(APIKeyUtils.class); + PowerMockito.when(APIKeyUtils.isValidAPIKey(Mockito.anyString())).thenReturn(true); + PowerMockito.when(APIKeyUtils.generateAPIKeyHash(Mockito.anyString())).thenReturn("key_hash"); + PowerMockito.when(APIKeyUtils.isJWTExpired(Mockito.anyString())).thenReturn(false); + + PowerMockito.mockStatic(CacheProvider.class); + LoadingCache gatewayAPIKeyJWTCache = PowerMockito.mock(LoadingCache.class); + PowerMockito.when(CacheProvider.getGatewayAPIKeyJWTCache()).thenReturn(gatewayAPIKeyJWTCache); + PowerMockito.when(gatewayAPIKeyJWTCache.getIfPresent(Mockito.anyString())).thenReturn(mockJWT); + + RequestContext.Builder requestContextBuilder = new RequestContext.Builder("/api-key"); + requestContextBuilder.matchedAPI(new APIConfig.Builder("Petstore") + .basePath("/test") + .apiType("REST") + .build()); + Map headersMap = new HashMap<>(); + headersMap.put("choreo-api-key", + "chk_eyJhdHRyMSI6InYxIiwiY29ubmVjdGlvbklkIjoiNjAwM2EzYjctYWYwZi00ZmIzLTg1M2UtYTY1NjJiMjM0N" + + "WYyIiwia2V5IjoieG5lcGVxZmZ4eWx2Y2Q4a3FnNHprZDFpMHoxMnA2dTBqcW50aDUyM3JlN292a2pudncifQBdZRRQ"); + requestContextBuilder.headers(headersMap); + RequestContext requestContext = requestContextBuilder.build(); + + APIKeyAuthenticator apiKeyAuthenticator = new APIKeyAuthenticator(); + String token = apiKeyAuthenticator.retrieveTokenFromRequestCtx(requestContext); + Assert.assertEquals(mockJWT, token); + } + + @Test + public void retrieveTokenFromRequestCtxTest_validKey() throws APISecurityException { + + PowerMockito.mockStatic(APIKeyUtils.class); + String mockJWT = "eyJrZXkiOiJieTlpYXQ5d3MycDY0dWF6anFkbzQ4cnAyYnY3aWoxdWRuYmRzNzN6ZWx5OWNoZHJ2YiJ97JYPAg"; + PowerMockito.when(APIKeyUtils.exchangeAPIKeyToJWT(Mockito.anyString())).thenReturn(Optional.of(mockJWT)); + PowerMockito.when(APIKeyUtils.isValidAPIKey(Mockito.anyString())).thenReturn(true); + PowerMockito.when(APIKeyUtils.generateAPIKeyHash(Mockito.anyString())).thenReturn("key_hash"); + + PowerMockito.mockStatic(CacheProvider.class); + LoadingCache gatewayAPIKeyJWTCache = PowerMockito.mock(LoadingCache.class); + PowerMockito.when(CacheProvider.getGatewayAPIKeyJWTCache()).thenReturn(gatewayAPIKeyJWTCache); + PowerMockito.when(gatewayAPIKeyJWTCache.getIfPresent(Mockito.anyString())).thenReturn(null); + + RequestContext.Builder requestContextBuilder = new RequestContext.Builder("/api-key"); + requestContextBuilder.matchedAPI(new APIConfig.Builder("Petstore") + .basePath("/test") + .apiType("REST") + .build()); + Map headersMap = new HashMap<>(); + headersMap.put("choreo-api-key", + "chk_eyJhdHRyMSI6InYxIiwiY29ubmVjdGlvbklkIjoiNjAwM2EzYjctYWYwZi00ZmIzLTg1M2UtYTY1NjJiMjM0N" + + "WYyIiwia2V5IjoieG5lcGVxZmZ4eWx2Y2Q4a3FnNHprZDFpMHoxMnA2dTBqcW50aDUyM3JlN292a2pudncifQBdZRRQ"); + requestContextBuilder.headers(headersMap); + RequestContext requestContext = requestContextBuilder.build(); + + APIKeyAuthenticator apiKeyAuthenticator = new APIKeyAuthenticator(); + String token = apiKeyAuthenticator.retrieveTokenFromRequestCtx(requestContext); + Assert.assertEquals(mockJWT, token); + } +} diff --git a/enforcer-parent/enforcer/src/test/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyUtilsTest.java b/enforcer-parent/enforcer/src/test/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyUtilsTest.java index cf40893fce..a60a38b3f2 100644 --- a/enforcer-parent/enforcer/src/test/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyUtilsTest.java +++ b/enforcer-parent/enforcer/src/test/java/org/wso2/choreo/connect/enforcer/security/jwt/APIKeyUtilsTest.java @@ -24,6 +24,7 @@ import org.powermock.core.classloader.annotations.PrepareForTest; import org.powermock.modules.junit4.PowerMockRunner; import org.wso2.choreo.connect.enforcer.config.ConfigHolder; +import org.wso2.choreo.connect.enforcer.exception.APISecurityException; @RunWith(PowerMockRunner.class) @PrepareForTest({ConfigHolder.class}) @@ -45,7 +46,7 @@ public void testIsValidAPIKey_invalid() { } @Test - public void testGenerateAPIKeyHash() { + public void testGenerateAPIKeyHash() throws APISecurityException { String apiKey = "chp_eyJrZXkiOiJlanp6am8yaGc5MnA2MTF6NTI2OXMzNzU1ZnJzbnFlNm9vb2hldWd0djBjbmQ3bXdobCJ9dknDJA"; String expectedKeyHash = "62f73948188c9f773414d4ec77eae6e8caab21556e4ad18f94b7c6c5b018524c"; String generatedAPIKeyHash = APIKeyUtils.generateAPIKeyHash(apiKey);