From 5a9068e73206010ec8e8430d3d936d966fb71d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Barto=C5=A1?= Date: Mon, 23 Nov 2020 18:20:25 +0100 Subject: [PATCH] KEYCLOAK-16401 Deny/Allow access in a conditional context --- .../AuthenticationFlowError.java | 4 +- .../access/AllowAccessAuthenticator.java | 66 +++ .../AllowAccessAuthenticatorFactory.java | 101 +++++ .../access/DenyAccessAuthenticator.java | 79 ++++ .../DenyAccessAuthenticatorFactory.java | 111 +++++ .../ConditionalRoleAuthenticator.java | 4 +- .../ConditionalRoleAuthenticatorFactory.java | 33 +- ...onditionalUserConfiguredAuthenticator.java | 8 +- .../keycloak/services/messages/Messages.java | 3 + ...ycloak.authentication.AuthenticatorFactory | 4 +- .../ConditionalUserAttributeValue.java | 11 +- .../admin/authentication/ProvidersTest.java | 6 + .../forms/AllowDenyAuthenticatorTest.java | 382 ++++++++++++++++++ .../testsuite/forms/BrowserFlowTest.java | 17 +- .../login/messages/messages_en.properties | 1 + 15 files changed, 794 insertions(+), 36 deletions(-) create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/access/AllowAccessAuthenticator.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/access/AllowAccessAuthenticatorFactory.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/access/DenyAccessAuthenticator.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/access/DenyAccessAuthenticatorFactory.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/AllowDenyAuthenticatorTest.java diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java index 1d600525e2..e75c39a770 100755 --- a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java +++ b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java @@ -44,5 +44,7 @@ public enum AuthenticationFlowError { IDENTITY_PROVIDER_NOT_FOUND, IDENTITY_PROVIDER_DISABLED, IDENTITY_PROVIDER_ERROR, - DISPLAY_NOT_SUPPORTED + DISPLAY_NOT_SUPPORTED, + + ACCESS_DENIED } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/access/AllowAccessAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/access/AllowAccessAuthenticator.java new file mode 100644 index 0000000000..9f37c40615 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/access/AllowAccessAuthenticator.java @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.authentication.authenticators.access; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +/** + * Authenticator will always successfully authenticate. + * Useful for example in the conditional flows to be used after satisfying the previous conditions. + * + * @author Martin Bartos + */ +public class AllowAccessAuthenticator implements Authenticator { + private final Logger log = Logger.getLogger(AllowAccessAuthenticator.class); + + @Override + public void authenticate(AuthenticationFlowContext context) { + log.trace("Explicitly allowed access to the resource."); + context.success(); + } + + @Override + public void action(AuthenticationFlowContext context) { + + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return false; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + + } + + @Override + public void close() { + + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/access/AllowAccessAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/access/AllowAccessAuthenticatorFactory.java new file mode 100644 index 0000000000..2c237995c6 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/access/AllowAccessAuthenticatorFactory.java @@ -0,0 +1,101 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.authentication.authenticators.access; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +/** + * @author Martin Bartos + */ +public class AllowAccessAuthenticatorFactory implements AuthenticatorFactory { + private final static AllowAccessAuthenticator SINGLETON = new AllowAccessAuthenticator(); + public static final String PROVIDER_ID = "allow-access-authenticator"; + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public String getDisplayType() { + return "Allow access"; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return false; + } + + private static final AuthenticationExecutionModel.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 "Authenticator will always successfully authenticate. Useful for example in the conditional flows to be used after satisfying the previous conditions"; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/access/DenyAccessAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/access/DenyAccessAuthenticator.java new file mode 100644 index 0000000000..425ed55238 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/access/DenyAccessAuthenticator.java @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.authentication.authenticators.access; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.events.Errors; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.messages.Messages; + +import javax.ws.rs.core.Response; +import java.util.Optional; + +/** + * Explicitly deny access to the resources. + * Useful for example in the conditional flows to be used after satisfying the previous conditions. after satisfying conditions in the conditional flow. + * + * @author Martin Bartos + */ +public class DenyAccessAuthenticator implements Authenticator { + + @Override + public void authenticate(AuthenticationFlowContext context) { + String errorMessage = Optional.ofNullable(context.getAuthenticatorConfig()) + .map(AuthenticatorConfigModel::getConfig) + .map(f -> f.get(DenyAccessAuthenticatorFactory.ERROR_MESSAGE)) + .orElse(Messages.ACCESS_DENIED); + + context.getEvent().error(Errors.ACCESS_DENIED); + Response challenge = context.form() + .setError(errorMessage) + .createErrorPage(Response.Status.UNAUTHORIZED); + context.failure(AuthenticationFlowError.ACCESS_DENIED, challenge); + } + + @Override + public void action(AuthenticationFlowContext context) { + + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return false; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + + } + + @Override + public void close() { + + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/access/DenyAccessAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/access/DenyAccessAuthenticatorFactory.java new file mode 100644 index 0000000000..262d494de1 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/access/DenyAccessAuthenticatorFactory.java @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.authentication.authenticators.access; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.Collections; +import java.util.List; + +/** + * @author Martin Bartos + */ +public class DenyAccessAuthenticatorFactory implements AuthenticatorFactory { + private static final DenyAccessAuthenticator SINGLETON = new DenyAccessAuthenticator(); + public static final String PROVIDER_ID = "deny-access-authenticator"; + + public static final String ERROR_MESSAGE = "denyErrorMessage"; + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public String getDisplayType() { + return "Deny access"; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return true; + } + + private static final AuthenticationExecutionModel.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 "Access will be always denied. Useful for example in the conditional flows to be used after satisfying the previous conditions"; + } + + @Override + public List getConfigProperties() { + ProviderConfigProperty errorMessage = new ProviderConfigProperty(); + errorMessage.setType(ProviderConfigProperty.STRING_TYPE); + errorMessage.setName(ERROR_MESSAGE); + errorMessage.setLabel("Error message"); + errorMessage.setHelpText("Error message which will be shown to the user. " + + "You can directly define particular message or property, which will be used for mapping the error message f.e `deny-access-role1`. " + + "If the field is blank, default property 'access-denied' is used."); + return Collections.singletonList(errorMessage); + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalRoleAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalRoleAuthenticator.java index 05b54eb9c6..2720a2c660 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalRoleAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalRoleAuthenticator.java @@ -20,12 +20,14 @@ public class ConditionalRoleAuthenticator implements ConditionalAuthenticator { AuthenticatorConfigModel authConfig = context.getAuthenticatorConfig(); if (user != null && authConfig!=null && authConfig.getConfig()!=null) { String requiredRole = authConfig.getConfig().get(ConditionalRoleAuthenticatorFactory.CONDITIONAL_USER_ROLE); + boolean negateOutput = Boolean.parseBoolean(authConfig.getConfig().get(ConditionalRoleAuthenticatorFactory.CONF_NEGATE)); RoleModel role = KeycloakModelUtils.getRoleFromString(realm, requiredRole); if (role == null) { logger.errorv("Invalid role name submitted: {0}", requiredRole); return false; } - return user.hasRole(role); + + return negateOutput != user.hasRole(role); } return false; } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalRoleAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalRoleAuthenticatorFactory.java index f66d4f257b..5bab6e55d1 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalRoleAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalRoleAuthenticatorFactory.java @@ -5,25 +5,15 @@ import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationExecutionModel.Requirement; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.provider.ProviderConfigProperty; -import org.keycloak.provider.ProviderConfigurationBuilder; -import java.util.Collections; +import java.util.Arrays; import java.util.List; public class ConditionalRoleAuthenticatorFactory implements ConditionalAuthenticatorFactory { public static final String PROVIDER_ID = "conditional-user-role"; - protected static final String CONDITIONAL_USER_ROLE = "condUserRole"; - private static List commonConfig; - - static { - commonConfig = Collections.unmodifiableList(ProviderConfigurationBuilder.create() - .property().name(CONDITIONAL_USER_ROLE).label("User role") - .helpText("Role the user should have to execute this flow. Click 'Select Role' button to browse roles, or just type it in the textbox. To reference a client role the syntax is clientname.clientrole, i.e. myclient.myrole") - .type(ProviderConfigProperty.ROLE_TYPE).add() - .build() - ); - } + public static final String CONDITIONAL_USER_ROLE = "condUserRole"; + public static final String CONF_NEGATE = "negate"; @Override public void init(Scope config) { @@ -61,7 +51,8 @@ public class ConditionalRoleAuthenticatorFactory implements ConditionalAuthentic } private static final Requirement[] REQUIREMENT_CHOICES = { - AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.DISABLED + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED }; @Override @@ -81,7 +72,19 @@ public class ConditionalRoleAuthenticatorFactory implements ConditionalAuthentic @Override public List getConfigProperties() { - return commonConfig; + ProviderConfigProperty role = new ProviderConfigProperty(); + role.setType(ProviderConfigProperty.ROLE_TYPE); + role.setName(CONDITIONAL_USER_ROLE); + role.setLabel("User role"); + role.setHelpText("Role the user should have to execute this flow. Click 'Select Role' button to browse roles, or just type it in the textbox. To reference a client role the syntax is clientname.clientrole, i.e. myclient.myrole"); + + ProviderConfigProperty negateOutput = new ProviderConfigProperty(); + negateOutput.setType(ProviderConfigProperty.BOOLEAN_TYPE); + negateOutput.setName(CONF_NEGATE); + negateOutput.setLabel("Negate output"); + negateOutput.setHelpText("Apply a NOT to the check result. When this is true, then the condition will evaluate to true just if user does NOT have the specified role. When this is false, the condition will evaluate to true just if user has the specified role"); + + return Arrays.asList(role, negateOutput); } @Override diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalUserConfiguredAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalUserConfiguredAuthenticator.java index b791f1b262..5ab12804d7 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalUserConfiguredAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalUserConfiguredAuthenticator.java @@ -25,7 +25,7 @@ public class ConditionalUserConfiguredAuthenticator implements ConditionalAuthen List alternativeExecutions = new LinkedList<>(); context.getRealm().getAuthenticationExecutionsStream(flowId) //Check if the execution's authenticator is a conditional authenticator, as they must not be evaluated here. - .filter(e -> isConditionalExecution(context, e)) + .filter(e -> !isConditionalExecution(context, e)) .filter(e -> !Objects.equals(context.getExecution().getId(), e.getId()) && !e.isAuthenticatorFlow()) .forEachOrdered(e -> { if (e.isRequired()) { @@ -47,11 +47,9 @@ public class ConditionalUserConfiguredAuthenticator implements ConditionalAuthen .getProviderFactory(Authenticator.class, e.getAuthenticator()); if (factory != null) { Authenticator auth = factory.create(context.getSession()); - if (auth instanceof ConditionalAuthenticator) { - return false; - } + return (auth instanceof ConditionalAuthenticator); } - return true; + return false; } private boolean isConfiguredFor(AuthenticationExecutionModel model, AuthenticationFlowContext context) { diff --git a/services/src/main/java/org/keycloak/services/messages/Messages.java b/services/src/main/java/org/keycloak/services/messages/Messages.java index 46fd798660..03ca174158 100755 --- a/services/src/main/java/org/keycloak/services/messages/Messages.java +++ b/services/src/main/java/org/keycloak/services/messages/Messages.java @@ -259,6 +259,9 @@ public class Messages { public static final String WEBAUTHN_ERROR_REGISTER_VERIFICATION = "webauthn-error-register-verification"; public static final String WEBAUTHN_ERROR_USER_NOT_FOUND = "webauthn-error-user-not-found"; + // Conditions in Conditional Flow + public static final String ACCESS_DENIED = "access-denied"; + public static final String DELETE_ACCOUNT_LACK_PRIVILEDGES = "deletingAccountForbidden"; public static final String DELETE_ACCOUNT_ERROR = "errorDeletingAccount"; 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 1112781810..1bef1797fc 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 @@ -49,4 +49,6 @@ org.keycloak.authentication.authenticators.challenge.BasicAuthAuthenticatorFacto org.keycloak.authentication.authenticators.challenge.BasicAuthOTPAuthenticatorFactory org.keycloak.authentication.authenticators.challenge.NoCookieFlowRedirectAuthenticatorFactory org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory -org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory \ No newline at end of file +org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory +org.keycloak.authentication.authenticators.access.DenyAccessAuthenticatorFactory +org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory \ No newline at end of file diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/authentication/ConditionalUserAttributeValue.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/authentication/ConditionalUserAttributeValue.java index 93262bede0..cea590ef18 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/authentication/ConditionalUserAttributeValue.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/authentication/ConditionalUserAttributeValue.java @@ -8,7 +8,6 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; -import java.util.List; import java.util.Map; import java.util.Objects; @@ -19,8 +18,6 @@ public class ConditionalUserAttributeValue implements ConditionalAuthenticator { @Override public boolean matchCondition(AuthenticationFlowContext context) { - boolean result = false; - // Retrieve configuration Map config = context.getAuthenticatorConfig().getConfig(); String attributeName = config.get(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_NAME); @@ -32,12 +29,8 @@ public class ConditionalUserAttributeValue implements ConditionalAuthenticator { throw new AuthenticationFlowException("authenticator: " + ConditionalUserAttributeValueFactory.PROVIDER_ID, AuthenticationFlowError.UNKNOWN_USER); } - result = user.getAttributeStream(attributeName).anyMatch(attr -> Objects.equals(attr, attributeValue)); - if (negateOutput) { - result = !result; - } - - return result; + boolean result = user.getAttributeStream(attributeName).anyMatch(attr -> Objects.equals(attr, attributeValue)); + return negateOutput != result; } @Override diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java index 202b640d8a..0f2a98d8fc 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java @@ -216,6 +216,12 @@ public class ProvidersTest extends AbstractAuthenticationTest { "Set a user attribute"); addProviderInfo(result, "idp-detect-existing-broker-user", "Detect existing broker user", "Detect if there is an existing Keycloak account with same email like identity provider. If no, throw an error."); + + addProviderInfo(result, "deny-access-authenticator", "Deny access", + "Access will be always denied. Useful for example in the conditional flows to be used after satisfying the previous conditions"); + addProviderInfo(result, "allow-access-authenticator", "Allow access", + "Authenticator will always successfully authenticate. Useful for example in the conditional flows to be used after satisfying the previous conditions"); + return result; } 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 new file mode 100644 index 0000000000..6882590989 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/AllowDenyAuthenticatorTest.java @@ -0,0 +1,382 @@ +package org.keycloak.testsuite.forms; + +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory; +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.ConditionalRoleAuthenticatorFactory; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; +import org.keycloak.testsuite.authentication.ConditionalUserAttributeValueFactory; +import org.keycloak.testsuite.pages.ErrorPage; +import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; +import org.keycloak.testsuite.pages.PasswordPage; +import org.keycloak.testsuite.util.FlowUtil; + +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE; +import static org.keycloak.testsuite.forms.BrowserFlowTest.revertFlows; + +/** + * @author Martin Bartos + */ +@AuthServerContainerExclude(REMOTE) +public class AllowDenyAuthenticatorTest extends AbstractTestRealmKeycloakTest { + + @Page + protected LoginUsernameOnlyPage loginUsernameOnlyPage; + + @Page + protected PasswordPage passwordPage; + + @Page + protected ErrorPage errorPage; + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + } + + @Test + public void testDenyAccessWithDefaultMessage() { + testErrorMessageInDenyAccess(null, "Access denied"); + } + + @Test + public void testDenyAccessWithParticularMessage() { + final String message = "You are not allowed to authenticate."; + testErrorMessageInDenyAccess(message, message); + } + + @Test + public void testDenyAccessWithProperty() { + final String property = "brokerLinkingSessionExpired"; + final String message = "Requested broker account linking, but current session is no longer valid."; + + testErrorMessageInDenyAccess(property, message); + } + + @Test + public void testDenyAccessWithNotExistingProperty() { + final String property = "not-existing-property"; + final String message = "not-existing-property"; + + testErrorMessageInDenyAccess(property, message); + } + + /* Helper method for error messaged in Deny Authenticator */ + private void testErrorMessageInDenyAccess(String setUpMessage, String expectedMessage) { + final String flowAlias = "browser - deny defaultMessage"; + final String userWithoutAttribute = "test-user@localhost"; + + Map denyAccessConfigMap = new HashMap<>(); + if (setUpMessage != null) { + denyAccessConfigMap.put(DenyAccessAuthenticatorFactory.ERROR_MESSAGE, setUpMessage); + } + + configureBrowserFlowWithDenyAccess(flowAlias, denyAccessConfigMap); + + try { + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.assertCurrent(); + loginUsernameOnlyPage.login(userWithoutAttribute); + + errorPage.assertCurrent(); + assertThat(errorPage.getError(), is(expectedMessage)); + + events.expectLogin() + .user((String) null) + .session((String) null) + .error(Errors.ACCESS_DENIED) + .detail(Details.USERNAME, userWithoutAttribute) + .removeDetail(Details.CONSENT) + .assertEvent(); + } finally { + revertFlows(testRealm(), flowAlias); + } + } + + /** + * This test checks that if user does not have specific attribute, then the access is denied. + */ + @Test + public void testDenyAccessWithNegateUserAttributeCondition() { + final String flowAlias = "browser - user attribute condition"; + final String userWithoutAttribute = "test-user@localhost"; + final String errorMessage = "You don't have necessary attribute."; + + Map attributeConfigMap = new HashMap<>(); + attributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_NAME, "attribute"); + attributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_EXPECTED_VALUE, "value"); + attributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_NOT, "true"); + + Map denyAccessConfigMap = new HashMap<>(); + denyAccessConfigMap.put(DenyAccessAuthenticatorFactory.ERROR_MESSAGE, errorMessage); + + configureBrowserFlowWithDenyAccessInConditionalFlow(flowAlias, ConditionalUserAttributeValueFactory.PROVIDER_ID, attributeConfigMap, denyAccessConfigMap); + + try { + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.assertCurrent(); + loginUsernameOnlyPage.login(userWithoutAttribute); + + errorPage.assertCurrent(); + assertThat(errorPage.getError(), is(errorMessage)); + + events.expectLogin() + .user((String) null) + .session((String) null) + .error(Errors.ACCESS_DENIED) + .detail(Details.USERNAME, userWithoutAttribute) + .removeDetail(Details.CONSENT) + .assertEvent(); + } finally { + revertFlows(testRealm(), flowAlias); + } + } + + /** + * Deny access, if user has defined the role and print error message. + */ + @Test + public void testDenyAccessWithRoleCondition() { + denyAccessWithRoleCondition(false); + } + + /** + * Deny access, if user has NOT defined the role and print error message. + */ + @Test + public void testDenyAccessWithNegateRoleCondition() { + denyAccessWithRoleCondition(true); + } + + /** + * Helper method for deny access with role condition + * + * @param negateOutput + */ + private void denyAccessWithRoleCondition(boolean negateOutput) { + final String flowAlias = "browser-deny"; + final String userWithRole = "test-user@localhost"; + final String userWithoutRole = "john-doh@localhost"; + final String role = "offline_access"; + final String errorMessage = "Your account doesn't have the required role"; + + Map config = new HashMap<>(); + config.put(ConditionalRoleAuthenticatorFactory.CONDITIONAL_USER_ROLE, role); + config.put(ConditionalRoleAuthenticatorFactory.CONF_NEGATE, Boolean.toString(negateOutput)); + + Map denyConfig = new HashMap<>(); + denyConfig.put(DenyAccessAuthenticatorFactory.ERROR_MESSAGE, errorMessage); + + configureBrowserFlowWithDenyAccessInConditionalFlow(flowAlias, ConditionalRoleAuthenticatorFactory.PROVIDER_ID, config, denyConfig); + + denyAccessInConditionalFlow(flowAlias, + negateOutput ? userWithoutRole : userWithRole, + negateOutput ? userWithRole : userWithoutRole, + errorMessage + ); + } + + /** + * Helper method for deny access with two opposites cases + */ + private void denyAccessInConditionalFlow(String flowAlias, String userCondMatch, String userCondNotMatch, String errorMessage) { + try { + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.assertCurrent(); + loginUsernameOnlyPage.login(userCondMatch); + + errorPage.assertCurrent(); + assertThat(errorPage.getError(), is(errorMessage)); + + events.expectLogin() + .user((String) null) + .session((String) null) + .error(Errors.ACCESS_DENIED) + .detail(Details.USERNAME, userCondMatch) + .removeDetail(Details.CONSENT) + .assertEvent(); + + final String userCondNotMatchId = testRealm().users().search(userCondNotMatch).get(0).getId(); + + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.assertCurrent(); + loginUsernameOnlyPage.login(userCondNotMatch); + + passwordPage.assertCurrent(); + passwordPage.login("password"); + + events.expectLogin().user(userCondNotMatchId) + .detail(Details.USERNAME, userCondNotMatch) + .removeDetail(Details.CONSENT) + .assertEvent(); + } finally { + revertFlows(testRealm(), flowAlias); + } + } + + /** + * This test checks that if user has NOT the required role, the user has to enter the password + */ + @Test + public void testSkipExecutionUserHasNotRoleCondition() { + final String userWithoutRole = "john-doh@localhost"; + final String role = "offline_access"; + final String newFlowAlias = "browser - allow skip"; + + Map configMap = new HashMap<>(); + configMap.put(ConditionalRoleAuthenticatorFactory.CONDITIONAL_USER_ROLE, role); + configMap.put(ConditionalRoleAuthenticatorFactory.CONF_NEGATE, "false"); + + configureBrowserFlowWithSkipExecutionInConditionalFlow(newFlowAlias, ConditionalRoleAuthenticatorFactory.PROVIDER_ID, configMap); + try { + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.assertCurrent(); + loginUsernameOnlyPage.login(userWithoutRole); + + final String testUserWithoutRoleId = testRealm().users().search(userWithoutRole).get(0).getId(); + + passwordPage.assertCurrent(); + passwordPage.login("password"); + + events.expectLogin() + .user(testUserWithoutRoleId) + .detail(Details.USERNAME, userWithoutRole) + .removeDetail(Details.CONSENT) + .assertEvent(); + } finally { + revertFlows(testRealm(), newFlowAlias); + } + } + + /** + * This test checks that if user has the required role, the user skips the other executions + */ + @Test + public void testSkipOtherExecutionsIfUserHasRoleCondition() { + final String userWithRole = "test-user@localhost"; + final String role = "offline_access"; + final String newFlowAlias = "browser - allow skip"; + + Map configMap = new HashMap<>(); + configMap.put(ConditionalRoleAuthenticatorFactory.CONDITIONAL_USER_ROLE, role); + configMap.put(ConditionalRoleAuthenticatorFactory.CONF_NEGATE, "false"); + + configureBrowserFlowWithSkipExecutionInConditionalFlow(newFlowAlias, ConditionalRoleAuthenticatorFactory.PROVIDER_ID, configMap); + try { + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.assertCurrent(); + loginUsernameOnlyPage.login(userWithRole); + + final String testUserWithRoleId = testRealm().users().search(userWithRole).get(0).getId(); + + events.expectLogin() + .user(testUserWithRoleId) + .detail(Details.USERNAME, userWithRole) + .removeDetail(Details.CONSENT) + .assertEvent(); + } finally { + revertFlows(testRealm(), newFlowAlias); + } + } + + /** + * This flow contains: + * UsernameForm REQUIRED + * Subflow CONDITIONAL + * ** condition + * ** Deny Access REQUIRED + * Password REQUIRED + * + * @param newFlowAlias + * @param conditionProviderId + * @param conditionConfig + * @param denyConfig + */ + private void configureBrowserFlowWithDenyAccessInConditionalFlow(String newFlowAlias, String conditionProviderId, Map conditionConfig, Map denyConfig) { + testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); + testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session) + .selectFlow(newFlowAlias) + .inForms(forms -> forms + .clear() + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID) + .addSubFlowExecution(AuthenticationExecutionModel.Requirement.CONDITIONAL, subflow -> subflow + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, conditionProviderId, config -> config.setConfig(conditionConfig)) + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, DenyAccessAuthenticatorFactory.PROVIDER_ID, config -> config.setConfig(denyConfig)) + ) + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID) + ) + .defineAsBrowserFlow() // Activate this new flow + ); + } + + /** + * This flow contains: + * UsernameForm REQUIRED + * Deny Access REQUIRED + * + * @param newFlowAlias + * @param denyConfig + */ + private void configureBrowserFlowWithDenyAccess(String newFlowAlias, Map denyConfig) { + testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); + testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session) + .selectFlow(newFlowAlias) + .inForms(forms -> forms + .clear() + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID) + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, DenyAccessAuthenticatorFactory.PROVIDER_ID, config -> config.setConfig(denyConfig)) + ) + .defineAsBrowserFlow() // Activate this new flow + ); + } + + /** + * This flow contains: + * UsernameForm REQUIRED + * Subflow REQUIRED + * ** subflow ALTERNATIVE + * *** conditional-subflow CONDITIONAL + * **** condition REQUIRED + * **** Allow Access REQUIRED + * ** Password ALTERNATIVE + * + * @param newFlowAlias + * @param conditionProviderId + * @param configMap + */ + private void configureBrowserFlowWithSkipExecutionInConditionalFlow(String newFlowAlias, String conditionProviderId, Map configMap) { + testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); + testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session) + .selectFlow(newFlowAlias) + .inForms(forms -> forms + .clear() + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID) + .addSubFlowExecution(AuthenticationExecutionModel.Requirement.REQUIRED, subflow -> subflow + .addSubFlowExecution(AuthenticationExecutionModel.Requirement.ALTERNATIVE, flow -> flow + .addSubFlowExecution(AuthenticationExecutionModel.Requirement.CONDITIONAL, condFlow -> condFlow + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, conditionProviderId, config -> config.setConfig(configMap)) + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, AllowAccessAuthenticatorFactory.PROVIDER_ID))) + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.ALTERNATIVE, PasswordFormFactory.PROVIDER_ID) + + )) + .defineAsBrowserFlow() // Activate this new flow + ); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserFlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserFlowTest.java index 9a8892df18..66339c0dba 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserFlowTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserFlowTest.java @@ -24,7 +24,13 @@ import org.keycloak.models.AuthenticationExecutionModel.Requirement; import org.keycloak.models.Constants; import org.keycloak.models.utils.DefaultAuthenticationFlows; import org.keycloak.models.utils.TimeBasedOTP; -import org.keycloak.representations.idm.*; +import org.keycloak.representations.idm.AuthenticationFlowRepresentation; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RequiredActionProviderRepresentation; +import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation; +import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.ActionURIUtils; import org.keycloak.testsuite.AssertEvents; @@ -40,7 +46,10 @@ import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.pages.LoginTotpPage; import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; import org.keycloak.testsuite.pages.PasswordPage; -import org.keycloak.testsuite.util.*; +import org.keycloak.testsuite.util.FlowUtil; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.RoleBuilder; +import org.keycloak.testsuite.util.URLUtils; import org.keycloak.testsuite.authentication.ConditionalUserAttributeValueFactory; import org.keycloak.testsuite.authentication.SetUserAttributeAuthenticatorFactory; import org.openqa.selenium.By; @@ -52,8 +61,8 @@ import java.util.Collections; import java.util.List; import java.util.function.Consumer; +import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; -import static org.junit.Assert.assertThat; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE; import static org.keycloak.testsuite.broker.SocialLoginTest.Provider.GITHUB; @@ -1306,7 +1315,7 @@ public class BrowserFlowTest extends AbstractTestRealmKeycloakTest { revertFlows(testRealm(), flowToDeleteAlias); } - static void revertFlows(RealmResource realmResource, String flowToDeleteAlias) { + public static void revertFlows(RealmResource realmResource, String flowToDeleteAlias) { List flows = realmResource.flows().getFlows(); // Set default browser flow diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index 9376b9603f..c834e7b3fd 100755 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -411,3 +411,4 @@ loggingOutImmediately=Logging you out immediately accountUnusable=Any subsequent use of the application will not be possible with this account userDeletedSuccessfully=User deleted successfully +access-denied=Access denied \ No newline at end of file