KEYCLOAK-16401 Deny/Allow access in a conditional context

This commit is contained in:
Martin Bartoš 2020-11-23 18:20:25 +01:00 committed by Marek Posolda
parent cd342ad571
commit 5a9068e732
15 changed files with 794 additions and 36 deletions

View file

@ -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
}

View file

@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
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() {
}
}

View file

@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
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<ProviderConfigProperty> getConfigProperties() {
return null;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View file

@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
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() {
}
}

View file

@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
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<ProviderConfigProperty> 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() {
}
}

View file

@ -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;
}

View file

@ -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<ProviderConfigProperty> 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<ProviderConfigProperty> 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

View file

@ -25,7 +25,7 @@ public class ConditionalUserConfiguredAuthenticator implements ConditionalAuthen
List<AuthenticationExecutionModel> 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,12 +47,10 @@ public class ConditionalUserConfiguredAuthenticator implements ConditionalAuthen
.getProviderFactory(Authenticator.class, e.getAuthenticator());
if (factory != null) {
Authenticator auth = factory.create(context.getSession());
if (auth instanceof ConditionalAuthenticator) {
return (auth instanceof ConditionalAuthenticator);
}
return false;
}
}
return true;
}
private boolean isConfiguredFor(AuthenticationExecutionModel model, AuthenticationFlowContext context) {
if (model.isAuthenticatorFlow()) {

View file

@ -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";

View file

@ -50,3 +50,5 @@ org.keycloak.authentication.authenticators.challenge.BasicAuthOTPAuthenticatorFa
org.keycloak.authentication.authenticators.challenge.NoCookieFlowRedirectAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory
org.keycloak.authentication.authenticators.access.DenyAccessAuthenticatorFactory
org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory

View file

@ -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<String, String> 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

View file

@ -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;
}

View file

@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
@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<String, String> 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<String, String> 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<String, String> 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<String, String> config = new HashMap<>();
config.put(ConditionalRoleAuthenticatorFactory.CONDITIONAL_USER_ROLE, role);
config.put(ConditionalRoleAuthenticatorFactory.CONF_NEGATE, Boolean.toString(negateOutput));
Map<String, String> 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<String, String> 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<String, String> 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<String, String> conditionConfig, Map<String, String> 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<String, String> 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<String, String> 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
);
}
}

View file

@ -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<AuthenticationFlowRepresentation> flows = realmResource.flows().getFlows();
// Set default browser flow

View file

@ -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