[KEYCLOAK-18447] Dynamically select attributes based on requested scopes

This commit is contained in:
Vlastimil Elias 2021-06-18 13:48:39 +02:00 committed by Pedro Igor
parent 82491ae5d2
commit 458c841c39
10 changed files with 225 additions and 24 deletions

View file

@ -19,7 +19,6 @@
package org.keycloak.userprofile;
import static org.keycloak.userprofile.AttributeMetadata.ALWAYS_FALSE;
import static org.keycloak.userprofile.AttributeMetadata.ALWAYS_TRUE;
import java.util.ArrayList;
@ -73,8 +72,8 @@ public final class UserProfileMetadata implements Cloneable {
return addAttribute(new AttributeMetadata(name).addValidator(validators));
}
public AttributeMetadata addAttribute(String name, List<AttributeValidatorMetadata> validator, Predicate<AttributeContext> writeAllowed, Predicate<AttributeContext> required, Predicate<AttributeContext> readAllowed) {
return addAttribute(new AttributeMetadata(name, ALWAYS_TRUE, writeAllowed, required, readAllowed).addValidator(validator));
public AttributeMetadata addAttribute(String name, List<AttributeValidatorMetadata> validator, Predicate<AttributeContext> selector, Predicate<AttributeContext> writeAllowed, Predicate<AttributeContext> required, Predicate<AttributeContext> readAllowed) {
return addAttribute(new AttributeMetadata(name, selector, writeAllowed, required, readAllowed).addValidator(validator));
}
/**

View file

@ -78,7 +78,14 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
private static final String PARSED_CONFIG_COMPONENT_KEY = "kc.user.profile.metadata";
private static final String UP_PIECE_COMPONENT_CONFIG_KEY_BASE = "config-piece-";
private static boolean createRequiredForScopePredicate(AttributeContext context, List<String> requiredScopes) {
/**
* Method used for predicate which returns true if any of the configuredScopes is requested in current auth flow.
*
* @param context to get current auth flow from
* @param configuredScopes to be evaluated
* @return
*/
private static boolean requestedScopePredicate(AttributeContext context, List<String> configuredScopes) {
KeycloakSession session = context.getSession();
AuthenticationSessionModel authenticationSession = session.getContext().getAuthenticationSession();
@ -89,7 +96,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
String requestedScopesString = authenticationSession.getClientNote(OIDCLoginProtocol.SCOPE_PARAM);
ClientModel client = authenticationSession.getClient();
return getRequestedClientScopes(requestedScopesString, client).map((csm) -> csm.getName()).anyMatch(requiredScopes::contains);
return getRequestedClientScopes(requestedScopesString, client).map((csm) -> csm.getName()).anyMatch(configuredScopes::contains);
}
private String defaultRawConfig;
@ -251,7 +258,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
UPAttributeRequired rc = attrConfig.getRequired();
Predicate<AttributeContext> required = AttributeMetadata.ALWAYS_FALSE;
if (rc != null && !(UserModel.USERNAME.equals(attributeName) || UserModel.EMAIL.equals(attributeName))) {
if (rc != null && !isUsernameOrEmailAttribute(attributeName)) {
// do not take requirements from config for username and email as they are
// driven by business logic from parent!
if (rc.isAlways() || UPConfigUtils.isRoleForContext(context, rc.getRoles())) {
@ -259,7 +266,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
} else if (UPConfigUtils.canBeAuthFlowContext(context) && rc.getScopes() != null && !rc.getScopes().isEmpty()) {
// for contexts executed from auth flow and with configured scopes requirement
// we have to create required validation with scopes based selector
required = (c) -> createRequiredForScopePredicate(c, rc.getScopes());
required = (c) -> requestedScopePredicate(c, rc.getScopes());
}
validators.add(new AttributeValidatorMetadata(AttributeRequiredByMetadataValidator.ID));
@ -285,9 +292,17 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
}
}
Predicate<AttributeContext> selector = AttributeMetadata.ALWAYS_TRUE;
UPAttributeSelector sc = attrConfig.getSelector();
if (sc != null && !isUsernameOrEmailAttribute(attributeName) && UPConfigUtils.canBeAuthFlowContext(context) && sc.getScopes() != null && !sc.getScopes().isEmpty()) {
// for contexts executed from auth flow and with configured scopes selector
// we have to create correct predicate
selector = (c) -> requestedScopePredicate(c, sc.getScopes());
}
Map<String, Object> annotations = attrConfig.getAnnotations();
if (UserModel.USERNAME.equals(attributeName) || UserModel.EMAIL.equals(attributeName)) {
if (isUsernameOrEmailAttribute(attributeName)) {
if (permissions == null) {
writeAllowed = AttributeMetadata.ALWAYS_TRUE;
}
@ -305,7 +320,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
} else {
// always add validation for imuttable/read-only attributes
validators.add(new AttributeValidatorMetadata(ImmutableAttributeValidator.ID));
decoratedMetadata.addAttribute(attributeName, validators, writeAllowed, required, readAllowed).addAnnotations(annotations);
decoratedMetadata.addAttribute(attributeName, validators, selector, writeAllowed, required, readAllowed).addAnnotations(annotations);
}
}
@ -313,6 +328,10 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
}
private boolean isUsernameOrEmailAttribute(String attributeName) {
return UserModel.USERNAME.equals(attributeName) || UserModel.EMAIL.equals(attributeName);
}
private Predicate<AttributeContext> createViewAllowedPredicate(Predicate<AttributeContext> canEdit,
List<String> viewRoles) {
return ac -> UPConfigUtils.isRoleForContext(ac.getContext(), viewRoles) || canEdit.test(ac);

View file

@ -35,6 +35,8 @@ public class UPAttribute {
private UPAttributeRequired required;
/** null means everyone can view and edit the attribute */
private UPAttributePermissions permissions;
/** null means it is always selected */
private UPAttributeSelector selector;
public String getName() {
return name;
@ -83,9 +85,16 @@ public class UPAttribute {
validations.put(validator, config);
}
public UPAttributeSelector getSelector() {
return selector;
}
public void setSelector(UPAttributeSelector selector) {
this.selector = selector;
}
@Override
public String toString() {
return "UPAttribute [name=" + name + ", permissions=" + permissions + ", required=" + required + ", validations=" + validations + ", annotations="
+ annotations + "]";
return "UPAttribute [name=" + name + ", permissions=" + permissions + ", selector=" + selector + ", required=" + required + ", validations=" + validations + ", annotations=" + annotations + "]";
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2021 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.userprofile.config;
import java.util.List;
/**
* Config of the rules when attribute is selected.
*
* @author Vlastimil Elias <velias@redhat.com>
*
*/
public class UPAttributeSelector {
private List<String> scopes;
public List<String> getScopes() {
return scopes;
}
public void setScopes(List<String> scopes) {
this.scopes = scopes;
}
@Override
public String toString() {
return "UPAttributeSelector [scopes=" + scopes + "]";
}
}

View file

@ -45,7 +45,6 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.userprofile.AttributeContext;
import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.AttributeValidatorMetadata;
import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.DefaultAttributes;
@ -283,11 +282,6 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
new AttributeValidatorMetadata(DuplicateUsernameValidator.ID),
new AttributeValidatorMetadata(UsernameMutationValidator.ID));
metadata.addAttribute(UserModel.FIRST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID,
BlankAttributeValidator.createConfig(Messages.MISSING_FIRST_NAME)));
metadata.addAttribute(UserModel.LAST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_LAST_NAME)));
metadata.addAttribute(UserModel.EMAIL, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_EMAIL)),
new AttributeValidatorMetadata(EmailValidator.ID, ValidatorConfig.builder().config(EmailValidator.IGNORE_EMPTY_VALUE, true).build()),
new AttributeValidatorMetadata(DuplicateEmailValidator.ID),
@ -311,10 +305,6 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
metadata.addAttribute(UserModel.USERNAME, new AttributeValidatorMetadata(BrokeringFederatedUsernameHasValueValidator.ID));
metadata.addAttribute(UserModel.FIRST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_FIRST_NAME)));
metadata.addAttribute(UserModel.LAST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_LAST_NAME)));
metadata.addAttribute(UserModel.EMAIL, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_EMAIL)),
new AttributeValidatorMetadata(EmailValidator.ID));

View file

@ -22,8 +22,12 @@ package org.keycloak.userprofile.legacy;
import java.util.Map;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.userprofile.AttributeValidatorMetadata;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileMetadata;
import org.keycloak.userprofile.validator.BlankAttributeValidator;
/**
* @author <a href="mailto:markus.till@bosch.io">Markus Till</a>
@ -54,4 +58,13 @@ public class DefaultUserProfileProvider extends AbstractUserProfileProvider<Defa
public int order() {
return 1;
}
protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata) {
UserProfileContext ctx = metadata.getContext();
if(ctx != UserProfileContext.USER_API && ctx != UserProfileContext.REGISTRATION_USER_CREATION) {
metadata.addAttribute(UserModel.FIRST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_FIRST_NAME)));
metadata.addAttribute(UserModel.LAST_NAME, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_LAST_NAME)));
}
return metadata;
}
}

View file

@ -524,6 +524,125 @@ public class VerifyProfileTest extends AbstractTestRealmKeycloakTest {
assertEquals("ExistingLast", user.getLastName());
assertEquals("ExistingDepartment", user.firstAttribute(ATTRIBUTE_DEPARTMENT));
}
@Test
public void testAttributeRequiredButNotSelectedByScopeDoesntForceVerificationScreen() {
setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT);
updateUser(user5Id, "ExistingFirst", "ExistingLast", null);
setUserProfileConfiguration("{\"attributes\": ["
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "},"
+ "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{}, \"selector\":{\"scopes\":[\""+SCOPE_DEPARTMENT+"\"]}}"
+ "]}");
oauth.clientId(client_scope_optional.getClientId()).openLoginForm();
loginPage.assertCurrent();
loginPage.login("login-test5", "password");
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
}
@Test
public void testAttributeRequiredAndSelectedByScope() {
setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT);
updateUser(user5Id, "ExistingFirst", "ExistingLast", null);
setUserProfileConfiguration("{\"attributes\": ["
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "},"
+ "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{}, \"selector\":{\"scopes\":[\""+SCOPE_DEPARTMENT+"\"]}}"
+ "]}");
oauth.scope(SCOPE_DEPARTMENT).clientId(client_scope_optional.getClientId()).openLoginForm();
loginPage.assertCurrent();
loginPage.login("login-test5", "password");
verifyProfilePage.assertCurrent();
verifyProfilePage.update("FirstAA", "LastAA", "DepartmentAA");
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
events.expectRequiredAction(EventType.VERIFY_PROFILE).client(client_scope_optional).user(user5Id).assertEvent();
UserRepresentation user = getUser(user5Id);
assertEquals("FirstAA", user.getFirstName());
assertEquals("LastAA", user.getLastName());
assertEquals("DepartmentAA", user.firstAttribute(ATTRIBUTE_DEPARTMENT));
}
@Test
public void testAttributeNotRequiredAndSelectedByScopeCanBeUpdatedFromVerificationScreenForcedByAnotherAttribute() {
setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT);
updateUser(user5Id, "ExistingFirst", null, null);
setUserProfileConfiguration("{\"attributes\": ["
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"selector\":{\"scopes\":[\""+SCOPE_DEPARTMENT+"\"]}}"
+ "]}");
oauth.scope(SCOPE_DEPARTMENT).clientId(client_scope_optional.getClientId()).openLoginForm();
loginPage.assertCurrent();
loginPage.login("login-test5", "password");
verifyProfilePage.assertCurrent();
Assert.assertTrue(verifyProfilePage.isDepartmentPresent());
verifyProfilePage.update("FirstAA", "LastAA", "Department AA");
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
events.expectRequiredAction(EventType.VERIFY_PROFILE).client(client_scope_optional).user(user5Id).assertEvent();
UserRepresentation user = getUser(user5Id);
assertEquals("FirstAA", user.getFirstName());
assertEquals("LastAA", user.getLastName());
assertEquals("Department AA", user.firstAttribute(ATTRIBUTE_DEPARTMENT));
}
@Test
public void testAttributeRequiredButNotSelectedByScopeIsNotRenderedOnVerificationScreenForcedByAnotherAttribute() {
setUserProfileConfiguration(CONFIGURATION_FOR_USER_EDIT);
updateUser(user5Id, "ExistingFirst", null, null);
setUserProfileConfiguration("{\"attributes\": ["
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"department\"," + PERMISSIONS_ALL + ", \"required\":{}, \"selector\":{\"scopes\":[\""+SCOPE_DEPARTMENT+"\"]}}"
+ "]}");
oauth.clientId(client_scope_optional.getClientId()).openLoginForm();
loginPage.assertCurrent();
loginPage.login("login-test5", "password");
verifyProfilePage.assertCurrent();
Assert.assertFalse(verifyProfilePage.isDepartmentPresent());
verifyProfilePage.update("FirstAA", "LastAA");
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
events.expectRequiredAction(EventType.VERIFY_PROFILE).client(client_scope_optional).user(user5Id).assertEvent();
UserRepresentation user = getUser(user5Id);
assertEquals("FirstAA", user.getFirstName());
assertEquals("LastAA", user.getLastName());
assertEquals(null, user.firstAttribute(ATTRIBUTE_DEPARTMENT));
}
@Test
public void testCustomValidationInCustomAttribute() {

View file

@ -135,7 +135,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
}
assertThat(profile.getAttributes().nameSet(),
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, "address"));
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, "address"));
attributes.put("address", "myaddress");
@ -415,7 +415,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
UserModel user = profile.create();
assertThat(profile.getAttributes().nameSet(),
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, "address", "department"));
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, "address", "department"));
assertNull(user.getFirstAttribute("department"));

View file

@ -104,7 +104,6 @@ public class UPConfigParserTest extends AbstractTestRealmKeycloakTest {
Assert.assertNotNull(att.getRequired().getRoles());
Assert.assertEquals(2, att.getRequired().getRoles().size());
// permissions
att = config.getAttributes().get(3);
Assert.assertTrue(att.getRequired().isAlways());
@ -117,6 +116,12 @@ public class UPConfigParserTest extends AbstractTestRealmKeycloakTest {
Assert.assertEquals(2, att.getPermissions().getView().size());
Assert.assertTrue(att.getPermissions().getView().contains("admin"));
Assert.assertTrue(att.getPermissions().getView().contains("user"));
//selector
att = config.getAttributes().get(4);
Assert.assertNotNull(att.getSelector().getScopes());
Assert.assertEquals(3, att.getSelector().getScopes().size());
Assert.assertTrue(att.getSelector().getScopes().contains("phone-3-sel"));
}
/**

View file

@ -49,6 +49,9 @@
"scopes" : ["phone-1", "phone-2"],
"roles" : ["user", "admin"]
},
"selector" : {
"scopes" : ["phone-1-sel", "phone-2-sel", "phone-3-sel"]
},
"permissions": {
"view": ["admin", "user"],
"edit": ["admin"]