Extends the conditional user attribute authenticator to check the attributes of the joined groups (#20189)
Closes #20007
This commit is contained in:
parent
3daeee15f6
commit
d9b271c22a
4 changed files with 223 additions and 2 deletions
|
@ -24,7 +24,7 @@ This checks if the other executions in the flow are configured for the user.
|
||||||
The Execution requirements section includes an example of the OTP form.
|
The Execution requirements section includes an example of the OTP form.
|
||||||
|
|
||||||
`Condition - User Attribute`::
|
`Condition - User Attribute`::
|
||||||
This checks if the user has set up the required attribute.
|
This checks if the user has set up the required attribute: optionally, the check can also evaluate the group attributes.
|
||||||
There is a possibility to negate output, which means the user should not have the attribute.
|
There is a possibility to negate output, which means the user should not have the attribute.
|
||||||
The xref:proc-configuring-user-attributes_{context}[User Attributes] section shows how to add a custom attribute.
|
The xref:proc-configuring-user-attributes_{context}[User Attributes] section shows how to add a custom attribute.
|
||||||
You can provide these fields:
|
You can provide these fields:
|
||||||
|
@ -38,6 +38,9 @@ Name of the attribute to check.
|
||||||
Expected attribute value:::
|
Expected attribute value:::
|
||||||
Expected value in the attribute.
|
Expected value in the attribute.
|
||||||
|
|
||||||
|
Include group attributes:::
|
||||||
|
If On, the condition checks if any of the joined group has one attribute matching the configured name and value: this option can affect performance
|
||||||
|
|
||||||
Negate output:::
|
Negate output:::
|
||||||
You can negate the output.
|
You can negate the output.
|
||||||
In other words, the attribute should not be present.
|
In other words, the attribute should not be present.
|
||||||
|
|
|
@ -23,6 +23,7 @@ import org.keycloak.authentication.AuthenticationFlowException;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
|
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
@ -37,6 +38,7 @@ public class ConditionalUserAttributeValue implements ConditionalAuthenticator {
|
||||||
Map<String, String> config = context.getAuthenticatorConfig().getConfig();
|
Map<String, String> config = context.getAuthenticatorConfig().getConfig();
|
||||||
String attributeName = config.get(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_NAME);
|
String attributeName = config.get(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_NAME);
|
||||||
String attributeValue = config.get(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_EXPECTED_VALUE);
|
String attributeValue = config.get(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_EXPECTED_VALUE);
|
||||||
|
boolean includeGroupAttributes = Boolean.parseBoolean(config.get(ConditionalUserAttributeValueFactory.CONF_INCLUDE_GROUP_ATTRIBUTES));
|
||||||
boolean negateOutput = Boolean.parseBoolean(config.get(ConditionalUserAttributeValueFactory.CONF_NOT));
|
boolean negateOutput = Boolean.parseBoolean(config.get(ConditionalUserAttributeValueFactory.CONF_NOT));
|
||||||
|
|
||||||
UserModel user = context.getUser();
|
UserModel user = context.getUser();
|
||||||
|
@ -45,6 +47,9 @@ public class ConditionalUserAttributeValue implements ConditionalAuthenticator {
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean result = user.getAttributeStream(attributeName).anyMatch(attr -> Objects.equals(attr, attributeValue));
|
boolean result = user.getAttributeStream(attributeName).anyMatch(attr -> Objects.equals(attr, attributeValue));
|
||||||
|
if (!result && includeGroupAttributes) {
|
||||||
|
result = KeycloakModelUtils.resolveAttribute(user, attributeName, true).stream().anyMatch(attr -> Objects.equals(attr, attributeValue));
|
||||||
|
}
|
||||||
return negateOutput != result;
|
return negateOutput != result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,7 @@ public class ConditionalUserAttributeValueFactory implements ConditionalAuthenti
|
||||||
|
|
||||||
public static final String CONF_ATTRIBUTE_NAME = "attribute_name";
|
public static final String CONF_ATTRIBUTE_NAME = "attribute_name";
|
||||||
public static final String CONF_ATTRIBUTE_EXPECTED_VALUE = "attribute_expected_value";
|
public static final String CONF_ATTRIBUTE_EXPECTED_VALUE = "attribute_expected_value";
|
||||||
|
public static final String CONF_INCLUDE_GROUP_ATTRIBUTES = "include_group_attributes";
|
||||||
public static final String CONF_NOT = "not";
|
public static final String CONF_NOT = "not";
|
||||||
|
|
||||||
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
|
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
|
||||||
|
@ -96,13 +97,19 @@ public class ConditionalUserAttributeValueFactory implements ConditionalAuthenti
|
||||||
authNoteExpectedValue.setLabel("Expected attribute value");
|
authNoteExpectedValue.setLabel("Expected attribute value");
|
||||||
authNoteExpectedValue.setHelpText("Expected value in the attribute");
|
authNoteExpectedValue.setHelpText("Expected value in the attribute");
|
||||||
|
|
||||||
|
ProviderConfigProperty includeGroupAttributes = new ProviderConfigProperty();
|
||||||
|
includeGroupAttributes.setType(ProviderConfigProperty.BOOLEAN_TYPE);
|
||||||
|
includeGroupAttributes.setName(CONF_INCLUDE_GROUP_ATTRIBUTES);
|
||||||
|
includeGroupAttributes.setLabel("Include group attributes");
|
||||||
|
includeGroupAttributes.setHelpText("If On, the condition checks if any of the joined groups has one attribute matching the configured name and value (this option can affect performance)");
|
||||||
|
|
||||||
ProviderConfigProperty negateOutput = new ProviderConfigProperty();
|
ProviderConfigProperty negateOutput = new ProviderConfigProperty();
|
||||||
negateOutput.setType(ProviderConfigProperty.BOOLEAN_TYPE);
|
negateOutput.setType(ProviderConfigProperty.BOOLEAN_TYPE);
|
||||||
negateOutput.setName(CONF_NOT);
|
negateOutput.setName(CONF_NOT);
|
||||||
negateOutput.setLabel("Negate output");
|
negateOutput.setLabel("Negate output");
|
||||||
negateOutput.setHelpText("Apply a not to the check result");
|
negateOutput.setHelpText("Apply a not to the check result");
|
||||||
|
|
||||||
return Arrays.asList(authNoteName, authNoteExpectedValue, negateOutput);
|
return Arrays.asList(authNoteName, authNoteExpectedValue, includeGroupAttributes, negateOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -0,0 +1,206 @@
|
||||||
|
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.events.Details;
|
||||||
|
import org.keycloak.events.Errors;
|
||||||
|
import org.keycloak.models.AuthenticationExecutionModel;
|
||||||
|
import org.keycloak.representations.idm.GroupRepresentation;
|
||||||
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
|
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||||
|
import org.keycloak.testsuite.AssertEvents;
|
||||||
|
import org.keycloak.testsuite.admin.ApiUtil;
|
||||||
|
import org.keycloak.authentication.authenticators.conditional.ConditionalUserAttributeValueFactory;
|
||||||
|
import org.keycloak.testsuite.pages.ErrorPage;
|
||||||
|
import org.keycloak.testsuite.pages.LoginUsernameOnlyPage;
|
||||||
|
import org.keycloak.testsuite.pages.PasswordPage;
|
||||||
|
import org.keycloak.testsuite.util.AccountHelper;
|
||||||
|
import org.keycloak.testsuite.util.FlowUtil;
|
||||||
|
import org.keycloak.testsuite.util.GroupBuilder;
|
||||||
|
import org.keycloak.testsuite.util.UserBuilder;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.hamcrest.MatcherAssert.assertThat;
|
||||||
|
import static org.hamcrest.Matchers.is;
|
||||||
|
import static org.keycloak.testsuite.forms.BrowserFlowTest.revertFlows;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author <a href="mailto:dmartino@redhat.com">Daniele Martinoli</a>
|
||||||
|
*/
|
||||||
|
public class ConditionalUserAttributeAuthenticatorTest extends AbstractTestRealmKeycloakTest {
|
||||||
|
|
||||||
|
private final static String X_APPROVE_ATTR = "x-approved";
|
||||||
|
private final static String X_APPROVE_ATTR_VALUE = Boolean.toString(true);
|
||||||
|
|
||||||
|
private final static String APPROVED_GROUP = "approved";
|
||||||
|
private final static String SUBGROUP = "subgroup";
|
||||||
|
|
||||||
|
private final static String APPROVED_USER = "approved";
|
||||||
|
private final static String APPROVED_BY_GROUP_USER = "approved-by-group";
|
||||||
|
private final static String APPROVED_BY_SUBGROUP_USER = "approved-by-subgroup";
|
||||||
|
private final static String PASSWORD = "password";
|
||||||
|
|
||||||
|
@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) {}
|
||||||
|
|
||||||
|
private void createUsers() {
|
||||||
|
GroupRepresentation subGroup = GroupBuilder.create().name(SUBGROUP).build();
|
||||||
|
testRealm().groups().add(subGroup);
|
||||||
|
GroupRepresentation approvedGroup = GroupBuilder.create().name(APPROVED_GROUP).subGroups(List.of(subGroup))
|
||||||
|
.attributes(Map.of(X_APPROVE_ATTR, List.of(X_APPROVE_ATTR_VALUE)))
|
||||||
|
.build();
|
||||||
|
testRealm().groups().add(approvedGroup);
|
||||||
|
|
||||||
|
UserRepresentation approved = UserBuilder.create().username(APPROVED_USER).password(PASSWORD)
|
||||||
|
.addAttribute(X_APPROVE_ATTR, X_APPROVE_ATTR_VALUE)
|
||||||
|
.build();
|
||||||
|
testRealm().users().create(approved);
|
||||||
|
|
||||||
|
UserRepresentation approvedByGroup = UserBuilder.create().username(APPROVED_BY_GROUP_USER).password(PASSWORD)
|
||||||
|
.addAttribute(X_APPROVE_ATTR, X_APPROVE_ATTR_VALUE)
|
||||||
|
.addGroups(APPROVED_GROUP)
|
||||||
|
.build();
|
||||||
|
testRealm().users().create(approvedByGroup);
|
||||||
|
|
||||||
|
UserRepresentation approvedBySubgroup = UserBuilder.create().username(APPROVED_BY_SUBGROUP_USER).password(PASSWORD)
|
||||||
|
.addAttribute(X_APPROVE_ATTR, X_APPROVE_ATTR_VALUE)
|
||||||
|
.addGroups(SUBGROUP)
|
||||||
|
.build();
|
||||||
|
testRealm().users().create(approvedBySubgroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAllowedUsersWithApprovedAttribute(){
|
||||||
|
final String flowAlias = "browser - user attribute condition";
|
||||||
|
final String errorMessage = "You don't have necessary attribute.";
|
||||||
|
|
||||||
|
createUsers();
|
||||||
|
configureBrowserFlowWithConditionalUserAttribute(flowAlias, errorMessage);
|
||||||
|
|
||||||
|
for (String user : List.of(APPROVED_USER, APPROVED_BY_GROUP_USER, APPROVED_BY_SUBGROUP_USER)) {
|
||||||
|
loginUsernameOnlyPage.open();
|
||||||
|
loginUsernameOnlyPage.assertCurrent();
|
||||||
|
loginUsernameOnlyPage.login(user);
|
||||||
|
|
||||||
|
final String testUserId = testRealm().users().search(user).get(0).getId();
|
||||||
|
|
||||||
|
passwordPage.assertCurrent();
|
||||||
|
passwordPage.login(PASSWORD);
|
||||||
|
|
||||||
|
events.expectLogin()
|
||||||
|
.user(testUserId)
|
||||||
|
.detail(Details.USERNAME, user)
|
||||||
|
.removeDetail(Details.CONSENT)
|
||||||
|
.assertEvent();
|
||||||
|
|
||||||
|
AccountHelper.logout(testRealm(), user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This test checks that if user does not have specific attribute, then the access is denied.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testDenyUserWithoutApprovedAttribute() {
|
||||||
|
final String flowAlias = "browser - user attribute condition";
|
||||||
|
final String errorMessage = "You don't have necessary attribute.";
|
||||||
|
final String user = "test-user@localhost";
|
||||||
|
|
||||||
|
configureBrowserFlowWithConditionalUserAttribute(flowAlias, errorMessage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
* Subflow CONDITIONAL
|
||||||
|
* ** conditional user attribute
|
||||||
|
* ** Allow Access REQUIRED
|
||||||
|
* Subflow CONDITIONAL
|
||||||
|
* ** conditional user attribute-negated
|
||||||
|
* ** Deny Access REQUIRED
|
||||||
|
* Password REQUIRED
|
||||||
|
*
|
||||||
|
* @param newFlowAlias
|
||||||
|
* @param conditionProviderId
|
||||||
|
* @param conditionConfig
|
||||||
|
* @param denyConfig
|
||||||
|
*/
|
||||||
|
private void configureBrowserFlowWithConditionalUserAttribute(String newFlowAlias, String errorMessage) {
|
||||||
|
Map<String, String> hasApproveAttributeConfigMap = new HashMap<>();
|
||||||
|
hasApproveAttributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_NAME, X_APPROVE_ATTR);
|
||||||
|
hasApproveAttributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_ATTRIBUTE_EXPECTED_VALUE, X_APPROVE_ATTR_VALUE);
|
||||||
|
hasApproveAttributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_INCLUDE_GROUP_ATTRIBUTES, Boolean.toString(true));
|
||||||
|
hasApproveAttributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_NOT, Boolean.toString(false));
|
||||||
|
|
||||||
|
Map<String, String> missApproveAttributeConfigMap = new HashMap<>(hasApproveAttributeConfigMap);
|
||||||
|
missApproveAttributeConfigMap.put(ConditionalUserAttributeValueFactory.CONF_NOT, Boolean.toString(true));
|
||||||
|
|
||||||
|
Map<String, String> denyAccessConfigMap = new HashMap<>();
|
||||||
|
denyAccessConfigMap.put(DenyAccessAuthenticatorFactory.ERROR_MESSAGE, errorMessage);
|
||||||
|
|
||||||
|
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,
|
||||||
|
ConditionalUserAttributeValueFactory.PROVIDER_ID,
|
||||||
|
config -> config.setConfig(hasApproveAttributeConfigMap))
|
||||||
|
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED,
|
||||||
|
AllowAccessAuthenticatorFactory.PROVIDER_ID, config -> {})
|
||||||
|
)
|
||||||
|
.addSubFlowExecution(AuthenticationExecutionModel.Requirement.CONDITIONAL, subflow -> subflow
|
||||||
|
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED,
|
||||||
|
ConditionalUserAttributeValueFactory.PROVIDER_ID,
|
||||||
|
config -> config.setConfig(missApproveAttributeConfigMap))
|
||||||
|
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED,
|
||||||
|
DenyAccessAuthenticatorFactory.PROVIDER_ID, config -> config.setConfig(denyAccessConfigMap))
|
||||||
|
)
|
||||||
|
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID)
|
||||||
|
)
|
||||||
|
.defineAsBrowserFlow() // Activate this new flow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue