From 6ab91a1d4fa8de12ea7b660584a922e2361b9da9 Mon Sep 17 00:00:00 2001 From: Sebastian Preisner Date: Tue, 27 Sep 2022 14:06:23 +0200 Subject: [PATCH] add Conditional http request header This change will add a condition to check the http request header to match on a regex string. It is inspired by the ConditionalOTPFormAuthenticator but not limited to the OTP Form. --- .../ConditionalHttpHeaderAuthenticator.java | 80 ++++++++++++++ ...itionalHttpHeaderAuthenticatorFactory.java | 103 ++++++++++++++++++ ...ycloak.authentication.AuthenticatorFactory | 1 + .../forms/AllowDenyAuthenticatorTest.java | 103 ++++++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalHttpHeaderAuthenticator.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalHttpHeaderAuthenticatorFactory.java diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalHttpHeaderAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalHttpHeaderAuthenticator.java new file mode 100644 index 000000000000..5a12c6a06fe3 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalHttpHeaderAuthenticator.java @@ -0,0 +1,80 @@ +package org.keycloak.authentication.authenticators.conditional; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.jboss.logging.Logger; + +import javax.ws.rs.core.MultivaluedMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +public class ConditionalHttpHeaderAuthenticator implements ConditionalAuthenticator { + + public static final ConditionalHttpHeaderAuthenticator SINGLETON = new ConditionalHttpHeaderAuthenticator(); + private static final Logger logger = Logger.getLogger(ConditionalHttpHeaderAuthenticator.class); + + public boolean containsMatchingRequestHeader(MultivaluedMap requestHeaders, String headerPattern) { + if (headerPattern == null) { + logger.debugv("The metching request header pattern are !"); + return false; + } + + Pattern pattern = Pattern.compile(headerPattern, Pattern.DOTALL | Pattern.CASE_INSENSITIVE); + + for (Map.Entry> entry : requestHeaders.entrySet()) { + + String key = entry.getKey(); + + for (String value : entry.getValue()) { + + String headerEntry = key.trim() + ": " + value.trim(); + + if (pattern.matcher(headerEntry).matches()) { + logger.debugv("Pattern {0} matches header entry {1}", headerPattern, headerEntry); + return true; + } + } + } + + return false; + } + + @Override + public boolean matchCondition(AuthenticationFlowContext context) { + Map config = context.getAuthenticatorConfig().getConfig(); + if(config == null){ + return false; + } + + MultivaluedMap requestHeaders = context.getHttpRequest().getHttpHeaders().getRequestHeaders(); + String headerPattern = config.get(ConditionalHttpHeaderAuthenticatorFactory.HTTP_HEADER_PATTERN); + boolean negateOutcome = Boolean.parseBoolean(config.get(ConditionalHttpHeaderAuthenticatorFactory.NEGATE_OUTCOME)); + + return (negateOutcome != containsMatchingRequestHeader(requestHeaders, headerPattern)); + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public void action(AuthenticationFlowContext context) { + // Not used + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + // Not used + } + + @Override + public void close() { + // Does nothing + } +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalHttpHeaderAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalHttpHeaderAuthenticatorFactory.java new file mode 100644 index 000000000000..2347e04b06fe --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalHttpHeaderAuthenticatorFactory.java @@ -0,0 +1,103 @@ +package org.keycloak.authentication.authenticators.conditional; + +import org.keycloak.Config.Scope; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticatorFactory; +import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticator; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.Arrays; +import java.util.List; + +/** + * An {@link ConditionalAuthenticatorFactory} for {@link ConditionalHttpHeaderAuthenticator}s. + * + * @author Sebastian Preisner + */ +public class ConditionalHttpHeaderAuthenticatorFactory implements ConditionalAuthenticatorFactory { + public static final String PROVIDER_ID = "conditional-http-header"; + + public static final String HTTP_HEADER_PATTERN = "search_pattern"; + public static final String NEGATE_OUTCOME = "negate_outcome"; + + @Override + public void init(Scope config) { + // no-op + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + // no-op + } + + @Override + public void close() { + // no-op + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Condition - request header"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + private static final Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Flow is executed olny if HTTP request header matches supplied regular expression."; + } + + @Override + public List getConfigProperties() { + + ProviderConfigProperty HttpHeaderPattern = new ProviderConfigProperty(); + HttpHeaderPattern.setType(ProviderConfigProperty.STRING_TYPE); + HttpHeaderPattern.setName(HTTP_HEADER_PATTERN); + HttpHeaderPattern.setLabel("HTTP Header Pattern"); + HttpHeaderPattern.setHelpText("If a HTTP request header matches the given pattern the condition will be true." + + "Can be used to specify trusted networks via: X-Forwarded-Host: (1.2.3.4|1.2.3.5)." + + "In this case requests from 1.2.3.4 and 1.2.3.5 come from a trusted source."); + HttpHeaderPattern.setDefaultValue(""); + + ProviderConfigProperty negateOutcome = new ProviderConfigProperty(); + negateOutcome.setType(ProviderConfigProperty.BOOLEAN_TYPE); + negateOutcome.setName(NEGATE_OUTCOME); + negateOutcome.setLabel("Negate"); + negateOutcome.setHelpText("Send false if the given pattern matches."); + + return Arrays.asList(HttpHeaderPattern, negateOutcome); + } + + @Override + public ConditionalAuthenticator getSingleton() { + return ConditionalHttpHeaderAuthenticator.SINGLETON; + } + +} \ No newline at end of file diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory index 999a7696b605..b8f68623ffbc 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -26,6 +26,7 @@ org.keycloak.authentication.authenticators.conditional.ConditionalRoleAuthentica org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalUserAttributeValueFactory +org.keycloak.authentication.authenticators.conditional.ConditionalHttpHeaderAuthenticatorFactory org.keycloak.authentication.authenticators.directgrant.ValidateOTP org.keycloak.authentication.authenticators.directgrant.ValidatePassword org.keycloak.authentication.authenticators.directgrant.ValidateUsername diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/AllowDenyAuthenticatorTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/AllowDenyAuthenticatorTest.java index 706d7ce073c7..6edc06d77236 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/AllowDenyAuthenticatorTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/AllowDenyAuthenticatorTest.java @@ -7,6 +7,7 @@ import org.keycloak.authentication.authenticators.access.DenyAccessAuthenticatorFactory; import org.keycloak.authentication.authenticators.browser.PasswordFormFactory; import org.keycloak.authentication.authenticators.browser.UsernameFormFactory; +import org.keycloak.authentication.authenticators.conditional.ConditionalHttpHeaderAuthenticatorFactory; import org.keycloak.authentication.authenticators.conditional.ConditionalRoleAuthenticatorFactory; import org.keycloak.events.Details; import org.keycloak.events.Errors; @@ -26,6 +27,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.keycloak.testsuite.forms.BrowserFlowTest.revertFlows; +import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_PORT; /** * @author Martin Bartos @@ -293,6 +295,107 @@ public void testSkipOtherExecutionsIfUserHasRoleCondition() { } } + /** + * Test httpHeaderCondition with mathing pattern + */ + @Test + public void testDenyAccessWithHttpHeaderConditionMatch() { + String pattern = "Host: localhost:" + AUTH_SERVER_PORT; + boolean negateOutcome = false; + boolean expectedOutcome = false; + denyAccessWithHttpHeader(pattern, negateOutcome, expectedOutcome); + } + + /** + * Test httpHeaderCondition with negation and matching pattern + */ + @Test + public void testDenyAccessWithNegateHttpHeaderConditionMatch() { + String pattern = "Host: localhost:" + AUTH_SERVER_PORT; + boolean negateOutcome = true; + boolean expectedOutcome = true; + denyAccessWithHttpHeader(pattern, negateOutcome, expectedOutcome); + } + + /** + * Test httpHeaderCondition with no matching pattern + */ + @Test + public void testDenyAccessWithHttpHeaderConditionNoMatch() { + String pattern = "Host: nolocalhost:" + AUTH_SERVER_PORT; + boolean negateOutcome = false; + boolean expectedOutcome = true; + denyAccessWithHttpHeader(pattern, negateOutcome, expectedOutcome); + } + + /** + * Test httpHeaderCondition with negation and no matching pattern + */ + @Test + public void testDenyAccessWithNegateHttpHeaderConditionNoMatch() { + String pattern = "Host: nolocalhost:" + AUTH_SERVER_PORT; + boolean negateOutcome = true; + boolean expectedOutcome = false; + denyAccessWithHttpHeader(pattern, negateOutcome, expectedOutcome); + } + + /** + * Helper method to configure the Browser flow and call the expected Outcome method + */ + private void denyAccessWithHttpHeader(String httpHeaderPattern, boolean negateOutcome, boolean loginPossible) { + final String flowAlias = "browser - httpHeader condition"; + final String errorMessage = "You are at the wrong location"; + final String user = "john-doh@localhost"; + + Map attributeConfigMap = new HashMap<>(); + attributeConfigMap.put(ConditionalHttpHeaderAuthenticatorFactory.HTTP_HEADER_PATTERN, httpHeaderPattern); + attributeConfigMap.put(ConditionalHttpHeaderAuthenticatorFactory.NEGATE_OUTCOME, Boolean.toString(negateOutcome)); + + Map denyAccessConfigMap = new HashMap<>(); + denyAccessConfigMap.put(DenyAccessAuthenticatorFactory.ERROR_MESSAGE, errorMessage); + + configureBrowserFlowWithDenyAccessInConditionalFlow(flowAlias, ConditionalHttpHeaderAuthenticatorFactory.PROVIDER_ID ,attributeConfigMap, denyAccessConfigMap); + + try{ + if (loginPossible) { + + final String userId = testRealm().users().search(user).get(0).getId(); + + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.assertCurrent(); + loginUsernameOnlyPage.login(user); + + passwordPage.assertCurrent(); + passwordPage.login("password"); + + events.expectLogin() + .user(userId) + .detail(Details.USERNAME, user) + .removeDetail(Details.CONSENT) + .assertEvent(); + } else { + + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.assertCurrent(); + loginUsernameOnlyPage.login(user); + + errorPage.assertCurrent(); + assertThat(errorPage.getError(), is(errorMessage)); + + events.expectLogin() + .user((String) null) + .session((String) null) + .error(Errors.ACCESS_DENIED) + .detail(Details.USERNAME, user) + .removeDetail(Details.CONSENT) + .assertEvent(); + } + } finally { + revertFlows(testRealm(), flowAlias); + } + + } + /** * This flow contains: * UsernameForm REQUIRED