From a0f8d2bc0eadd0e8da54fbf8828d330032deb94f Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Thu, 11 Mar 2021 11:39:44 -0300 Subject: [PATCH] [KEYCLOAK-17399] - Review User Profile SPI Co-Authored-By: Vlastimil Elias --- .../account/UserRepresentation.java | 25 + .../idm/UserRepresentation.java | 25 + .../models/cache/infinispan/UserAdapter.java | 4 + .../org/keycloak/models/jpa/UserAdapter.java | 4 + .../models/map/user/MapUserAdapter.java | 10 +- .../userprofile/AttributeContext.java | 67 ++ .../userprofile/AttributeMetadata.java | 163 +++++ .../AttributeValidatorMetadata.java | 46 ++ .../org/keycloak/userprofile/Attributes.java | 126 ++++ .../userprofile/DefaultAttributes.java | 306 +++++++++ .../userprofile/DefaultUserProfile.java | 147 +++++ .../org/keycloak/userprofile/UserProfile.java | 64 +- .../userprofile/UserProfileAttributes.java | 62 -- .../userprofile/UserProfileContext.java | 47 +- .../userprofile/UserProfileMetadata.java | 119 ++++ .../userprofile/UserProfileProvider.java | 65 +- .../UserProfileProviderFactory.java | 2 +- .../userprofile/ValidationException.java | 98 +++ .../validation/AttributeValidationResult.java | 71 --- .../UserProfileValidationResult.java | 67 -- .../validation/UserUpdateEvent.java | 30 - .../validation/ValidationResult.java | 44 -- .../userprofile/validation/Validator.java | 34 + .../keycloak/component/ComponentModel.java | 4 + .../models/UserModelDefaultMethods.java | 2 +- .../broker/IdpReviewProfileAuthenticator.java | 72 ++- .../forms/RegistrationProfile.java | 39 +- .../forms/RegistrationUserCreation.java | 58 +- .../requiredactions/UpdateProfile.java | 51 +- .../resources/AttributeFormDataProcessor.java | 82 --- .../resources/account/AccountFormService.java | 70 +-- .../resources/account/AccountRestService.java | 40 +- .../resources/admin/UserProfileResource.java | 85 +++ .../resources/admin/UserResource.java | 40 +- .../resources/admin/UsersResource.java | 14 +- .../services/validation/Validation.java | 82 +-- .../LegacyUserProfileProvider.java | 152 ----- .../LegacyUserProfileProviderFactory.java | 99 --- .../legacy/AbstractUserProfileProvider.java | 411 +++++++++++++ .../legacy/DefaultUserProfileProvider.java | 57 ++ .../userprofile/legacy/Validators.java | 278 +++++++++ .../profile/AbstractUserProfile.java | 40 -- .../profile/DefaultUserProfileContext.java | 58 -- .../profile/UserProfileContextFactory.java | 109 ---- .../AccountUserRepresentationUserProfile.java | 65 -- .../representations/AttributeUserProfile.java | 45 -- .../representations/IdpUserProfile.java | 42 -- .../representations/UserModelUserProfile.java | 42 -- .../UserRepresentationUserProfile.java | 74 --- .../userprofile/utils/UserUpdateHelper.java | 168 ----- .../validation/AttributeValidator.java | 34 - .../validation/AttributeValidatorBuilder.java | 69 --- .../validation/StaticValidators.java | 125 ---- .../validation/ValidationChain.java | 57 -- .../validation/ValidationChainBuilder.java | 50 -- .../userprofile/validation/Validator.java | 37 -- ...oak.userprofile.UserProfileProviderFactory | 2 +- .../validation/ValidationChainTest.java | 92 --- .../testsuite/federation/UserMapStorage.java | 4 +- .../config/DeclarativeUserProfileModel.java | 34 + .../DeclarativeUserProfileProvider.java | 409 +++++++++++++ .../user/profile/config/UPAttribute.java | 91 +++ .../config/UPAttributePermissions.java | 53 ++ .../profile/config/UPAttributeRequired.java | 66 ++ .../user/profile/config/UPConfig.java | 54 ++ .../user/profile/config/UPConfigUtils.java | 223 +++++++ ...oak.userprofile.UserProfileProviderFactory | 20 + .../main/module.xml | 4 + .../config/keycloak-default-user-profile.json | 18 + .../account/AccountRestServiceTest.java | 4 +- .../testsuite/forms/RegisterTest.java | 8 +- .../user/profile/AbstractUserProfileTest.java | 241 ++++++++ .../user/profile/UserProfileConfigTest.java | 579 ++++++++++++++++++ .../user/profile/UserProfileTest.java | 416 +++++++++++++ .../profile/config/UPConfigParserTest.java | 250 ++++++++ .../profile/config/UPConfigUtilsTest.java | 115 ++++ .../user/profile/config/test-OK.json | 58 ++ .../config/test-invalidJsonFormat.json | 11 + .../user/profile/config/test-invalidType.json | 3 + .../profile/config/test-unknownField.json | 5 + 80 files changed, 5032 insertions(+), 2005 deletions(-) create mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/AttributeContext.java create mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java create mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/AttributeValidatorMetadata.java create mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java create mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java create mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java delete mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileAttributes.java create mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileMetadata.java create mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java delete mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/validation/AttributeValidationResult.java delete mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/validation/UserProfileValidationResult.java delete mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/validation/UserUpdateEvent.java delete mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/validation/ValidationResult.java create mode 100644 server-spi-private/src/main/java/org/keycloak/userprofile/validation/Validator.java delete mode 100755 services/src/main/java/org/keycloak/services/resources/AttributeFormDataProcessor.java create mode 100644 services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/LegacyUserProfileProvider.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/LegacyUserProfileProviderFactory.java create mode 100644 services/src/main/java/org/keycloak/userprofile/legacy/AbstractUserProfileProvider.java create mode 100644 services/src/main/java/org/keycloak/userprofile/legacy/DefaultUserProfileProvider.java create mode 100644 services/src/main/java/org/keycloak/userprofile/legacy/Validators.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/profile/AbstractUserProfile.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/profile/DefaultUserProfileContext.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/profile/UserProfileContextFactory.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/profile/representations/AccountUserRepresentationUserProfile.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/profile/representations/AttributeUserProfile.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/profile/representations/IdpUserProfile.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/profile/representations/UserModelUserProfile.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/profile/representations/UserRepresentationUserProfile.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/utils/UserUpdateHelper.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/validation/AttributeValidator.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/validation/AttributeValidatorBuilder.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/validation/StaticValidators.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/validation/ValidationChain.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/validation/ValidationChainBuilder.java delete mode 100644 services/src/main/java/org/keycloak/userprofile/validation/Validator.java delete mode 100644 services/src/test/java/org/keycloak/userprofile/validation/ValidationChainTest.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileModel.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileProvider.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttribute.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributePermissions.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributeRequired.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfig.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfigUtils.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/user/profile/config/keycloak-default-user-profile.json create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/AbstractUserProfileTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileConfigTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigUtilsTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-OK.json create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-invalidJsonFormat.json create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-invalidType.json create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-unknownField.json diff --git a/core/src/main/java/org/keycloak/representations/account/UserRepresentation.java b/core/src/main/java/org/keycloak/representations/account/UserRepresentation.java index aa384d1a9a..755adf0329 100755 --- a/core/src/main/java/org/keycloak/representations/account/UserRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/account/UserRepresentation.java @@ -22,6 +22,7 @@ import org.keycloak.json.StringListMapDeserializer; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -106,4 +107,28 @@ public class UserRepresentation { return this.attributes == null ? null : this.attributes.containsKey(key) ? this.attributes.get(key).get(0) : null; } + public Map> toAttributes() { + Map> attrs = new HashMap<>(); + + if (getAttributes() != null) attrs.putAll(getAttributes()); + + if (getUsername() != null) + attrs.put("username", Collections.singletonList(getUsername())); + else + attrs.remove("username"); + + if (getEmail() != null) + attrs.put("email", Collections.singletonList(getEmail())); + else + attrs.remove("email"); + + if (getLastName() != null) + attrs.put("lastName", Collections.singletonList(getLastName())); + + if (getFirstName() != null) + attrs.put("firstName", Collections.singletonList(getFirstName())); + + + return attrs; + } } diff --git a/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java index 1bd8bb7f3a..b80a708dea 100755 --- a/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import org.keycloak.json.StringListMapDeserializer; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.ArrayList; @@ -287,4 +288,28 @@ public class UserRepresentation { public void setAccess(Map access) { this.access = access; } + + public Map> toAttributes() { + Map> attrs = new HashMap<>(); + + if (getAttributes() != null) attrs.putAll(getAttributes()); + + if (getUsername() != null) + attrs.put("username", Collections.singletonList(getUsername())); + else + attrs.remove("username"); + + if (getEmail() != null) + attrs.put("email", Collections.singletonList(getEmail())); + else + attrs.remove("email"); + + if (getLastName() != null) + attrs.put("lastName", Collections.singletonList(getLastName())); + + if (getFirstName() != null) + attrs.put("firstName", Collections.singletonList(getFirstName())); + + return attrs; + } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java index 99c608add0..9d4048d258 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java @@ -61,6 +61,7 @@ public class UserAdapter implements CachedUserModel.Streams { @Override public String getFirstName() { + if (updated != null) return updated.getFirstName(); return getFirstAttribute(FIRST_NAME); } @@ -71,6 +72,7 @@ public class UserAdapter implements CachedUserModel.Streams { @Override public String getLastName() { + if (updated != null) return updated.getLastName(); return getFirstAttribute(LAST_NAME); } @@ -81,6 +83,7 @@ public class UserAdapter implements CachedUserModel.Streams { @Override public String getEmail() { + if (updated != null) return updated.getEmail(); return getFirstAttribute(EMAIL); } @@ -132,6 +135,7 @@ public class UserAdapter implements CachedUserModel.Streams { @Override public String getUsername() { + if (updated != null) return updated.getUsername(); return getFirstAttribute(UserModel.USERNAME); } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java index a3dad14b58..c59b375db7 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java @@ -18,6 +18,7 @@ package org.keycloak.models.jpa; import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.ObjectUtil; import org.keycloak.models.ClientModel; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; @@ -316,6 +317,9 @@ public class UserAdapter implements UserModel.Streams, JpaModel { @Override public void setEmail(String email) { + if (ObjectUtil.isBlank(email)) { + email = null; + } email = KeycloakModelUtils.toLowerCaseSafe(email); user.setEmail(email, realm.isDuplicateEmailsAllowed()); } diff --git a/model/map/src/main/java/org/keycloak/models/map/user/MapUserAdapter.java b/model/map/src/main/java/org/keycloak/models/map/user/MapUserAdapter.java index 1c7b8c2cdf..ab52784c8d 100644 --- a/model/map/src/main/java/org/keycloak/models/map/user/MapUserAdapter.java +++ b/model/map/src/main/java/org/keycloak/models/map/user/MapUserAdapter.java @@ -18,6 +18,7 @@ package org.keycloak.models.map.user; import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.ObjectUtil; import org.keycloak.models.ClientModel; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; @@ -205,7 +206,14 @@ public abstract class MapUserAdapter extends AbstractUserModelPedro Igor + */ +public final class AttributeContext { + + private final KeycloakSession session; + private final Map.Entry> attribute; + private final UserModel user; + private final AttributeMetadata metadata; + private UserProfileContext context; + + public AttributeContext(UserProfileContext context, KeycloakSession session, Map.Entry> attribute, + UserModel user, AttributeMetadata metadata) { + this.context = context; + this.session = session; + this.attribute = attribute; + this.user = user; + this.metadata = metadata; + } + + public KeycloakSession getSession() { + return session; + } + + public Map.Entry> getAttribute() { + return attribute; + } + + public UserModel getUser() { + return user; + } + + public UserProfileContext getContext() { + return context; + } + + public AttributeMetadata getMetadata() { + return metadata; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java new file mode 100644 index 0000000000..852454a2f7 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java @@ -0,0 +1,163 @@ +/* + * + * * 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; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.keycloak.models.ClientScopeProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.sessions.AuthenticationSessionModel; + +/** + * @author Pedro Igor + */ +public final class AttributeMetadata { + + public static final Predicate ALWAYS_TRUE = context -> true; + public static final Predicate ALWAYS_FALSE = context -> false; + + private final String attributeName; + private final Predicate selector; + private final Predicate readOnly; + /** Predicate to decide if attribute is required, it is handled as required if predicate is null */ + private final Predicate required; + private List validators; + private Map annotations; + + AttributeMetadata(String attributeName) { + this(attributeName, ALWAYS_TRUE, ALWAYS_FALSE, ALWAYS_TRUE); + } + + AttributeMetadata(String attributeName, Predicate readOnly, Predicate required) { + this(attributeName, ALWAYS_TRUE, readOnly, required); + } + + AttributeMetadata(String attributeName, Predicate selector) { + this(attributeName, selector, ALWAYS_FALSE, ALWAYS_TRUE); + } + + AttributeMetadata(String attributeName, List scopes, Predicate readOnly, Predicate required) { + this(attributeName, context -> { + KeycloakSession session = context.getSession(); + AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); + + if (authSession == null) { + return false; + } + + ClientScopeProvider clientScopes = session.clientScopes(); + RealmModel realm = session.getContext().getRealm(); + + // TODO UserProfile - LOOKS LIKE THIS DOESN'T WORK FOR SOME AUTH FLOWS, LIKE + // REGISTER? + if (authSession.getClientScopes().stream().anyMatch(scopes::contains)) { + return true; + } + + return authSession.getClientScopes().stream() + .map(id -> clientScopes.getClientScopeById(realm, id).getName()).anyMatch(scopes::contains); + }, readOnly, required); + } + + AttributeMetadata(String attributeName, Predicate selector, Predicate readOnly, Predicate required) { + this.attributeName = attributeName; + this.selector = selector; + this.readOnly = readOnly; + this.required = required; + } + + public String getName() { + return attributeName; + } + + public boolean isSelected(AttributeContext context) { + return selector.test(context); + } + + public boolean isReadOnly(AttributeContext context) { + return readOnly.test(context); + } + + /** + * Check if attribute is required based on it's predicate, it is handled as required if predicate is null + * @param context to evaluate requirement of the attribute from + * @return true if attribute is required in provided context + */ + public boolean isRequired(AttributeContext context) { + return required == null || required.test(context); + } + + public List getValidators() { + return validators; + } + + public AttributeMetadata addValidator(List validators) { + if (this.validators == null) { + this.validators = new ArrayList<>(); + } + + this.validators.addAll(validators.stream().filter(Objects::nonNull).collect(Collectors.toList())); + + return this; + } + + public AttributeMetadata addValidator(AttributeValidatorMetadata validator) { + addValidator(Arrays.asList(validator)); + return this; + } + + public Map getAnnotations() { + return annotations; + } + + public AttributeMetadata addAnnotations(Map annotations) { + if(annotations != null) { + if(this.annotations == null) { + this.annotations = new HashMap<>(); + } + + this.annotations.putAll(annotations); + } + return this; + } + + @Override + public AttributeMetadata clone() { + AttributeMetadata cloned = new AttributeMetadata(attributeName, selector, readOnly, required); + // we clone validators list to allow adding or removing validators. Validators + // itself are not cloned as we do not expect them to be reconfigured. + if (validators != null) { + cloned.addValidator(validators); + } + //we clone annotations map to allow adding to or removing from it + if(annotations != null) { + cloned.addAnnotations(annotations); + } + return cloned; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeValidatorMetadata.java b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeValidatorMetadata.java new file mode 100644 index 0000000000..b19bc3543b --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeValidatorMetadata.java @@ -0,0 +1,46 @@ +/* + * + * * 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; + +import org.keycloak.userprofile.AttributeContext; +import org.keycloak.userprofile.validation.Validator; + +/** + * @author Pedro Igor + */ +public final class AttributeValidatorMetadata implements Validator { + + private final String message; + private final Validator validator; + + public AttributeValidatorMetadata(String message, Validator validator) { + this.message = message; + this.validator = validator; + } + + public String getMessage() { + return message; + } + + @Override + public boolean validate(AttributeContext context) { + return validator.validate(context); + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java b/server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java new file mode 100644 index 0000000000..b79fe74ba9 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java @@ -0,0 +1,126 @@ +/* + * + * * 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; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + *

This interface wraps the attributes associated with a user profile. Different operations are provided to access and + * manage these attributes. + * + * @author Pedro Igor + */ +public interface Attributes { + + /** + * Default value for attributes with no value set. + */ + List EMPTY_VALUE = Collections.emptyList(); + + /** + * Returns the first value associated with the attribute with the given {@name}. + * + * @param name the name of the attribute + * + * @return the first value + */ + default String getFirstValue(String name) { + List values = getValues(name); + + if (values.isEmpty()) { + return null; + } + + return values.get(0); + } + + /** + * Returns all values for an attribute with the given {@code name}. + * + * @param name the name of the attribute + * + * @return the attribute values + */ + List getValues(String name); + + /** + * Checks whether an attribute is read-only. + * + * @param key + * + * @return + */ + boolean isReadOnly(String key); + + /** + * >, String>... listeners); + + /** + * A simpler variant of {@link #validate(String, BiConsumer[])} for those only interested on error messages. + * + * @param name the name of the attribute + * @param listeners the listeners for listening for errors + * @return {@code true} if validation is successful. Otherwise, {@code false}. In case there is no attribute with the given {@code name}, + * {@code false} is also returned but without triggering listeners + */ + default boolean validate(String name, Consumer... listeners) { + return validate(name, (attribute, error) -> { + for (Consumer consumer : listeners) { + consumer.accept(error); + } + }); + } + + /** + * Checks whether an attribute with the given {@code name} is defined. + * + * @param name the name of the attribute + * + * @return {@code true} if the attribute is defined. Otherwise, {@code false} + */ + boolean contains(String name); + + /** + * Returns the names of all defined attributes. + * + * @return the set of attribute names + */ + Set nameSet(); + + /** + * Returns all attributes defined. + * + * @return the attributes + */ + Set>> attributeSet(); +} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java new file mode 100644 index 0000000000..6da62a799c --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java @@ -0,0 +1,306 @@ +/* + * + * * 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; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; + +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +/** + *

The default implementation for {@link Attributes}. Should be reused as much as possible by the different implementations + * of {@link UserProfileProvider}. + * + *

One of the main aspects of this implementation is to allow normalizing attributes accordingly to the profile + * configuration and current context. As such, it provides some common normalization to common profile attributes (e.g.: username, + * email, first and last names, dynamic read-only attributes). + * + *

This implementation is not specific to any user profile implementation. + * + * @author Pedro Igor + */ +public final class DefaultAttributes extends HashMap> implements Attributes { + + /** + * To reference dynamic attributes that can be configured as read-only when setting up the provider. + * We should probably remove that once we remove the legacy provider, because this will come from the configuration. + */ + public static final String READ_ONLY_ATTRIBUTE_KEY = "kc.read.only"; + + private final UserProfileContext context; + private final KeycloakSession session; + private final Map metadataByAttribute; + private final UserModel user; + + public DefaultAttributes(UserProfileContext context, Map attributes, UserModel user, + UserProfileMetadata profileMetadata, + KeycloakSession session) { + this.context = context; + this.user = user; + this.session = session; + this.metadataByAttribute = configureMetadata(profileMetadata.getAttributes()); + putAll(Collections.unmodifiableMap(normalizeAttributes(attributes))); + } + + @Override + public boolean isReadOnly(String attributeName) { + return isReadOnlyFromMetadata(attributeName) || isReadOnlyInternalAttribute(attributeName); + } + + private boolean isReadOnlyFromMetadata(String attributeName) { + AttributeMetadata attributeMetadata = metadataByAttribute.get(attributeName); + + if (attributeMetadata != null && attributeMetadata.isReadOnly(createAttributeContext(attributeName, attributeMetadata))) { + return true; + } + return false; + } + + @Override + public boolean validate(String name, BiConsumer>, String>... listeners) { + Entry> attribute = createAttribute(name); + List metadatas = new ArrayList<>(); + + metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(attribute.getKey())) + .map(Collections::singletonList).orElse(Collections.emptyList())); + metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(READ_ONLY_ATTRIBUTE_KEY)) + .map(Collections::singletonList).orElse(Collections.emptyList())); + + List failingValidators = Collections.emptyList(); + + for (AttributeMetadata metadata : metadatas) { + for (AttributeValidatorMetadata validator : metadata.getValidators()) { + if (!validator.validate(createAttributeContext(attribute, metadata))) { + if (failingValidators.equals(Collections.emptyList())) { + failingValidators = new ArrayList<>(); + } + failingValidators.add(validator); + } + } + } + + if (listeners != null) { + for (AttributeValidatorMetadata failingValidator : failingValidators) { + for (BiConsumer>, String> consumer : listeners) { + consumer.accept(attribute, failingValidator.getMessage()); + } + } + } + + return failingValidators.isEmpty(); + } + + @Override + public List getValues(String name) { + return getOrDefault(name, EMPTY_VALUE); + } + + @Override + public boolean contains(String name) { + return containsKey(name); + } + + @Override + public Set nameSet() { + return keySet(); + } + + @Override + public Set>> attributeSet() { + return entrySet(); + } + + private AttributeContext createAttributeContext(Entry> attribute, AttributeMetadata metadata) { + return new AttributeContext(context, session, attribute, user, metadata); + } + + private AttributeContext createAttributeContext(String attributeName, AttributeMetadata metadata) { + return createAttributeContext(createAttribute(attributeName), metadata); + } + + private Map configureMetadata(List attributes) { + Map metadatas = new HashMap<>(); + + for (AttributeMetadata metadata : attributes) { + // checks whether the attribute is selected for the current profile + if (metadata.isSelected(createAttributeContext(metadata.getName(), metadata))) { + metadatas.put(metadata.getName(), metadata); + } + } + + return metadatas; + } + + private SimpleImmutableEntry> createAttribute(String name) { + return new SimpleImmutableEntry>(name, null) { + @Override + public List getValue() { + List values = get(name); + + if (values == null) { + return EMPTY_VALUE; + } + + return values; + } + }; + } + + /** + * Normalizes the given {@code attributes} (as they were provided when creating a profile) accordingly to the + * profile configuration and the current context. + * + * @param attributes the denormalized map of attributes + * + * @return a normalized map of attributes + */ + private Map> normalizeAttributes(Map attributes) { + Map> newAttributes = new HashMap<>(); + RealmModel realm = session.getContext().getRealm(); + + if (attributes != null && !attributes.isEmpty()) { + for (Map.Entry entry : attributes.entrySet()) { + Object value = entry.getValue(); + String key = entry.getKey(); + + if (!isSupportedAttribute(key)) { + continue; + } + + if (key.startsWith(Constants.USER_ATTRIBUTES_PREFIX)) { + key = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length()); + } + + List values; + + if (value instanceof String) { + values = Collections.singletonList((String) value); + } else { + values = (List) value; + } + + if (key.equals(UserModel.USERNAME)) { + values = Collections.singletonList(values.get(0).toLowerCase()); + } + + if (isReadOnlyFromMetadata(key)) { + // only revert attribute values if not an internal read-only attribute + // for backward compatibility changing these attributes should cause validation errors + // ideally, we should just ignore and remove this check + if (user == null) { + values = EMPTY_VALUE; + } else { + values = user.getAttributeStream(key).collect(Collectors.toList()); + } + } + + newAttributes.put(key, Collections.unmodifiableList(values)); + } + } + + // the profile should always hold all attributes defined in the config + for (String attributeName : metadataByAttribute.keySet()) { + if (isSupportedAttribute(attributeName)) { + newAttributes.computeIfAbsent(attributeName, s -> EMPTY_VALUE); + } + } + + if (user != null) { + List username = newAttributes.get(UserModel.USERNAME); + + if (username == null || username.isEmpty() || (!realm.isEditUsernameAllowed() && UserProfileContext.USER_API.equals(context))) { + newAttributes.put(UserModel.USERNAME, Collections.singletonList(user.getUsername())); + } + } + + List email = newAttributes.get(UserModel.EMAIL); + + if (email != null && realm.isRegistrationEmailAsUsername()) { + newAttributes.put(UserModel.USERNAME, email); + } + + return newAttributes; + } + + /** + *

Checks whether an attribute is support by the profile configuration and the current context. + * + *

This method can be used to avoid unexpected attributes from being added as an attribute because + * the attribute source is a regular {@link Map} and not normalized. + * + * @param name the name of the attribute + * @return + */ + private boolean isSupportedAttribute(String name) { + if (READ_ONLY_ATTRIBUTE_KEY.equals(name)) { + return false; + } + + if (metadataByAttribute.containsKey(name)) { + return true; + } + + // expect any attribute if managing the user profile using REST + if (UserProfileContext.USER_API.equals(context) || UserProfileContext.ACCOUNT.equals(context)) { + return true; + } + + // attributes managed using forms with a pre-defined prefix are supported + if (name.startsWith(Constants.USER_ATTRIBUTES_PREFIX)) { + return true; + } + + if (isReadOnly(name)) { + return true; + } + + // checks whether the attribute is a core attribute + return UserModel.USERNAME.equals(name) || UserModel.EMAIL.equals(name) || UserModel.LAST_NAME.equals(name) || UserModel.FIRST_NAME.equals(name); + } + + private boolean isReadOnlyInternalAttribute(String attributeName) { + // read-only can be configured through the provider so we try to validate global validations + AttributeMetadata readonlyMetadata = metadataByAttribute.get(READ_ONLY_ATTRIBUTE_KEY); + + if (readonlyMetadata == null) { + return false; + } + + SimpleImmutableEntry> attribute = createAttribute(attributeName); + + for (AttributeValidatorMetadata validator : readonlyMetadata.getValidators()) { + if (!validator.validate(createAttributeContext(attribute, readonlyMetadata))) { + return true; + } + } + + return false; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java new file mode 100644 index 0000000000..866292759a --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java @@ -0,0 +1,147 @@ +/* + * + * * 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; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.keycloak.models.ModelException; +import org.keycloak.models.UserModel; + +/** + *

The default implementation for {@link UserProfile}. Should be reused as much as possible by the different implementations + * of {@link UserProfileProvider}. + * + *

This implementation is not specific to any user profile implementation. + * + * @author Pedro Igor + */ +public final class DefaultUserProfile implements UserProfile { + + private final Function userSupplier; + private final Attributes attributes; + private boolean validated; + private UserModel user; + + public DefaultUserProfile(Attributes attributes, Function userCreator, UserModel user) { + this.userSupplier = userCreator; + this.attributes = attributes; + this.user = user; + } + + @Override + public void validate() { + ValidationException validationException = new ValidationException(); + + for (String attributeName : attributes.nameSet()) { + this.attributes.validate(attributeName, + (attribute, message) -> validationException.addError(new ValidationException.Error(attribute, message))); + } + + if (validationException.hasError()) { + throw validationException; + } + + validated = true; + } + + @Override + public UserModel create() throws ValidationException { + if (user != null) { + throw new RuntimeException("User already created"); + } + + if (!validated) { + validate(); + } + + user = userSupplier.apply(this.attributes); + + return updateInternal(user, false); + } + + @Override + public void update(boolean removeAttributes, BiConsumer... changeListener) { + if (!validated) { + validate(); + } + + updateInternal(user, removeAttributes, changeListener); + } + + private UserModel updateInternal(UserModel user, boolean removeAttributes, BiConsumer... changeListener) { + if (user == null) { + throw new RuntimeException("No user model provided for persisting changes"); + } + + try { + for (Map.Entry> attribute : attributes.attributeSet()) { + String name = attribute.getKey(); + + if (attributes.isReadOnly(name)) { + continue; + } + + List currentValue = user.getAttributeStream(name).filter(Objects::nonNull).collect(Collectors.toList()); + List updatedValue = attribute.getValue().stream().filter(Objects::nonNull).collect(Collectors.toList()); + + if (currentValue.size() != updatedValue.size() || !currentValue.containsAll(updatedValue)) { + user.setAttribute(name, updatedValue); + for (BiConsumer listener : changeListener) { + listener.accept(name, user); + } + } + } + + // this is a workaround for supporting contexts where the decision to whether attributes should be removed depends on + // specific aspect. For instance, old account should never remove attributes, the admin rest api should only remove if + // the attribute map was sent. + if (removeAttributes) { + Set attrsToRemove = new HashSet<>(user.getAttributes().keySet()); + attrsToRemove.removeAll(attributes.nameSet()); + + for (String attr : attrsToRemove) { + if (this.attributes.isReadOnly(attr)) { + continue; + } + user.removeAttribute(attr); + } + } + } catch (ModelException me) { + // some client code relies on this exception to react to exceptions from the storage + throw me; + } catch (Exception cause) { + throw new RuntimeException("Unexpected error when persisting user profile", cause); + } + + return user; + } + + @Override + public Attributes getAttributes() { + return attributes; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfile.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfile.java index a73ed45e15..689c153c2c 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfile.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfile.java @@ -17,16 +17,72 @@ package org.keycloak.userprofile; +import java.util.function.BiConsumer; + +import org.keycloak.models.UserModel; + /** - * Abstraction, which allows to update the user in various contexts (Required action of already existing user, or first identity provider - * login when user doesn't yet exists in Keycloak DB) + *

An interface providing as an entry point for managing users. * + *

A {@code UserProfile} provides a manageable view for user information that also takes into account the context where it is being used. + * The context represents the different places in Keycloak where users are created, updated, or validated. + * Examples of contexts are: managing users through the Admin API, or through the Account API. + * + *

By taking the context into account, the state and behavior of {@link UserProfile} instances depend on the context they + * are associated with, where validating, updating, creating, or obtaining representations of users is based on the configuration + * and constraints associated with a context. + * + *

A {@code UserProfile} instance can be obtained through the {@link UserProfileProvider}. + * + * @see UserProfileContext + * @see UserProfileProvider * @author Markus Till */ public interface UserProfile { - String getId(); + /** + * Validates the attributes associated with this instance. + * + * @throws ValidationException in case + */ + void validate() throws ValidationException; - UserProfileAttributes getAttributes(); + /** + * Creates a new {@link UserModel} based on the attributes associated with this instance. + * + * @throws ValidationException in case validation fails + * + * @return the {@link UserModel} instance created from this profile + */ + UserModel create() throws ValidationException; + /** + *

Updates the {@link UserModel} associated with this instance. If no {@link UserModel} is associated with this instance, this operation has no effect. + * + *

Before updating the {@link UserModel}, this method first checks whether the {@link #validate()} method was previously + * invoked. If not, the validation step is performed prior to updating the model. + * + * @param removeAttributes if attributes should be removed from the {@link UserModel} if they are not among the attributes associated with this instance. + * @param changeListener a set of one or more listeners to listen for attribute changes + * @throws ValidationException in case of any validation error + */ + void update(boolean removeAttributes, BiConsumer... changeListener) throws ValidationException; + + /** + *

The same as {@link #update(boolean, BiConsumer[])} but forcing the removal of attributes. + * + * @param changeListener a set of one or more listeners to listen for attribute changes + * @throws ValidationException in case of any validation error + */ + default void update(BiConsumer... changeListener) throws ValidationException, RuntimeException { + update(true, changeListener); + } + + /** + * Returns the attributes associated with this instance. Note that the attributes returned by this method are not necessarily + * the same from the {@link UserModel}, but those that should be validated and possibly updated to the {@link UserModel}. + * + * @return the attributes associated with this instance. + */ + Attributes getAttributes(); } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileAttributes.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileAttributes.java deleted file mode 100644 index 26dbfd893a..0000000000 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileAttributes.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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.userprofile; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.List; - -public class UserProfileAttributes extends HashMap> { - - private final UserProfileProvider profileProvider; - - public UserProfileAttributes(Map> attribtues, - UserProfileProvider profileProvider){ - this.profileProvider = profileProvider; - this.putAll(attribtues); - } - - public UserProfileAttributes(Map> attribtues){ - this(attribtues, null); - } - - public void setAttribute(String key, List value){ - this.put(key,value); - } - - public void setSingleAttribute(String key, String value) { - this.setAttribute(key, Collections.singletonList(value)); - } - - public String getFirstAttribute(String key) { - return this.get(key) == null ? null : this.get(key).isEmpty()? null : this.get(key).get(0); - } - - public List getAttribute(String key) { - return this.get(key); - } - - public void removeAttribute(String attr) { - this.remove(attr); - } - - public boolean isReadOnlyAttribute(String key) { - return profileProvider != null && profileProvider.isReadOnlyAttribute(key); - } -} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileContext.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileContext.java index c3d67fd3a5..0357317e0c 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileContext.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileContext.java @@ -1,31 +1,40 @@ /* - * 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 + * * 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. * - * 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; -import org.keycloak.userprofile.validation.UserProfileValidationResult; -import org.keycloak.userprofile.validation.UserUpdateEvent; - /** + *

This interface represents the different contexts from where user profiles are managed. The core contexts are already + * available here representing the different parts in Keycloak where user profiles are managed. + * + *

The context is crucial to drive the conditions that should be respected when managing user profiles. It might be possible + * to include in the future metadata about contexts. As well as support custom contexts. + * * @author Markus Till */ -public interface UserProfileContext { +public enum UserProfileContext { - UserUpdateEvent getUpdateEvent(); - UserProfile getCurrentProfile(); - UserProfileValidationResult validate(); + UPDATE_PROFILE, + USER_API, + ACCOUNT, + ACCOUNT_OLD, + IDP_REVIEW, + REGISTRATION_PROFILE, + REGISTRATION_USER_CREATION; } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileMetadata.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileMetadata.java new file mode 100644 index 0000000000..d9fcd4f7b8 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileMetadata.java @@ -0,0 +1,119 @@ +/* + * + * * 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; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * @author Pedro Igor + */ +public final class UserProfileMetadata implements Cloneable { + + private final UserProfileContext context; + private List attributes; + + public UserProfileMetadata(UserProfileContext context) { + this.context = context; + } + + public List getAttributes() { + return attributes; + } + + public void addAttributes(AttributeMetadata... metadata) { + addAttributes(Arrays.asList(metadata)); + } + + public void addAttributes(List metadata) { + if (attributes == null) { + attributes = new ArrayList<>(); + } + attributes.addAll(metadata); + } + + public AttributeMetadata addAttribute(AttributeMetadata metadata) { + addAttributes(Arrays.asList(metadata)); + return metadata; + } + + public AttributeMetadata addAttribute(String name, AttributeValidatorMetadata... validator) { + return addAttribute(name, Arrays.asList(validator)); + } + + public AttributeMetadata addAttribute(String name, List validators) { + return addAttribute(new AttributeMetadata(name).addValidator(validators)); + } + + public AttributeMetadata addAttribute(String name, List validator, Predicate required) { + return addAttribute(new AttributeMetadata(name, AttributeMetadata.ALWAYS_FALSE, required).addValidator(validator)); + } + + public AttributeMetadata addAttribute(String name, List validator, Predicate readOnly, Predicate required) { + return addAttribute(new AttributeMetadata(name, readOnly, required).addValidator(validator)); + } + + /** + * Get existing AttributeMetadata for attribute of given name. + * + * @param name of the attribute + * @return list of existing metadata for given attribute, never null + */ + public List getAttribute(String name) { + if (attributes == null) + return Collections.emptyList(); + return attributes.stream().filter((c) -> name.equals(c.getName())).collect(Collectors.toList()); + + } + + public UserProfileContext getContext() { + return context; + } + + @Override + public UserProfileMetadata clone() { + UserProfileMetadata metadata = new UserProfileMetadata(this.context); + + //deeply clone AttributeMetadata so we can modify them (add validators etc) + if (attributes != null) { + metadata.addAttributes(attributes.stream().map((c)-> c.clone()).collect(Collectors.toList())); + } + + return metadata; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof UserProfileMetadata)) return false; + + UserProfileMetadata that = (UserProfileMetadata) o; + return that.getContext().equals(getContext()); + } + + @Override + public int hashCode() { + return getContext().hashCode(); + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProvider.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProvider.java index 74c12d469b..718f8390c7 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProvider.java @@ -17,15 +17,74 @@ package org.keycloak.userprofile; +import java.util.Map; + +import org.keycloak.models.UserModel; import org.keycloak.provider.Provider; -import org.keycloak.userprofile.validation.UserProfileValidationResult; /** + *

The provider responsible for creating {@link UserProfile} instances. + * + * @see UserProfile * @author Markus Till */ public interface UserProfileProvider extends Provider { - UserProfileValidationResult validate(UserProfileContext updateContext, UserProfile updatedProfile); + /** + *

Creates a new {@link UserProfile} instance only for validation purposes to check whether its attributes are in conformance + * with the given {@code context} and profile configuration. + * + * @param context the context + * @param user an existing user + * + * @return the user profile instance + */ + UserProfile create(UserProfileContext context, UserModel user); - boolean isReadOnlyAttribute(String key); + /** + *

Creates a new {@link UserProfile} instance for a given {@code context} and {@code attributes} for validation purposes. + * + *

Instances created from this method are usually related to contexts where validation and updates are performed in different + * steps, or when creating new users based on the given {@code attributes}. + * + * @param context the context + * @param attributes the attributes to associate with the instance returned from this method + * + * @return the user profile instance + */ + UserProfile create(UserProfileContext context, Map attributes); + + /** + *

Creates a new {@link UserProfile} instance for a given {@code context} and {@code attributes} for update purposes. + * + *

Instances created from this method are going to run validations and updates based on the given {@code user}. This + * might be useful when updating an existing user. + * + * @param context the context + * @param attributes the attributes to associate with the instance returned from this method + * @param user the user to eventually update with the given {@code attributes} + * + * @return the user profile instance + */ + UserProfile create(UserProfileContext context, Map attributes, UserModel user); + + /** + * Get current UserProfile configuration. JSON formatted file is expected, but + * depends on the implementation. + * + * @return current UserProfile configuration + * @see #setConfiguration(String) + */ + String getConfiguration(); + + /** + * Set new UserProfile configuration. It is persisted inside of the provider. + * + * @param configuration to be set + * @throws RuntimeException if configuration is invalid (exact exception class + * depends on the implementation) or configuration + * can't be persisted. + * @see #getConfiguration() + */ + void setConfiguration(String configuration); } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProviderFactory.java index e57e9f6086..10214d3bba 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProviderFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileProviderFactory.java @@ -22,6 +22,6 @@ import org.keycloak.provider.ProviderFactory; /** * @author Markus Till */ -public interface UserProfileProviderFactory extends ProviderFactory { +public interface UserProfileProviderFactory extends ProviderFactory { } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java b/server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java new file mode 100644 index 0000000000..ce016cc973 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java @@ -0,0 +1,98 @@ +/* + * + * * 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; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author Pedro Igor + */ +public final class ValidationException extends RuntimeException { + + private final Map> errors = new HashMap<>(); + + public List getErrors() { + return errors.values().stream().reduce(new ArrayList<>(), + (l, r) -> { + l.addAll(r); + return l; + }, (l, r) -> l); + } + + public boolean hasError(String... types) { + if (types.length == 0) { + return !errors.isEmpty(); + } + + for (String type : types) { + if (errors.containsKey(type)) { + return true; + } + } + return false; + } + + /** + * Checks if there are validation errors related to the attribute with the given {@code name}. + * + * @param name + * @return + */ + public boolean isAttributeOnError(String... name) { + if (name.length == 0) { + return !errors.isEmpty(); + } + + List names = Arrays.asList(name); + + return errors.values().stream().flatMap(Collection::stream) + .anyMatch(error -> names.contains(error.attribute.getKey())); + } + + void addError(Error error) { + List errors = this.errors.computeIfAbsent(error.getMessage(), (k) -> new ArrayList<>()); + errors.add(error); + } + + public static class Error { + + private final Map.Entry> attribute; + private final String message; + + public Error(Map.Entry> attribute, String message) { + this.attribute = attribute; + this.message = message; + } + + public String getAttribute() { + return attribute.getKey(); + } + + //TODO: support parameters to messsages for formatting purposes. Message key and parameters. + public String getMessage() { + return message; + } + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/validation/AttributeValidationResult.java b/server-spi-private/src/main/java/org/keycloak/userprofile/validation/AttributeValidationResult.java deleted file mode 100644 index 9cade92303..0000000000 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/validation/AttributeValidationResult.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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.userprofile.validation; - -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -/** - * @author Markus Till - */ -public class AttributeValidationResult { - - private final String attributeKey; - private final boolean changed; - List validationResults; - - public List getValidationResults() { - return validationResults; - } - - public List getFailedValidations() { - return validationResults == null ? null : validationResults.stream().filter(ValidationResult::isInvalid).collect(Collectors.toList()); - } - - - public AttributeValidationResult(String attributeKey, boolean changed, List validationResults) { - this.attributeKey = attributeKey; - this.validationResults = validationResults; - this.changed = changed; - - } - - public boolean isValid() { - return validationResults.stream().allMatch(ValidationResult::isValid); - } - - protected boolean isInvalid() { - return !isValid(); - } - - public boolean hasChanged() { - return changed; - } - - public String getField() { - return attributeKey; - } - - public boolean hasFailureOfErrorType(String... errorKeys) { - return this.validationResults != null - && this.getFailedValidations().stream().anyMatch(o -> o.getErrorType() != null - && Arrays.stream(errorKeys).anyMatch(a -> a.equals(o.getErrorType()))); - } - -} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/validation/UserProfileValidationResult.java b/server-spi-private/src/main/java/org/keycloak/userprofile/validation/UserProfileValidationResult.java deleted file mode 100644 index 9cec63869b..0000000000 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/validation/UserProfileValidationResult.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 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.userprofile.validation; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -import org.keycloak.userprofile.UserProfile; - -/** - * @author Markus Till - */ -public class UserProfileValidationResult { - - - List attributeValidationResults; - private final UserProfile updatedProfile; - - public UserProfileValidationResult(List attributeValidationResults, - UserProfile updatedProfile) { - this.attributeValidationResults = attributeValidationResults; - this.updatedProfile = updatedProfile; - } - - public List getValidationResults() { - return attributeValidationResults; - } - - public List getErrors() { - return attributeValidationResults.stream().filter(AttributeValidationResult::isInvalid).collect(Collectors.toCollection(ArrayList::new)); - } - - - public boolean hasFailureOfErrorType(String... errorKeys) { - return this.attributeValidationResults != null - && this.attributeValidationResults.stream().anyMatch(attributeValidationResult -> attributeValidationResult.hasFailureOfErrorType(errorKeys)); - } - - public boolean hasAttributeChanged(String attribute) { - return this.attributeValidationResults.stream().filter(o -> o.getField().equals(attribute)).collect(Collectors.toList()).get(0).hasChanged(); - } - - /** - * Returns the {@link UserProfile} used during validations. - * - * @return the profile user during validations - */ - public UserProfile getProfile() { - return updatedProfile; - } -} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/validation/UserUpdateEvent.java b/server-spi-private/src/main/java/org/keycloak/userprofile/validation/UserUpdateEvent.java deleted file mode 100644 index 4b2bb5240b..0000000000 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/validation/UserUpdateEvent.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.userprofile.validation; - -/** - * @author Markus Till - */ -public enum UserUpdateEvent { - UpdateProfile, - UserResource, - Account, - IdpReview, - RegistrationProfile, - RegistrationUserCreation -} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/validation/ValidationResult.java b/server-spi-private/src/main/java/org/keycloak/userprofile/validation/ValidationResult.java deleted file mode 100644 index 3d2fb6e3b6..0000000000 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/validation/ValidationResult.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.userprofile.validation; - -/** - * @author Markus Till - */ -public class ValidationResult { - boolean valid; - - String errorType; - - public ValidationResult( boolean valid, String errorType) { - this.errorType = errorType; - this.valid = valid; - } - - public boolean isValid() { - return valid; - } - - protected boolean isInvalid() { - return !isValid(); - } - - public String getErrorType() { - return errorType; - } -} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/validation/Validator.java b/server-spi-private/src/main/java/org/keycloak/userprofile/validation/Validator.java new file mode 100644 index 0000000000..9c85f1beed --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/validation/Validator.java @@ -0,0 +1,34 @@ +/* + * + * * 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.validation; + +import org.keycloak.userprofile.AttributeContext; + +/** + * @author Pedro Igor + */ +public interface Validator { + + /** + * @returns true if validation success, false if validation fails + */ + boolean validate(AttributeContext context); + +} diff --git a/server-spi/src/main/java/org/keycloak/component/ComponentModel.java b/server-spi/src/main/java/org/keycloak/component/ComponentModel.java index 8f5066b01c..7a1a2cc565 100755 --- a/server-spi/src/main/java/org/keycloak/component/ComponentModel.java +++ b/server-spi/src/main/java/org/keycloak/component/ComponentModel.java @@ -132,6 +132,10 @@ public class ComponentModel implements Serializable { notes.put(key, object); } + public void removeNote(String key) { + notes.remove(key); + } + public String getProviderId() { return providerId; } diff --git a/server-spi/src/main/java/org/keycloak/models/UserModelDefaultMethods.java b/server-spi/src/main/java/org/keycloak/models/UserModelDefaultMethods.java index 7bfb1cbd33..4a888a87e7 100644 --- a/server-spi/src/main/java/org/keycloak/models/UserModelDefaultMethods.java +++ b/server-spi/src/main/java/org/keycloak/models/UserModelDefaultMethods.java @@ -52,7 +52,7 @@ public abstract class UserModelDefaultMethods implements UserModel { @Override public void setEmail(String email) { - email = email == null ? null : email.toLowerCase(); + email = email == null || email.trim().isEmpty() ? null : email.toLowerCase(); setSingleAttribute(EMAIL, email); } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpReviewProfileAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpReviewProfileAuthenticator.java index 492fcf035c..ffe31bb916 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpReviewProfileAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpReviewProfileAuthenticator.java @@ -17,8 +17,6 @@ package org.keycloak.authentication.authenticators.broker; -import static org.keycloak.userprofile.profile.UserProfileContextFactory.forIdpReview; - import org.jboss.logging.Logger; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; @@ -36,9 +34,10 @@ import org.keycloak.models.utils.FormMessage; import org.keycloak.models.utils.UserModelDelegate; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.services.validation.Validation; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.utils.UserUpdateHelper; -import org.keycloak.userprofile.validation.UserProfileValidationResult; +import org.keycloak.userprofile.UserProfileProvider; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; @@ -102,23 +101,13 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator { EventBuilder event = context.getEvent(); event.event(EventType.UPDATE_PROFILE); MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); - UserProfileValidationResult result = forIdpReview(userCtx, formData, context.getSession()).validate(); + UserModelDelegate updatedProfile = new UserModelDelegate(null) { - List errors = Validation.getFormErrorsFromValidation(result); + @Override + public String getId() { + return userCtx.getId(); + } - if (errors != null && !errors.isEmpty()) { - Response challenge = context.form() - .setErrors(errors) - .setAttribute(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR, userCtx) - .setFormData(formData) - .createUpdateProfilePage(); - context.challenge(challenge); - return; - } - - UserProfile updatedProfile = result.getProfile(); - - UserUpdateHelper.updateIdpReview(context.getRealm(), new UserModelDelegate(null) { @Override public Map> getAttributes() { return userCtx.getAttributes(); @@ -138,19 +127,50 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator { public void removeAttribute(String name) { userCtx.getAttributes().remove(name); } - }, updatedProfile); + + @Override + public String getFirstAttribute(String name) { + return userCtx.getFirstAttribute(name); + } + + @Override + public String getUsername() { + return userCtx.getUsername(); + } + }; + + UserProfileProvider profileProvider = context.getSession().getProvider(UserProfileProvider.class); + UserProfile profile = profileProvider.create(UserProfileContext.IDP_REVIEW, formData, updatedProfile); + + try { + String oldEmail = userCtx.getEmail(); + + profile.update((attributeName, userModel) -> { + if (attributeName.equals(UserModel.EMAIL)) { + context.getAuthenticationSession().setAuthNote(UPDATE_PROFILE_EMAIL_CHANGED, "true"); + event.clone().event(EventType.UPDATE_EMAIL).detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, profile.getAttributes().getFirstValue(UserModel.EMAIL)).success(); + } + }); + } catch (ValidationException pve) { + List errors = Validation.getFormErrorsFromValidation(pve.getErrors()); + + Response challenge = context.form() + .setErrors(errors) + .setAttribute(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR, userCtx) + .setFormData(formData) + .createUpdateProfilePage(); + + context.challenge(challenge); + + return; + } userCtx.saveToAuthenticationSession(context.getAuthenticationSession(), BROKERED_CONTEXT_NOTE); logger.debugf("Profile updated successfully after first authentication with identity provider '%s' for broker user '%s'.", brokerContext.getIdpConfig().getAlias(), userCtx.getUsername()); - String oldEmail = userCtx.getEmail(); - String newEmail = updatedProfile.getAttributes().getFirstAttribute(UserModel.EMAIL); + String newEmail = profile.getAttributes().getFirstValue(UserModel.EMAIL); - if (result.hasAttributeChanged(UserModel.EMAIL)) { - context.getAuthenticationSession().setAuthNote(UPDATE_PROFILE_EMAIL_CHANGED, "true"); - event.clone().event(EventType.UPDATE_EMAIL).detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, newEmail).success(); - } event.detail(Details.UPDATED_EMAIL, newEmail); // Ensure page is always shown when user later returns to it - for example with form "back" button diff --git a/services/src/main/java/org/keycloak/authentication/forms/RegistrationProfile.java b/services/src/main/java/org/keycloak/authentication/forms/RegistrationProfile.java index aa4ea6416f..978134da42 100755 --- a/services/src/main/java/org/keycloak/authentication/forms/RegistrationProfile.java +++ b/services/src/main/java/org/keycloak/authentication/forms/RegistrationProfile.java @@ -17,8 +17,6 @@ package org.keycloak.authentication.forms; -import static org.keycloak.userprofile.profile.UserProfileContextFactory.forRegistrationProfile; - import org.keycloak.Config; import org.keycloak.authentication.FormAction; import org.keycloak.authentication.FormActionFactory; @@ -34,12 +32,11 @@ import org.keycloak.models.UserModel; import org.keycloak.models.utils.FormMessage; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.services.messages.Messages; -import org.keycloak.services.resources.AttributeFormDataProcessor; import org.keycloak.services.validation.Validation; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.profile.representations.AttributeUserProfile; -import org.keycloak.userprofile.utils.UserUpdateHelper; -import org.keycloak.userprofile.validation.UserProfileValidationResult; +import org.keycloak.userprofile.UserProfileProvider; import javax.ws.rs.core.MultivaluedMap; import java.util.List; @@ -67,33 +64,37 @@ public class RegistrationProfile implements FormAction, FormActionFactory { context.getEvent().detail(Details.REGISTER_METHOD, "form"); - UserProfileValidationResult result = forRegistrationProfile(context.getSession(), formData).validate(); - List errors = Validation.getFormErrorsFromValidation(result); + UserProfileProvider profileProvider = context.getSession().getProvider(UserProfileProvider.class); + UserProfile profile = profileProvider.create(UserProfileContext.REGISTRATION_PROFILE, formData); - if (errors.size() > 0) { - if (result.hasFailureOfErrorType(Messages.EMAIL_EXISTS, Messages.INVALID_EMAIL)) { - UserProfile updatedProfile = result.getProfile(); - context.getEvent().detail(Details.EMAIL, updatedProfile.getAttributes().getFirstAttribute(UserModel.EMAIL)); + try { + profile.validate(); + } catch (ValidationException pve) { + List errors = Validation.getFormErrorsFromValidation(pve.getErrors()); + + if (pve.hasError(Messages.EMAIL_EXISTS, Messages.INVALID_EMAIL)) { + context.getEvent().detail(Details.EMAIL, profile.getAttributes().getFirstValue(UserModel.EMAIL)); } - if (result.hasFailureOfErrorType(Messages.EMAIL_EXISTS)) { + if (pve.hasError(Messages.EMAIL_EXISTS)) { context.error(Errors.EMAIL_IN_USE); formData.remove("email"); } else context.error(Errors.INVALID_REGISTRATION); - context.validationError(formData, errors); - return; - } else { - context.success(); + context.validationError(formData, errors); + + return; } + + context.success(); } @Override public void success(FormContext context) { UserModel user = context.getUser(); - AttributeUserProfile updatedProfile = AttributeFormDataProcessor.toUserProfile(context.getHttpRequest().getDecodedFormParameters()); - UserUpdateHelper.updateRegistrationProfile(context.getRealm(), user, updatedProfile); + UserProfileProvider provider = context.getSession().getProvider(UserProfileProvider.class); + provider.create(UserProfileContext.REGISTRATION_PROFILE, context.getHttpRequest().getDecodedFormParameters(), user).update(); } @Override diff --git a/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java b/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java index 85c45780a6..e7dba5176b 100755 --- a/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java +++ b/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java @@ -17,8 +17,6 @@ package org.keycloak.authentication.forms; -import static org.keycloak.userprofile.profile.UserProfileContextFactory.forRegistrationUserCreation; - import org.keycloak.Config; import org.keycloak.authentication.FormAction; import org.keycloak.authentication.FormActionFactory; @@ -37,12 +35,11 @@ import org.keycloak.models.utils.FormMessage; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.services.messages.Messages; -import org.keycloak.services.resources.AttributeFormDataProcessor; import org.keycloak.services.validation.Validation; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.profile.representations.AttributeUserProfile; -import org.keycloak.userprofile.utils.UserUpdateHelper; -import org.keycloak.userprofile.validation.UserProfileValidationResult; +import org.keycloak.userprofile.UserProfileProvider; import javax.ws.rs.core.MultivaluedMap; import java.util.List; @@ -70,33 +67,37 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory { MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); context.getEvent().detail(Details.REGISTER_METHOD, "form"); + KeycloakSession session = context.getSession(); + UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); + UserProfile profile = profileProvider.create(UserProfileContext.REGISTRATION_USER_CREATION, formData); + String email = profile.getAttributes().getFirstValue(UserModel.EMAIL); - UserProfileValidationResult result = forRegistrationUserCreation(context.getSession(), formData).validate(); - UserProfile newProfile = result.getProfile(); - String email = newProfile.getAttributes().getFirstAttribute(UserModel.EMAIL); - - String username = newProfile.getAttributes().getFirstAttribute(UserModel.USERNAME); - String firstName = newProfile.getAttributes().getFirstAttribute(UserModel.FIRST_NAME); - String lastName = newProfile.getAttributes().getFirstAttribute(UserModel.LAST_NAME); + String username = profile.getAttributes().getFirstValue(UserModel.USERNAME); + String firstName = profile.getAttributes().getFirstValue(UserModel.FIRST_NAME); + String lastName = profile.getAttributes().getFirstValue(UserModel.LAST_NAME); context.getEvent().detail(Details.EMAIL, email); context.getEvent().detail(Details.USERNAME, username); context.getEvent().detail(Details.FIRST_NAME, firstName); context.getEvent().detail(Details.LAST_NAME, lastName); - List errors = Validation.getFormErrorsFromValidation(result); if (context.getRealm().isRegistrationEmailAsUsername()) { context.getEvent().detail(Details.USERNAME, email); } - if (errors.size() > 0) { - if (result.hasFailureOfErrorType(Messages.EMAIL_EXISTS)) { + + try { + profile.validate(); + } catch (ValidationException pve) { + List errors = Validation.getFormErrorsFromValidation(pve.getErrors()); + + if (pve.hasError(Messages.EMAIL_EXISTS)) { context.error(Errors.EMAIL_IN_USE); formData.remove(RegistrationPage.FIELD_EMAIL); - } else if (result.hasFailureOfErrorType(Messages.MISSING_EMAIL, Messages.MISSING_USERNAME, Messages.INVALID_EMAIL)) { - if (result.hasFailureOfErrorType(Messages.INVALID_EMAIL)) + } else if (pve.hasError(Messages.MISSING_EMAIL, Messages.MISSING_USERNAME, Messages.INVALID_EMAIL)) { + if (pve.hasError(Messages.INVALID_EMAIL)) formData.remove(Validation.FIELD_EMAIL); context.error(Errors.INVALID_REGISTRATION); - } else if (result.hasFailureOfErrorType(Messages.USERNAME_EXISTS)) { + } else if (pve.hasError(Messages.USERNAME_EXISTS)) { context.error(Errors.USERNAME_IN_USE); formData.remove(Validation.FIELD_USERNAME); } @@ -114,24 +115,31 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory { @Override public void success(FormContext context) { - AttributeUserProfile updatedProfile = AttributeFormDataProcessor.toUserProfile(context.getHttpRequest().getDecodedFormParameters()); + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + + String email = formData.getFirst(UserModel.EMAIL); + String username = formData.getFirst(UserModel.USERNAME); - String email = updatedProfile.getAttributes().getFirstAttribute(UserModel.EMAIL); - String username = updatedProfile.getAttributes().getFirstAttribute(UserModel.USERNAME); if (context.getRealm().isRegistrationEmailAsUsername()) { username = email; } + context.getEvent().detail(Details.USERNAME, username) .detail(Details.REGISTER_METHOD, "form") .detail(Details.EMAIL, email); - UserModel user = context.getSession().users().addUser(context.getRealm(), username); + KeycloakSession session = context.getSession(); + + UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); + UserProfile profile = profileProvider.create(UserProfileContext.REGISTRATION_USER_CREATION, formData); + UserModel user = profile.create(); + user.setEnabled(true); - UserUpdateHelper.updateRegistrationUserCreation(context.getRealm(), user, updatedProfile); + + context.setUser(user); context.getAuthenticationSession().setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, username); - context.setUser(user); context.getEvent().user(user); context.getEvent().success(); context.newEvent().event(EventType.LOGIN); diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java index 7c6ba8ec9d..9d8330e329 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateProfile.java @@ -17,8 +17,6 @@ package org.keycloak.authentication.requiredactions; -import static org.keycloak.userprofile.profile.UserProfileContextFactory.forUpdateProfile; - import org.keycloak.Config; import org.keycloak.OAuth2Constants; import org.keycloak.authentication.DisplayTypeRequiredActionFactory; @@ -34,9 +32,10 @@ import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.UserModel; import org.keycloak.models.utils.FormMessage; import org.keycloak.services.validation.Validation; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.utils.UserUpdateHelper; -import org.keycloak.userprofile.validation.UserProfileValidationResult; +import org.keycloak.userprofile.UserProfileProvider; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; @@ -73,36 +72,34 @@ public class UpdateProfile implements RequiredActionProvider, RequiredActionFact String oldFirstName = user.getFirstName(); String oldLastName = user.getLastName(); String oldEmail = user.getEmail(); - UserProfileValidationResult result = forUpdateProfile(user, formData, context.getSession()).validate(); - final UserProfile updatedProfile = result.getProfile(); - List errors = Validation.getFormErrorsFromValidation(result); + UserProfileProvider provider = context.getSession().getProvider(UserProfileProvider.class); + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, formData, user); + + try { + // backward compatibility with old account console where attributes are not removed if missing + profile.update(false, (attributeName, userModel) -> { + if (attributeName.equals(UserModel.FIRST_NAME)) { + event.detail(Details.PREVIOUS_FIRST_NAME, oldFirstName).detail(Details.UPDATED_FIRST_NAME, user.getFirstName()); + } + if (attributeName.equals(UserModel.LAST_NAME)) { + event.detail(Details.PREVIOUS_LAST_NAME, oldLastName).detail(Details.UPDATED_LAST_NAME, user.getLastName()); + } + if (attributeName.equals(UserModel.EMAIL)) { + user.setEmailVerified(false); + event.detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, user.getEmail()); + } + }); + + context.success(); + } catch (ValidationException pve) { + List errors = Validation.getFormErrorsFromValidation(pve.getErrors()); - if (errors != null && !errors.isEmpty()) { Response challenge = context.form() .setErrors(errors) .setFormData(formData) .createResponse(UserModel.RequiredAction.UPDATE_PROFILE); context.challenge(challenge); - return; } - - String newEmail = updatedProfile.getAttributes().getFirstAttribute(UserModel.EMAIL); - String newFirstName = updatedProfile.getAttributes().getFirstAttribute(UserModel.FIRST_NAME); - String newLastName = updatedProfile.getAttributes().getFirstAttribute(UserModel.LAST_NAME); - - UserUpdateHelper.updateUserProfile(context.getRealm(), user, updatedProfile); - if (result.hasAttributeChanged(UserModel.FIRST_NAME)) { - event.detail(Details.PREVIOUS_FIRST_NAME, oldFirstName).detail(Details.UPDATED_FIRST_NAME, newFirstName); - } - if (result.hasAttributeChanged(UserModel.LAST_NAME)) { - event.detail(Details.PREVIOUS_LAST_NAME, oldLastName).detail(Details.UPDATED_LAST_NAME, newLastName); - } - if (result.hasAttributeChanged(UserModel.EMAIL)) { - user.setEmailVerified(false); - event.detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, newEmail); - } - context.success(); - } diff --git a/services/src/main/java/org/keycloak/services/resources/AttributeFormDataProcessor.java b/services/src/main/java/org/keycloak/services/resources/AttributeFormDataProcessor.java deleted file mode 100755 index 5ccee9c0fc..0000000000 --- a/services/src/main/java/org/keycloak/services/resources/AttributeFormDataProcessor.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2016 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.services.resources; - -import org.keycloak.models.Constants; -import org.keycloak.models.UserModel; -import org.keycloak.userprofile.profile.representations.AttributeUserProfile; - -import javax.ws.rs.core.MultivaluedMap; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * @author Bill Burke - * @version $Revision: 1 $ - */ -public class AttributeFormDataProcessor { - - - public static AttributeUserProfile process(MultivaluedMap formData) { - Map> attributes= new HashMap<>(); - for (String key : formData.keySet()) { - if (!key.startsWith(Constants.USER_ATTRIBUTES_PREFIX)) continue; - String attribute = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length()); - - // Need to handle case when attribute has multiple values, but in UI was displayed just first value - List modelValue = new ArrayList(); - - int index = 0; - for (String value : formData.get(key)) { - addOrSetValue(modelValue, index, value); - index++; - } - - attributes.put(attribute, modelValue); - } - return new AttributeUserProfile(attributes); - } - - public static AttributeUserProfile toUserProfile(MultivaluedMap formData) { - AttributeUserProfile profile = process(formData); - - copyAttribute(UserModel.USERNAME, formData, profile); - copyAttribute(UserModel.FIRST_NAME, formData, profile); - copyAttribute(UserModel.LAST_NAME, formData, profile); - copyAttribute(UserModel.EMAIL, formData, profile); - - - return profile; - } - - private static void copyAttribute(String key, MultivaluedMap formData, AttributeUserProfile rep) { - if (formData.getFirst(key) != null) - rep.getAttributes().setSingleAttribute(key, formData.getFirst(key)); - } - - - private static void addOrSetValue(List list, int index, String value) { - if (list.size() > index) { - list.set(index, value); - } else { - list.add(value); - } - } -} diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountFormService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountFormService.java index d6211d90c8..6ba2e3a043 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountFormService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountFormService.java @@ -16,8 +16,6 @@ */ package org.keycloak.services.resources.account; -import static org.keycloak.userprofile.profile.UserProfileContextFactory.forOldAccount; - import org.jboss.logging.Logger; import org.keycloak.authorization.AuthorizationProvider; import org.keycloak.authorization.model.PermissionTicket; @@ -66,7 +64,6 @@ import org.keycloak.services.managers.Auth; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.managers.UserConsentManager; -import org.keycloak.services.managers.UserSessionManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.AbstractSecuredLocalService; import org.keycloak.services.resources.RealmsResource; @@ -74,9 +71,10 @@ import org.keycloak.services.util.ResolveRelative; import org.keycloak.services.validation.Validation; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.storage.ReadOnlyException; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.utils.UserUpdateHelper; -import org.keycloak.userprofile.validation.UserProfileValidationResult; +import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.util.JsonSerialization; import org.keycloak.utils.CredentialHelper; @@ -371,47 +369,43 @@ public class AccountFormService extends AbstractSecuredLocalService { event.event(EventType.UPDATE_PROFILE).client(auth.getClient()).user(auth.getUser()); - UserProfileValidationResult result = forOldAccount(user, formData, session).validate(); - List errors = Validation.getFormErrorsFromValidation(result); - - if (!errors.isEmpty()) { - setReferrerOnPage(); - Response.Status status = Status.OK; - - if (result.hasFailureOfErrorType(Messages.READ_ONLY_USERNAME)) { - status = Response.Status.BAD_REQUEST; - } else if (result.hasFailureOfErrorType(Messages.EMAIL_EXISTS, Messages.USERNAME_EXISTS)) { - status = Response.Status.CONFLICT; - } - - return account.setErrors(status, errors).setProfileFormData(formData).createResponse(AccountPages.ACCOUNT); - } - - UserProfile updatedProfile = result.getProfile(); - String newEmail = updatedProfile.getAttributes().getFirstAttribute(UserModel.EMAIL); - String newFirstName = updatedProfile.getAttributes().getFirstAttribute(UserModel.FIRST_NAME); - String newLastName = updatedProfile.getAttributes().getFirstAttribute(UserModel.LAST_NAME); - + UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); + UserProfile profile = profileProvider.create(UserProfileContext.ACCOUNT_OLD, formData, user); try { // backward compatibility with old account console where attributes are not removed if missing - UserUpdateHelper.updateAccountOldConsole(realm, user, updatedProfile); + profile.update(false, (attributeName, userModel) -> { + if (attributeName.equals(UserModel.EMAIL)) { + user.setEmailVerified(false); + event.detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, user.getEmail()).success(); + } + if (attributeName.equals(UserModel.FIRST_NAME)) { + event.detail(Details.PREVIOUS_FIRST_NAME, oldFirstName).detail(Details.UPDATED_FIRST_NAME, user.getFirstName()); + } + if (attributeName.equals(UserModel.LAST_NAME)) { + event.detail(Details.PREVIOUS_LAST_NAME, oldLastName).detail(Details.UPDATED_LAST_NAME, user.getLastName()); + } + }); + } catch (ValidationException pve) { + List errors = Validation.getFormErrorsFromValidation(pve.getErrors()); + + if (!errors.isEmpty()) { + setReferrerOnPage(); + Response.Status status = Status.OK; + + if (pve.hasError(Messages.READ_ONLY_USERNAME)) { + status = Response.Status.BAD_REQUEST; + } else if (pve.hasError(Messages.EMAIL_EXISTS, Messages.USERNAME_EXISTS)) { + status = Response.Status.CONFLICT; + } + + return account.setErrors(status, errors).setProfileFormData(formData).createResponse(AccountPages.ACCOUNT); + } } catch (ReadOnlyException e) { setReferrerOnPage(); return account.setError(Response.Status.BAD_REQUEST, Messages.READ_ONLY_USER).setProfileFormData(formData).createResponse(AccountPages.ACCOUNT); } - if (result.hasAttributeChanged(UserModel.FIRST_NAME)) { - event.detail(Details.PREVIOUS_FIRST_NAME, oldFirstName).detail(Details.UPDATED_FIRST_NAME, newFirstName); - } - if (result.hasAttributeChanged(UserModel.LAST_NAME)) { - event.detail(Details.PREVIOUS_LAST_NAME, oldLastName).detail(Details.UPDATED_LAST_NAME, newLastName); - } - if (result.hasAttributeChanged(UserModel.EMAIL)) { - user.setEmailVerified(false); - event.detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, newEmail); - } - event.success(); setReferrerOnPage(); return account.setSuccess(Messages.ACCOUNT_UPDATED).createResponse(AccountPages.ACCOUNT); diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java index 07bd10260d..71bc41d233 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java @@ -16,8 +16,6 @@ */ package org.keycloak.services.resources.account; -import static org.keycloak.userprofile.profile.UserProfileContextFactory.forAccountService; - import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.spi.HttpRequest; import org.keycloak.common.ClientConnection; @@ -42,14 +40,15 @@ import org.keycloak.representations.account.UserRepresentation; import org.keycloak.services.ErrorResponse; import org.keycloak.services.managers.Auth; import org.keycloak.services.managers.UserConsentManager; -import org.keycloak.services.managers.UserSessionManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.account.resources.ResourcesService; import org.keycloak.services.util.ResolveRelative; import org.keycloak.storage.ReadOnlyException; import org.keycloak.theme.Theme; -import org.keycloak.userprofile.utils.UserUpdateHelper; -import org.keycloak.userprofile.validation.UserProfileValidationResult; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.ValidationException; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileProvider; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; @@ -158,25 +157,26 @@ public class AccountRestService { event.event(EventType.UPDATE_PROFILE).client(auth.getClient()).user(auth.getUser()); - UserProfileValidationResult result = forAccountService(user, rep, session).validate(); - - if (result.hasFailureOfErrorType(Messages.READ_ONLY_USERNAME)) - return ErrorResponse.error(Messages.READ_ONLY_USERNAME, Response.Status.BAD_REQUEST); - if (result.hasFailureOfErrorType(Messages.USERNAME_EXISTS)) - return ErrorResponse.exists(Messages.USERNAME_EXISTS); - if (result.hasFailureOfErrorType(Messages.EMAIL_EXISTS)) - return ErrorResponse.exists(Messages.EMAIL_EXISTS); - if (!result.getErrors().isEmpty()) { - // Here should be possibility to somehow return all errors? - String firstErrorMessage = result.getErrors().get(0).getFailedValidations().get(0).getErrorType(); - return ErrorResponse.error(firstErrorMessage, Response.Status.BAD_REQUEST); - } - try { - UserUpdateHelper.updateAccount(realm, user, result.getProfile()); + UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); + UserProfile profile = profileProvider.create(UserProfileContext.ACCOUNT, rep.toAttributes(), auth.getUser()); + + profile.update(); + event.success(); return Response.noContent().build(); + } catch (ValidationException pve) { + if (pve.hasError(Messages.READ_ONLY_USERNAME)) + return ErrorResponse.error(Messages.READ_ONLY_USERNAME, Response.Status.BAD_REQUEST); + if (pve.hasError(Messages.USERNAME_EXISTS)) + return ErrorResponse.exists(Messages.USERNAME_EXISTS); + if (pve.hasError(Messages.EMAIL_EXISTS)) + return ErrorResponse.exists(Messages.EMAIL_EXISTS); + + // Here should be possibility to somehow return all errors? + String firstErrorMessage = pve.getErrors().get(0).getMessage(); + return ErrorResponse.error(firstErrorMessage, Response.Status.BAD_REQUEST); } catch (ReadOnlyException e) { return ErrorResponse.error(Messages.READ_ONLY_USER, Response.Status.BAD_REQUEST); } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java new file mode 100644 index 0000000000..0e50b24716 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java @@ -0,0 +1,85 @@ +/* + * 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.services.resources.admin; + +import java.io.IOException; + +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; + +import org.keycloak.component.ComponentValidationException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; +import org.keycloak.userprofile.UserProfileProvider; + +/** + * + * @author Vlastimil Elias + * + */ +public class UserProfileResource { + + @Context + protected KeycloakSession session; + + protected RealmModel realm; + private AdminPermissionEvaluator auth; + + public UserProfileResource(RealmModel realm, AdminPermissionEvaluator auth) { + this.realm = realm; + this.auth = auth; + } + + + @GET + @Path("configuration") + @Produces(MediaType.APPLICATION_JSON) + public String getConfiguration() { + + auth.realm().requireViewRealm(); + + UserProfileProvider t = session.getProvider(UserProfileProvider.class); + return t.getConfiguration(); + } + + @PUT + @Path("configuration") + @Consumes(MediaType.APPLICATION_JSON) + public Response updateConfiguration(String text) throws IOException { + + auth.realm().requireManageRealm(); + + UserProfileProvider t = session.getProvider(UserProfileProvider.class); + try { + t.setConfiguration(text); + } catch (ComponentValidationException e) { + //show validation result containing details about error + return Response.status(Status.BAD_REQUEST).type(MediaType.TEXT_PLAIN).entity(e.getMessage()).build(); + } + + return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build(); + } + +} diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java index 346b3b0e71..91b3922c70 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java @@ -72,10 +72,9 @@ import org.keycloak.services.resources.account.AccountFormService; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.services.validation.Validation; import org.keycloak.storage.ReadOnlyException; -import org.keycloak.userprofile.utils.UserUpdateHelper; -import org.keycloak.userprofile.validation.AttributeValidationResult; -import org.keycloak.userprofile.validation.UserProfileValidationResult; -import org.keycloak.userprofile.validation.ValidationResult; +import org.keycloak.userprofile.ValidationException; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.utils.ProfileHelper; import javax.ws.rs.BadRequestException; @@ -113,7 +112,7 @@ import java.util.stream.Stream; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME; -import static org.keycloak.userprofile.profile.UserProfileContextFactory.forUserResource; +import static org.keycloak.userprofile.UserProfileContext.USER_API; /** * Base resource for managing users @@ -170,11 +169,14 @@ public class UserResource { wasPermanentlyLockedOut = session.getProvider(BruteForceProtector.class).isPermanentlyLockedOut(session, realm, user); } - Response response = validateUserProfile(user, rep, session); + UserProfile profile = session.getProvider(UserProfileProvider.class).create(USER_API, rep.toAttributes(), user); + + Response response = validateUserProfile(profile); if (response != null) { return response; } - updateUserFromRep(user, rep, session, true); + profile.update(rep.getAttributes() != null); + updateUserFromRep(profile, user, rep, session, true); RepresentationToModel.createCredentials(rep, session, realm, user, true); // we need to do it here as the attributes would be overwritten by what is in the rep @@ -203,25 +205,25 @@ public class UserResource { } } - public static Response validateUserProfile(UserModel user, UserRepresentation rep, KeycloakSession session) { - UserProfileValidationResult result = forUserResource(user, rep, session).validate(); - if (!result.getErrors().isEmpty()) { - for (AttributeValidationResult attrValidation : result.getErrors()) { - StringBuilder s = new StringBuilder("Failed to update attribute " + attrValidation.getField() + ": "); - for (ValidationResult valResult : attrValidation.getFailedValidations()) { - s.append(valResult.getErrorType() + ", "); - } + public static Response validateUserProfile(UserProfile profile) { + try { + profile.validate(); + } catch (ValidationException pve) { + for (ValidationException.Error error : pve.getErrors()) { + StringBuilder s = new StringBuilder("Failed to update attribute " + error.getAttribute() + ": "); + + s.append(error.getMessage()).append(", "); + logger.warn(s); } return ErrorResponse.error("Could not update user! See server log for more details", Response.Status.BAD_REQUEST); - } else { - return null; } + + return null; } - public static void updateUserFromRep(UserModel user, UserRepresentation rep, KeycloakSession session, boolean isUpdateExistingUser) { + public static void updateUserFromRep(UserProfile profile, UserModel user, UserRepresentation rep, KeycloakSession session, boolean isUpdateExistingUser) { boolean removeMissingRequiredActions = isUpdateExistingUser; - UserUpdateHelper.updateUserResource(session, user, rep, rep.getAttributes() != null); if (rep.isEnabled() != null) user.setEnabled(rep.isEnabled()); if (rep.isEmailVerified() != null) user.setEmailVerified(rep.isEmailVerified()); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java index 375eaa5e92..11387f2bbf 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java @@ -16,6 +16,8 @@ */ package org.keycloak.services.resources.admin; +import static org.keycloak.userprofile.UserProfileContext.USER_API; + import org.jboss.logging.Logger; import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.spi.ResteasyProviderFactory; @@ -39,6 +41,8 @@ import org.keycloak.services.ErrorResponse; import org.keycloak.services.ForbiddenException; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.services.resources.admin.permissions.UserPermissionEvaluator; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileProvider; import javax.ws.rs.Consumes; import javax.ws.rs.GET; @@ -146,15 +150,19 @@ public class UsersResource { } } + UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); + + UserProfile profile = profileProvider.create(USER_API, rep.toAttributes()); + try { - Response response = UserResource.validateUserProfile(null, rep, session); + Response response = UserResource.validateUserProfile(profile); if (response != null) { return response; } - UserModel user = session.users().addUser(realm, username); + UserModel user = profile.create(); - UserResource.updateUserFromRep(user, rep, session, false); + UserResource.updateUserFromRep(profile, user, rep, session, false); RepresentationToModel.createFederatedIdentities(rep, session, realm, user); RepresentationToModel.createGroups(rep, realm, user); diff --git a/services/src/main/java/org/keycloak/services/validation/Validation.java b/services/src/main/java/org/keycloak/services/validation/Validation.java index ab0f681ecc..a12e6ffe10 100755 --- a/services/src/main/java/org/keycloak/services/validation/Validation.java +++ b/services/src/main/java/org/keycloak/services/validation/Validation.java @@ -26,21 +26,17 @@ import org.keycloak.policy.PasswordPolicyManagerProvider; import org.keycloak.policy.PolicyError; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.services.messages.Messages; -import org.keycloak.userprofile.validation.AttributeValidationResult; -import org.keycloak.userprofile.validation.UserProfileValidationResult; +import org.keycloak.userprofile.ValidationException; import javax.ws.rs.core.MultivaluedMap; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; -import java.util.stream.Collectors; public class Validation { public static final String FIELD_PASSWORD_CONFIRM = "password-confirm"; public static final String FIELD_EMAIL = "email"; - public static final String FIELD_LAST_NAME = "lastName"; - public static final String FIELD_FIRST_NAME = "firstName"; public static final String FIELD_PASSWORD = "password"; public static final String FIELD_USERNAME = "username"; public static final String FIELD_OTP_CODE = "totp"; @@ -49,76 +45,10 @@ public class Validation { // Actually allow same emails like angular. See ValidationTest.testEmailValidation() private static final Pattern EMAIL_PATTERN = Pattern.compile("[a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-zA-Z0-9-]+(\\.[a-zA-Z0-9-]+)*"); - public static List validateRegistrationForm(KeycloakSession session, RealmModel realm, MultivaluedMap formData, List requiredCredentialTypes, PasswordPolicy policy) { - List errors = new ArrayList<>(); - - if (!realm.isRegistrationEmailAsUsername() && isBlank(formData.getFirst(FIELD_USERNAME))) { - addError(errors, FIELD_USERNAME, Messages.MISSING_USERNAME); - } - - if (isBlank(formData.getFirst(FIELD_FIRST_NAME))) { - addError(errors, FIELD_FIRST_NAME, Messages.MISSING_FIRST_NAME); - } - - if (isBlank(formData.getFirst(FIELD_LAST_NAME))) { - addError(errors, FIELD_LAST_NAME, Messages.MISSING_LAST_NAME); - } - - if (isBlank(formData.getFirst(FIELD_EMAIL))) { - addError(errors, FIELD_EMAIL, Messages.MISSING_EMAIL); - } else if (!isEmailValid(formData.getFirst(FIELD_EMAIL))) { - addError(errors, FIELD_EMAIL, Messages.INVALID_EMAIL); - } - - if (requiredCredentialTypes.contains(CredentialRepresentation.PASSWORD)) { - if (isBlank(formData.getFirst(FIELD_PASSWORD))) { - addError(errors, FIELD_PASSWORD, Messages.MISSING_PASSWORD); - } else if (!formData.getFirst(FIELD_PASSWORD).equals(formData.getFirst(FIELD_PASSWORD_CONFIRM))) { - addError(errors, FIELD_PASSWORD_CONFIRM, Messages.INVALID_PASSWORD_CONFIRM); - } - } - - if (formData.getFirst(FIELD_PASSWORD) != null) { - PolicyError err = session.getProvider(PasswordPolicyManagerProvider.class).validate(realm.isRegistrationEmailAsUsername() ? formData.getFirst(FIELD_EMAIL) : formData.getFirst(FIELD_USERNAME), formData.getFirst(FIELD_PASSWORD)); - if (err != null) - errors.add(new FormMessage(FIELD_PASSWORD, err.getMessage(), err.getParameters())); - } - - return errors; - } - private static void addError(List errors, String field, String message){ errors.add(new FormMessage(field, message)); } - public static List validateUpdateProfileForm(RealmModel realm, MultivaluedMap formData) { - return validateUpdateProfileForm(realm, formData, realm.isEditUsernameAllowed()); - } - - public static List validateUpdateProfileForm(RealmModel realm, MultivaluedMap formData, boolean userNameRequired) { - List errors = new ArrayList<>(); - - if (!realm.isRegistrationEmailAsUsername() && userNameRequired && isBlank(formData.getFirst(FIELD_USERNAME))) { - addError(errors, FIELD_USERNAME, Messages.MISSING_USERNAME); - } - - if (isBlank(formData.getFirst(FIELD_FIRST_NAME))) { - addError(errors, FIELD_FIRST_NAME, Messages.MISSING_FIRST_NAME); - } - - if (isBlank(formData.getFirst(FIELD_LAST_NAME))) { - addError(errors, FIELD_LAST_NAME, Messages.MISSING_LAST_NAME); - } - - if (isBlank(formData.getFirst(FIELD_EMAIL))) { - addError(errors, FIELD_EMAIL, Messages.MISSING_EMAIL); - } else if (!isEmailValid(formData.getFirst(FIELD_EMAIL))) { - addError(errors, FIELD_EMAIL, Messages.INVALID_EMAIL); - } - - return errors; - } - /** * Validate if user object contains all mandatory fields. * @@ -155,12 +85,12 @@ public class Validation { } - public static List getFormErrorsFromValidation(UserProfileValidationResult results) { - List errors = new ArrayList<>(); - for (AttributeValidationResult result : results.getErrors()) { - result.getFailedValidations().forEach(o -> addError(errors, result.getField(), o.getErrorType())); + public static List getFormErrorsFromValidation(List errors) { + List messages = new ArrayList<>(); + for (ValidationException.Error error : errors) { + addError(messages, error.getAttribute(), error.getMessage()); } - return errors; + return messages; } } diff --git a/services/src/main/java/org/keycloak/userprofile/LegacyUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/LegacyUserProfileProvider.java deleted file mode 100644 index d6f7ac45c5..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/LegacyUserProfileProvider.java +++ /dev/null @@ -1,152 +0,0 @@ -/* - * - * * 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; - -import java.util.regex.Pattern; - -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.UserModel; -import org.keycloak.services.messages.Messages; -import org.keycloak.userprofile.validation.StaticValidators; -import org.keycloak.userprofile.validation.UserProfileValidationResult; -import org.keycloak.userprofile.validation.ValidationChainBuilder; - -/** - * @author Markus Till - */ -public class LegacyUserProfileProvider implements UserProfileProvider { - - private final KeycloakSession session; - private final Pattern readOnlyAttributes; - private final Pattern adminReadOnlyAttributes; - - public LegacyUserProfileProvider(KeycloakSession session, Pattern readOnlyAttributes, Pattern adminReadOnlyAttributes) { - this.session = session; - this.readOnlyAttributes = readOnlyAttributes; - this.adminReadOnlyAttributes = adminReadOnlyAttributes; - } - - @Override - public void close() { - - } - - @Override - public UserProfileValidationResult validate(UserProfileContext updateContext, UserProfile updatedProfile) { - RealmModel realm = this.session.getContext().getRealm(); - - ValidationChainBuilder builder = ValidationChainBuilder.builder(); - switch (updateContext.getUpdateEvent()) { - case UserResource: - addReadOnlyAttributeValidators(builder, adminReadOnlyAttributes, updateContext, updatedProfile); - break; - case IdpReview: - addBasicValidators(builder, !realm.isRegistrationEmailAsUsername()); - addReadOnlyAttributeValidators(builder, readOnlyAttributes, updateContext, updatedProfile); - break; - case Account: - case RegistrationProfile: - case UpdateProfile: - addBasicValidators(builder, !realm.isRegistrationEmailAsUsername() && realm.isEditUsernameAllowed()); - addReadOnlyAttributeValidators(builder, readOnlyAttributes, updateContext, updatedProfile); - addSessionValidators(builder); - break; - case RegistrationUserCreation: - addUserCreationValidators(builder); - addReadOnlyAttributeValidators(builder, readOnlyAttributes, updateContext, updatedProfile); - break; - } - return new UserProfileValidationResult(builder.build().validate(updateContext,updatedProfile), updatedProfile); - } - - @Override - public boolean isReadOnlyAttribute(String key) { - return readOnlyAttributes.matcher(key).find() || adminReadOnlyAttributes.matcher(key).find(); - } - - private void addUserCreationValidators(ValidationChainBuilder builder) { - RealmModel realm = this.session.getContext().getRealm(); - - if (realm.isRegistrationEmailAsUsername()) { - builder.addAttributeValidator().forAttribute(UserModel.EMAIL) - .addSingleAttributeValueValidationFunction(Messages.INVALID_EMAIL, StaticValidators.isEmailValid()) - .addSingleAttributeValueValidationFunction(Messages.MISSING_EMAIL, StaticValidators.isBlank()) - .addSingleAttributeValueValidationFunction(Messages.EMAIL_EXISTS, StaticValidators.doesEmailExist(session)).build() - .build(); - - - } else { - builder.addAttributeValidator().forAttribute(UserModel.USERNAME) - .addSingleAttributeValueValidationFunction(Messages.MISSING_USERNAME, StaticValidators.isBlank()) - .addSingleAttributeValueValidationFunction(Messages.USERNAME_EXISTS, - (value, o) -> session.users().getUserByUsername(realm, value) == null) - .build(); - } - } - - private void addBasicValidators(ValidationChainBuilder builder, boolean userNameExistsCondition) { - - builder.addAttributeValidator().forAttribute(UserModel.USERNAME) - .addSingleAttributeValueValidationFunction(Messages.MISSING_USERNAME, StaticValidators.checkUsernameExists(userNameExistsCondition)).build() - - .addAttributeValidator().forAttribute(UserModel.FIRST_NAME) - .addSingleAttributeValueValidationFunction(Messages.MISSING_FIRST_NAME, StaticValidators.isBlank()).build() - - .addAttributeValidator().forAttribute(UserModel.LAST_NAME) - .addSingleAttributeValueValidationFunction(Messages.MISSING_LAST_NAME, StaticValidators.isBlank()).build() - - .addAttributeValidator().forAttribute(UserModel.EMAIL) - .addSingleAttributeValueValidationFunction(Messages.MISSING_EMAIL, StaticValidators.isBlank()) - .addSingleAttributeValueValidationFunction(Messages.INVALID_EMAIL, StaticValidators.isEmailValid()) - .build(); - } - - private void addSessionValidators(ValidationChainBuilder builder) { - RealmModel realm = this.session.getContext().getRealm(); - builder.addAttributeValidator().forAttribute(UserModel.USERNAME) - .addSingleAttributeValueValidationFunction(Messages.USERNAME_EXISTS, StaticValidators.userNameExists(session)) - .addSingleAttributeValueValidationFunction(Messages.READ_ONLY_USERNAME, StaticValidators.isUserMutable(realm)).build() - - .addAttributeValidator().forAttribute(UserModel.EMAIL) - .addSingleAttributeValueValidationFunction(Messages.EMAIL_EXISTS, StaticValidators.isEmailDuplicated(session)) - .addSingleAttributeValueValidationFunction(Messages.USERNAME_EXISTS, StaticValidators.doesEmailExistAsUsername(session)).build() - .build(); - } - - private void addReadOnlyAttributeValidators(ValidationChainBuilder builder, Pattern configuredReadOnlyAttrs, UserProfileContext updateContext, UserProfile updatedProfile) { - addValidatorsForReadOnlyAttributes(builder, configuredReadOnlyAttrs, updatedProfile); - addValidatorsForReadOnlyAttributes(builder, configuredReadOnlyAttrs, updateContext.getCurrentProfile()); - } - - - private void addValidatorsForReadOnlyAttributes(ValidationChainBuilder builder, Pattern configuredReadOnlyAttrsPattern, UserProfile profile) { - if (profile == null) { - return; - } - - profile.getAttributes().keySet().stream() - .filter(currentAttrName -> configuredReadOnlyAttrsPattern.matcher(currentAttrName).find()) - .forEach((currentAttrName) -> - builder.addAttributeValidator().forAttribute(currentAttrName) - .addValidationFunction(Messages.UPDATE_READ_ONLY_ATTRIBUTES_REJECTED, StaticValidators.isReadOnlyAttributeUnchanged(currentAttrName)).build() - ); - } -} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/userprofile/LegacyUserProfileProviderFactory.java b/services/src/main/java/org/keycloak/userprofile/LegacyUserProfileProviderFactory.java deleted file mode 100644 index b9ab129743..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/LegacyUserProfileProviderFactory.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * 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.userprofile; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -import org.jboss.logging.Logger; -import org.keycloak.Config; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; - -/** - * @author Markus Till - */ -public class LegacyUserProfileProviderFactory implements UserProfileProviderFactory { - - private static final Logger logger = Logger.getLogger(LegacyUserProfileProviderFactory.class); - - UserProfileProvider provider; - - // Attributes, which can't be updated by user himself - private Pattern readOnlyAttributesPattern; - - // Attributes, which can't be updated by administrator - private Pattern adminReadOnlyAttributesPattern; - - private String[] DEFAULT_READ_ONLY_ATTRIBUTES = { "KERBEROS_PRINCIPAL", "LDAP_ID", "LDAP_ENTRY_DN", "CREATED_TIMESTAMP", "createTimestamp", "modifyTimestamp", "userCertificate", "saml.persistent.name.id.for.*", "ENABLED", "EMAIL_VERIFIED", "disabledReason" }; - private String[] DEFAULT_ADMIN_READ_ONLY_ATTRIBUTES = { "KERBEROS_PRINCIPAL", "LDAP_ID", "LDAP_ENTRY_DN", "CREATED_TIMESTAMP", "createTimestamp", "modifyTimestamp" }; - - @Override - public UserProfileProvider create(KeycloakSession session) { - provider = new LegacyUserProfileProvider(session, readOnlyAttributesPattern, adminReadOnlyAttributesPattern); - - return provider; - } - - @Override - public void init(Config.Scope config) { - this.readOnlyAttributesPattern = getRegexPatternString(config, "read-only-attributes", DEFAULT_READ_ONLY_ATTRIBUTES); - this.adminReadOnlyAttributesPattern = getRegexPatternString(config, "admin-read-only-attributes", DEFAULT_ADMIN_READ_ONLY_ATTRIBUTES); - } - - private Pattern getRegexPatternString(Config.Scope config, String configKey, String[] builtinReadOnlyAttributes) { - String[] readOnlyAttributesCfg = config.getArray(configKey); - List readOnlyAttributes = new ArrayList<>(Arrays.asList(builtinReadOnlyAttributes)); - if (readOnlyAttributesCfg != null) { - List configured = Arrays.asList(readOnlyAttributesCfg); - logger.infof("Configured %s: %s", configKey, configured); - readOnlyAttributes.addAll(configured); - } - - String regexStr = readOnlyAttributes.stream() - .map(configAttrName -> configAttrName.endsWith("*") - ? "^" + Pattern.quote(configAttrName.substring(0, configAttrName.length() - 1)) + ".*$" - : "^" + Pattern.quote(configAttrName ) + "$") - .collect(Collectors.joining("|")); - regexStr = "(?i:" + regexStr + ")"; - - logger.debugf("Regex used for %s: %s", configKey, regexStr); - return Pattern.compile(regexStr); - } - - @Override - public void postInit(KeycloakSessionFactory factory) { - } - - @Override - public void close() { - - } - public static final String PROVIDER_ID = "legacy-user-profile"; - - @Override - public String getId() { - return PROVIDER_ID; - } - - -} diff --git a/services/src/main/java/org/keycloak/userprofile/legacy/AbstractUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/legacy/AbstractUserProfileProvider.java new file mode 100644 index 0000000000..6a251f4fe6 --- /dev/null +++ b/services/src/main/java/org/keycloak/userprofile/legacy/AbstractUserProfileProvider.java @@ -0,0 +1,411 @@ +/* + * + * * 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.legacy; + +import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY; +import static org.keycloak.userprofile.UserProfileContext.*; +import static org.keycloak.userprofile.UserProfileContext.ACCOUNT; +import static org.keycloak.userprofile.UserProfileContext.ACCOUNT_OLD; +import static org.keycloak.userprofile.UserProfileContext.IDP_REVIEW; +import static org.keycloak.userprofile.UserProfileContext.REGISTRATION_PROFILE; +import static org.keycloak.userprofile.UserProfileContext.UPDATE_PROFILE; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.common.util.ObjectUtil; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.messages.Messages; +import org.keycloak.services.validation.Validation; +import org.keycloak.userprofile.Attributes; +import org.keycloak.userprofile.DefaultAttributes; +import org.keycloak.userprofile.DefaultUserProfile; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileMetadata; +import org.keycloak.userprofile.UserProfileProvider; +import org.keycloak.userprofile.UserProfileProviderFactory; +import org.keycloak.userprofile.AttributeValidatorMetadata; +import org.keycloak.userprofile.validation.Validator; + +/** + *

A base class for {@link UserProfileProvider} implementations providing the main hooks for customizations. + * + * @author Markus Till + */ +public abstract class AbstractUserProfileProvider implements UserProfileProvider, UserProfileProviderFactory { + + private static final Logger logger = Logger.getLogger(DefaultAttributes.class); + + public static Pattern getRegexPatternString(String[] builtinReadOnlyAttributes) { + if (builtinReadOnlyAttributes != null) { + List readOnlyAttributes = new ArrayList<>(Arrays.asList(builtinReadOnlyAttributes)); + + String regexStr = readOnlyAttributes.stream() + .map(configAttrName -> configAttrName.endsWith("*") + ? "^" + Pattern.quote(configAttrName.substring(0, configAttrName.length() - 1)) + ".*$" + : "^" + Pattern.quote(configAttrName) + "$") + .collect(Collectors.joining("|")); + regexStr = "(?i:" + regexStr + ")"; + + return Pattern.compile(regexStr); + } + + return null; + } + + public static Validator isReadOnlyAttributeUnchanged(Pattern pattern) { + return (context) -> { + Map.Entry> attribute = context.getAttribute(); + String key = attribute.getKey(); + + if (!pattern.matcher(key).find()) { + return true; + } + + List values = attribute.getValue(); + + if (values == null) { + return true; + } + + UserModel user = context.getUser(); + + List existingAttrValues = user == null ? null : user.getAttribute(key); + String existingValue = null; + + if (existingAttrValues != null && !existingAttrValues.isEmpty()) { + existingValue = existingAttrValues.get(0); + } + + if (values.isEmpty() && existingValue != null) { + return false; + } + + String value = null; + + if (!values.isEmpty()) { + value = values.get(0); + } + + boolean result = ObjectUtil.isEqualOrBothNull(value, existingValue); + + if (!result) { + logger.warnf("Attempt to edit denied attribute '%s' of user '%s'", pattern, user == null ? "new user" : user.getFirstAttribute(UserModel.USERNAME)); + } + + return result; + }; + } + + /** + * There are the declarations for creating the built-in validations for read-only attributes. Regardless of the context where + * user profiles are used. They are related to internal attributes with hard conditions on them in terms of management. + */ + private static String UPDATE_READ_ONLY_ATTRIBUTES_REJECTED = "updateReadOnlyAttributesRejectedMessage"; + private static String[] DEFAULT_READ_ONLY_ATTRIBUTES = { "KERBEROS_PRINCIPAL", "LDAP_ID", "LDAP_ENTRY_DN", "CREATED_TIMESTAMP", "createTimestamp", "modifyTimestamp", "userCertificate", "saml.persistent.name.id.for.*", "ENABLED", "EMAIL_VERIFIED", "disabledReason" }; + private static String[] DEFAULT_ADMIN_READ_ONLY_ATTRIBUTES = { "KERBEROS_PRINCIPAL", "LDAP_ID", "LDAP_ENTRY_DN", "CREATED_TIMESTAMP", "createTimestamp", "modifyTimestamp" }; + private static Pattern readOnlyAttributesPattern = getRegexPatternString(DEFAULT_READ_ONLY_ATTRIBUTES); + private static Pattern adminReadOnlyAttributesPattern = getRegexPatternString(DEFAULT_ADMIN_READ_ONLY_ATTRIBUTES); + + protected final Map contextualMetadataRegistry; + protected final KeycloakSession session; + + public AbstractUserProfileProvider() { + // for reflection + this(null, new HashMap<>()); + } + + public AbstractUserProfileProvider(KeycloakSession session, Map contextualMetadataRegistry) { + this.session = session; + this.contextualMetadataRegistry = contextualMetadataRegistry; + } + + @Override + public UserProfile create(UserProfileContext context, UserModel user) { + return createUserProfile(context, user.getAttributes(), user); + } + + @Override + public UserProfile create(UserProfileContext context, Map attributes, UserModel user) { + return createUserProfile(context, attributes, user); + } + + @Override + public UserProfile create(UserProfileContext context, Map attributes) { + return createUserProfile(context, attributes, null); + } + + @Override + public U create(KeycloakSession session) { + return create(session, contextualMetadataRegistry); + } + + @Override + public void init(Config.Scope config) { + Pattern pattern = getRegexPatternString(config.getArray("read-only-attributes")); + AttributeValidatorMetadata readOnlyValidator = null; + + if (pattern != null) { + readOnlyValidator = Validators.create(Messages.UPDATE_READ_ONLY_ATTRIBUTES_REJECTED, isReadOnlyAttributeUnchanged(pattern)); + } + + addContextualProfileMetadata(configureUserProfile(createBrokeringProfile(readOnlyValidator))); + addContextualProfileMetadata(configureUserProfile(createDefaultProfile(ACCOUNT, readOnlyValidator))); + addContextualProfileMetadata(configureUserProfile(createDefaultProfile(ACCOUNT_OLD, readOnlyValidator))); + addContextualProfileMetadata(configureUserProfile(createDefaultProfile(REGISTRATION_PROFILE, readOnlyValidator))); + addContextualProfileMetadata(configureUserProfile(createDefaultProfile(UPDATE_PROFILE, readOnlyValidator))); + addContextualProfileMetadata(configureUserProfile(createRegistrationUserCreationProfile())); + addContextualProfileMetadata(configureUserProfile(createUserResourceValidation(config))); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + + } + + @Override + public String getConfiguration() { + return null; + } + + @Override + public void setConfiguration(String configuration) { + + } + + /** + * Subclasses can override this method to create their instances of {@link UserProfileProvider}. + * + * @param session the session + * @param metadataRegistry the profile metadata + * + * @return the profile provider instance + */ + protected abstract U create(KeycloakSession session, Map metadataRegistry); + + /** + * Sub-types can override this method to customize how contextual profile metadata is configured at init time. + * + * @param metadata the profile metadata + * @return the metadata + */ + protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata) { + return metadata; + } + + /** + * Sub-types can override this method to customize how contextual profile metadata is configured at runtime. + * + * @param metadata the profile metadata + * @param metadata the current session + * @return the metadata + */ + protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata, KeycloakSession session) { + return metadata; + } + + /** + * Creates a {@link Function} for creating new users when the creating them using {@link UserProfile#create()}. + * + * @return a function for creating new users. + */ + private Function createUserFactory() { + return new Function() { + private UserModel user; + + @Override + public UserModel apply(Attributes attributes) { + if (user == null) { + String userName = attributes.getFirstValue(UserModel.USERNAME); + + // fallback to email in case email is allowed + if (userName == null) { + userName = attributes.getFirstValue(UserModel.EMAIL); + } + + user = session.users().addUser(session.getContext().getRealm(), userName); + } + + return user; + } + }; + } + + private UserProfile createUserProfile(UserProfileContext context, Map attributes, UserModel user) { + UserProfileMetadata metadata = configureUserProfile(contextualMetadataRegistry.get(context), session); + Attributes profileAttributes = new DefaultAttributes(context, attributes, user, metadata, session); + return new DefaultUserProfile(profileAttributes, createUserFactory(), user); + } + + private void addContextualProfileMetadata(UserProfileMetadata metadata) { + if (contextualMetadataRegistry.putIfAbsent(metadata.getContext(), metadata) != null) { + throw new IllegalStateException("Multiple profile metadata found for context " + metadata.getContext()); + } + } + + private UserProfileMetadata createRegistrationUserCreationProfile() { + UserProfileMetadata metadata = new UserProfileMetadata(REGISTRATION_USER_CREATION); + + metadata.addAttribute(UserModel.USERNAME, Validators.create(Messages.MISSING_USERNAME, (context) -> { + RealmModel realm = context.getSession().getContext().getRealm(); + + if (!realm.isRegistrationEmailAsUsername()) { + return true; + } + + return Validators.isBlank().validate(context); + }), Validators.create(Messages.USERNAME_EXISTS, + (context) -> { + KeycloakSession session = context.getSession(); + RealmModel realm = session.getContext().getRealm(); + + if (realm.isRegistrationEmailAsUsername()) { + return true; + } + + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + + if (values.isEmpty()) { + return true; + } + + String value = values.get(0); + + UserModel existing = session.users().getUserByUsername(realm, value); + return existing == null; + })); + + metadata.addAttribute(UserModel.EMAIL, Validators.create(Messages.INVALID_EMAIL, (context) -> { + RealmModel realm = context.getSession().getContext().getRealm(); + + if (!realm.isRegistrationEmailAsUsername()) { + return true; + } + + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + + if (values.isEmpty()) { + return true; + } + + String value = values.get(0); + + return Validation.isBlank(value) || Validation.isEmailValid(value); + })); + + metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, new AttributeValidatorMetadata(UPDATE_READ_ONLY_ATTRIBUTES_REJECTED, isReadOnlyAttributeUnchanged(readOnlyAttributesPattern))); + + return metadata; + } + + private UserProfileMetadata createDefaultProfile(UserProfileContext context, AttributeValidatorMetadata readOnlyValidator) { + UserProfileMetadata metadata = new UserProfileMetadata(context); + + metadata.addAttribute(UserModel.USERNAME, Validators.create(Messages.MISSING_USERNAME, Validators.checkUsernameExists()), + Validators.create(Messages.USERNAME_EXISTS, Validators.userNameExists()), + Validators.create(Messages.READ_ONLY_USERNAME, Validators.isUserMutable())); + + metadata.addAttribute(UserModel.FIRST_NAME, Validators.create(Messages.MISSING_FIRST_NAME, Validators.isBlank())); + + metadata.addAttribute(UserModel.LAST_NAME, Validators.create(Messages.MISSING_LAST_NAME, Validators.isBlank())); + + metadata.addAttribute(UserModel.EMAIL, Validators.create(Messages.MISSING_EMAIL, Validators.isBlank()), + Validators.create(Messages.INVALID_EMAIL, Validators.isEmailValid()), + Validators.create(Messages.EMAIL_EXISTS, Validators.isEmailDuplicated()), + Validators.create(Messages.USERNAME_EXISTS, Validators.doesEmailExistAsUsername())); + + List readonlyValidators = new ArrayList<>(); + + readonlyValidators.add(new AttributeValidatorMetadata(UPDATE_READ_ONLY_ATTRIBUTES_REJECTED, + isReadOnlyAttributeUnchanged(readOnlyAttributesPattern))); + + if (readOnlyValidator != null) { + readonlyValidators.add(readOnlyValidator); + } + + metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, readonlyValidators); + + return metadata; + } + + private UserProfileMetadata createBrokeringProfile(AttributeValidatorMetadata readOnlyValidator) { + UserProfileMetadata metadata = new UserProfileMetadata(IDP_REVIEW); + + metadata.addAttribute(UserModel.USERNAME, Validators + .create(Messages.MISSING_USERNAME, Validators.checkFederatedUsernameExists())); + + metadata.addAttribute(UserModel.FIRST_NAME, + Validators.create(Messages.MISSING_FIRST_NAME, Validators.isBlank())); + + metadata.addAttribute(UserModel.LAST_NAME, + Validators.create(Messages.MISSING_LAST_NAME, Validators.isBlank())); + + metadata.addAttribute(UserModel.EMAIL, Validators.create(Messages.MISSING_EMAIL, Validators.isBlank()), + Validators.create(Messages.INVALID_EMAIL, Validators.isEmailValid())); + + List readonlyValidators = new ArrayList<>(); + + readonlyValidators.add(new AttributeValidatorMetadata(UPDATE_READ_ONLY_ATTRIBUTES_REJECTED, + isReadOnlyAttributeUnchanged(readOnlyAttributesPattern))); + + if (readOnlyValidator != null) { + readonlyValidators.add(readOnlyValidator); + } + + metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, readonlyValidators); + + return metadata; + } + + private UserProfileMetadata createUserResourceValidation(Config.Scope config) { + Pattern p = getRegexPatternString(config.getArray("admin-read-only-attributes")); + UserProfileMetadata metadata = new UserProfileMetadata(USER_API); + List readonlyValidators = new ArrayList<>(); + + if (p != null) { + readonlyValidators.add(Validators.create(Messages.UPDATE_READ_ONLY_ATTRIBUTES_REJECTED, isReadOnlyAttributeUnchanged(p))); + } + + readonlyValidators.add(new AttributeValidatorMetadata(UPDATE_READ_ONLY_ATTRIBUTES_REJECTED, + isReadOnlyAttributeUnchanged(adminReadOnlyAttributesPattern))); + + metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, readonlyValidators); + + return metadata; + } +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/userprofile/legacy/DefaultUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/legacy/DefaultUserProfileProvider.java new file mode 100644 index 0000000000..430b24a9ee --- /dev/null +++ b/services/src/main/java/org/keycloak/userprofile/legacy/DefaultUserProfileProvider.java @@ -0,0 +1,57 @@ +/* + * + * * 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.legacy; + +import java.util.Map; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileMetadata; + +/** + * @author Markus Till + */ +public class DefaultUserProfileProvider extends AbstractUserProfileProvider { + + private static final String PROVIDER_ID = "legacy-user-profile"; + + public DefaultUserProfileProvider() { + // for reflection + } + + public DefaultUserProfileProvider(KeycloakSession session, Map validators) { + super(session, validators); + } + + @Override + protected DefaultUserProfileProvider create(KeycloakSession session, Map metadataRegistry) { + return new DefaultUserProfileProvider(session, metadataRegistry); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public int order() { + return 1; + } +} diff --git a/services/src/main/java/org/keycloak/userprofile/legacy/Validators.java b/services/src/main/java/org/keycloak/userprofile/legacy/Validators.java new file mode 100644 index 0000000000..c6911c1490 --- /dev/null +++ b/services/src/main/java/org/keycloak/userprofile/legacy/Validators.java @@ -0,0 +1,278 @@ +/* + * + * * 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.legacy; + +import java.util.List; +import java.util.Map; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.validation.Validation; +import org.keycloak.userprofile.AttributeContext; +import org.keycloak.userprofile.AttributeMetadata; +import org.keycloak.userprofile.AttributeValidatorMetadata; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.validation.Validator; + +/** + * Functions are supposed to return: + * - true if validation success + * - false if validation fails + * + * @author Markus Till + */ +public class Validators { + + public static final AttributeValidatorMetadata create(String message, Validator validator) { + return new AttributeValidatorMetadata(message, validator); + } + + public static final Validator isBlank() { + return (context) -> { + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + + if (values.isEmpty()) { + return true; + } + + String value = values.get(0); + + return value == null || !Validation.isBlank(value); + }; + } + + public static final Validator isEmailValid() { + return (context) -> { + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + + if (values.isEmpty()) { + return true; + } + + String value = values.get(0); + + return Validation.isBlank(value) || Validation.isEmailValid(value); + }; + } + + public static final Validator userNameExists() { + return (context) -> { + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + + if (values.isEmpty()) { + return true; + } + + String value = values.get(0); + + if (Validation.isBlank(value)) return true; + + KeycloakSession session = context.getSession(); + UserModel existing = session.users().getUserByUsername(session.getContext().getRealm(), value); + UserModel user = context.getUser(); + + return !(user != null + && !value.equals(user.getFirstAttribute(UserModel.USERNAME)) + && (existing != null && !existing.getId().equals(user.getId()))); + }; + } + + public static final Validator isUserMutable() { + return (context) -> { + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + + if (values.isEmpty()) { + return true; + } + + String value = values.get(0); + + if (Validation.isBlank(value)) return true; + + UserModel user = context.getUser(); + RealmModel realm = context.getSession().getContext().getRealm(); + + return !(!realm.isEditUsernameAllowed() + && user != null + && !value.equals(user.getFirstAttribute(UserModel.USERNAME)) + ); + }; + } + + public static final Validator checkFederatedUsernameExists() { + return (context) -> { + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + String value = null; + + if (!values.isEmpty()) { + value = values.get(0); + } + + RealmModel realm = context.getSession().getContext().getRealm(); + + return !(!realm.isRegistrationEmailAsUsername() && Validation.isBlank(value)); + }; + } + + public static final Validator checkUsernameExists() { + return (context) -> { + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + String value = null; + + if (!values.isEmpty()) { + value = values.get(0); + } + + return !Validation.isBlank(value); + }; + } + + public static final Validator doesEmailExistAsUsername() { + return (context) -> { + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + + if (values.isEmpty()) { + return true; + } + + String value = values.get(0); + + if (Validation.isBlank(value)) return true; + + KeycloakSession session = context.getSession(); + RealmModel realm = session.getContext().getRealm(); + UserModel user = context.getUser(); + + if (!realm.isDuplicateEmailsAllowed()) { + UserModel userByEmail = session.users().getUserByEmail(realm, value); + return !(realm.isRegistrationEmailAsUsername() && userByEmail != null && user != null && !userByEmail.getId().equals(user.getId())); + } + return true; + }; + } + + public static final Validator isEmailDuplicated() { + return (context) -> { + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + + if (values.isEmpty()) { + return true; + } + + String value = values.get(0); + + if (Validation.isBlank(value)) return true; + + KeycloakSession session = context.getSession(); + RealmModel realm = session.getContext().getRealm(); + + if (!realm.isDuplicateEmailsAllowed()) { + UserModel userByEmail = session.users().getUserByEmail(realm, value); + UserModel user = context.getUser(); + // check for duplicated email + return !(userByEmail != null && (user == null || !userByEmail.getId().equals(user.getId()))); + } + return true; + }; + } + + public static final Validator doesEmailExist(KeycloakSession session) { + return (context) -> { + if (UserProfileContext.REGISTRATION_USER_CREATION.equals(context.getContext())) { + RealmModel realm = context.getSession().getContext().getRealm(); + + if (!realm.isRegistrationEmailAsUsername()) { + return true; + } + } + + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + String value = values.get(0); + + return !(value != null + && !session.getContext().getRealm().isDuplicateEmailsAllowed() + && session.users().getUserByEmail(session.getContext().getRealm(), value) != null); + }; + } + + /** + * Validate String length based on the configuration if string is not blank. + * + * @param config can contain "max" and "min" keys with integer values + * @return true if string is blank or conforms min and max configurations + */ + public static final Validator length(final Map config) { + return (context) -> { + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + + if (values == null || values.isEmpty()) { + return true; + } + + String value = values.get(0); + + if (Validation.isBlank(value)) + return true; + + if (config.containsKey("min") && value.length() < (Integer) config.get("min")) { + return false; + } + if (config.containsKey("max") && value.length() > (Integer) config.get("max")) { + return false; + } + return true; + }; + } + + /** + * Validator for "required" validation based on evaluation of the {@link AttributeMetadata#isRequired(AttributeContext)}. + * + */ + public static final Validator requiredByAttributeMetadata() { + return (context) -> { + if(!context.getMetadata().isRequired(context)) { + return true; + } + + Map.Entry> attribute = context.getAttribute(); + List values = attribute.getValue(); + + if (values == null || values.isEmpty()) { + return false; + } + + String value = values.get(0); + + return !Validation.isBlank(value); + }; + } + +} diff --git a/services/src/main/java/org/keycloak/userprofile/profile/AbstractUserProfile.java b/services/src/main/java/org/keycloak/userprofile/profile/AbstractUserProfile.java deleted file mode 100644 index 91f751afda..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/profile/AbstractUserProfile.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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.userprofile.profile; - -import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.UserProfileAttributes; -import org.keycloak.userprofile.UserProfileProvider; - -import java.util.List; -import java.util.Map; - -public abstract class AbstractUserProfile implements UserProfile { - - private final UserProfileAttributes attributes; - - - public AbstractUserProfile(Map> attributes, UserProfileProvider profileProvider) { - this.attributes = new UserProfileAttributes(attributes, profileProvider); - } - - @Override - public UserProfileAttributes getAttributes() { - return this.attributes; - } -} diff --git a/services/src/main/java/org/keycloak/userprofile/profile/DefaultUserProfileContext.java b/services/src/main/java/org/keycloak/userprofile/profile/DefaultUserProfileContext.java deleted file mode 100644 index 4e29c048bd..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/profile/DefaultUserProfileContext.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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.userprofile.profile; - -import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.UserProfileContext; -import org.keycloak.userprofile.UserProfileProvider; -import org.keycloak.userprofile.validation.UserProfileValidationResult; -import org.keycloak.userprofile.validation.UserUpdateEvent; - -/** - * @author Markus Till - */ -public class DefaultUserProfileContext implements UserProfileContext { - private UserProfile currentUserProfile; - private final UserProfile updatedProfile; - private final UserProfileProvider profileProvider; - private UserUpdateEvent userUpdateEvent; - - DefaultUserProfileContext(UserUpdateEvent userUpdateEvent, UserProfile currentUserProfile, - UserProfile updatedProfile, - UserProfileProvider profileProvider) { - this.userUpdateEvent = userUpdateEvent; - this.currentUserProfile = currentUserProfile; - this.updatedProfile = updatedProfile; - this.profileProvider = profileProvider; - } - - @Override - public UserProfile getCurrentProfile() { - return currentUserProfile; - } - - @Override - public UserUpdateEvent getUpdateEvent(){ - return userUpdateEvent; - } - - @Override - public UserProfileValidationResult validate() { - return profileProvider.validate(this, updatedProfile); - } -} diff --git a/services/src/main/java/org/keycloak/userprofile/profile/UserProfileContextFactory.java b/services/src/main/java/org/keycloak/userprofile/profile/UserProfileContextFactory.java deleted file mode 100644 index 699473e8f9..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/profile/UserProfileContextFactory.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * 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.profile; - -import javax.ws.rs.core.MultivaluedMap; - -import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.UserModel; -import org.keycloak.representations.account.UserRepresentation; -import org.keycloak.services.resources.AttributeFormDataProcessor; -import org.keycloak.userprofile.LegacyUserProfileProviderFactory; -import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.UserProfileProvider; -import org.keycloak.userprofile.profile.representations.AccountUserRepresentationUserProfile; -import org.keycloak.userprofile.profile.representations.IdpUserProfile; -import org.keycloak.userprofile.profile.representations.UserModelUserProfile; -import org.keycloak.userprofile.profile.representations.UserRepresentationUserProfile; -import org.keycloak.userprofile.validation.UserUpdateEvent; - -/** - * @author Pedro Igor - */ -public final class UserProfileContextFactory { - - public static DefaultUserProfileContext forIdpReview(SerializedBrokeredIdentityContext currentUser, - MultivaluedMap formData, KeycloakSession session) { - UserProfileProvider profileProvider = getProfileProvider(session); - return new DefaultUserProfileContext(UserUpdateEvent.IdpReview, new IdpUserProfile(currentUser, profileProvider), - AttributeFormDataProcessor.toUserProfile(formData), profileProvider); - } - - public static DefaultUserProfileContext forUpdateProfile(UserModel currentUser, - MultivaluedMap formData, - KeycloakSession session) { - UserProfileProvider profileProvider = getProfileProvider(session); - return new DefaultUserProfileContext(UserUpdateEvent.UpdateProfile, new UserModelUserProfile(currentUser, profileProvider), - AttributeFormDataProcessor.toUserProfile(formData), profileProvider); - } - - public static DefaultUserProfileContext forAccountService(UserModel currentUser, - UserRepresentation rep, KeycloakSession session) { - UserProfileProvider profileProvider = getProfileProvider(session); - return new DefaultUserProfileContext(UserUpdateEvent.Account, new UserModelUserProfile(currentUser, profileProvider), - new AccountUserRepresentationUserProfile(rep, profileProvider), - profileProvider); - } - - public static DefaultUserProfileContext forOldAccount(UserModel currentUser, - MultivaluedMap formData, KeycloakSession session) { - UserProfileProvider profileProvider = getProfileProvider(session); - return new DefaultUserProfileContext(UserUpdateEvent.Account, new UserModelUserProfile(currentUser, profileProvider), - AttributeFormDataProcessor.toUserProfile(formData), - profileProvider); - } - - public static DefaultUserProfileContext forRegistrationUserCreation( - KeycloakSession session, MultivaluedMap formData) { - UserProfileProvider profileProvider = getProfileProvider(session); - return new DefaultUserProfileContext(UserUpdateEvent.RegistrationUserCreation, null, - AttributeFormDataProcessor.toUserProfile(formData), profileProvider); - } - - public static DefaultUserProfileContext forRegistrationProfile(KeycloakSession session, - MultivaluedMap formData) { - UserProfileProvider profileProvider = getProfileProvider(session); - return new DefaultUserProfileContext(UserUpdateEvent.RegistrationProfile, null, - AttributeFormDataProcessor.toUserProfile(formData), profileProvider); - } - - /** - * @param currentUser if this is null, then we're creating new user. If it is not null, we're updating existing user - * @param rep - * @return user profile context for the validation of user when called from admin REST API - */ - public static DefaultUserProfileContext forUserResource(UserModel currentUser, - org.keycloak.representations.idm.UserRepresentation rep, KeycloakSession session) { - UserProfileProvider profileProvider = getProfileProvider(session); - UserProfile currentUserProfile = currentUser == null ? null : new UserModelUserProfile(currentUser, profileProvider); - return new DefaultUserProfileContext(UserUpdateEvent.UserResource, currentUserProfile, - new UserRepresentationUserProfile(rep, profileProvider), profileProvider); - } - - public static DefaultUserProfileContext forProfile(UserUpdateEvent event) { - return new DefaultUserProfileContext(event, null, null, null); - } - - private static UserProfileProvider getProfileProvider(KeycloakSession session) { - if (session == null) { - return null; - } - return session.getProvider(UserProfileProvider.class, LegacyUserProfileProviderFactory.PROVIDER_ID); - } -} diff --git a/services/src/main/java/org/keycloak/userprofile/profile/representations/AccountUserRepresentationUserProfile.java b/services/src/main/java/org/keycloak/userprofile/profile/representations/AccountUserRepresentationUserProfile.java deleted file mode 100644 index 5df34fd93b..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/profile/representations/AccountUserRepresentationUserProfile.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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.userprofile.profile.representations; - - -import org.keycloak.models.UserModel; -import org.keycloak.representations.account.UserRepresentation; -import org.keycloak.userprofile.UserProfileAttributes; -import org.keycloak.userprofile.UserProfileProvider; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * @author Markus Till - */ -public class AccountUserRepresentationUserProfile extends AttributeUserProfile { - - public AccountUserRepresentationUserProfile(UserRepresentation user, UserProfileProvider profileProvider) { - super(flattenUserRepresentation(user), profileProvider); - } - - private static UserProfileAttributes flattenUserRepresentation(UserRepresentation user) { - Map> attrs = new HashMap<>(); - - if (user.getAttributes() != null) attrs.putAll(user.getAttributes()); - - if (user.getUsername() != null) - attrs.put(UserModel.USERNAME, Collections.singletonList(user.getUsername())); - else - attrs.remove(UserModel.USERNAME); - - if (user.getEmail() != null) - attrs.put(UserModel.EMAIL, Collections.singletonList(user.getEmail())); - else - attrs.remove(UserModel.EMAIL); - - if (user.getLastName() != null) - attrs.put(UserModel.LAST_NAME, Collections.singletonList(user.getLastName())); - - if (user.getFirstName() != null) - attrs.put(UserModel.FIRST_NAME, Collections.singletonList(user.getFirstName())); - - - return new UserProfileAttributes(attrs); - } - -} diff --git a/services/src/main/java/org/keycloak/userprofile/profile/representations/AttributeUserProfile.java b/services/src/main/java/org/keycloak/userprofile/profile/representations/AttributeUserProfile.java deleted file mode 100644 index aebfbd754f..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/profile/representations/AttributeUserProfile.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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.userprofile.profile.representations; - -import org.keycloak.userprofile.UserProfileProvider; -import org.keycloak.userprofile.profile.AbstractUserProfile; - -import javax.ws.rs.NotSupportedException; -import java.util.List; -import java.util.Map; - -/** - * @author Markus Till - */ -public class AttributeUserProfile extends AbstractUserProfile { - - public AttributeUserProfile(Map> attributes, UserProfileProvider profileProvider) { - super(attributes, profileProvider); - } - - public AttributeUserProfile(Map> attributes) { - super(attributes, null); - } - - @Override - public String getId() { - throw new NotSupportedException("No ID support"); - } - -} diff --git a/services/src/main/java/org/keycloak/userprofile/profile/representations/IdpUserProfile.java b/services/src/main/java/org/keycloak/userprofile/profile/representations/IdpUserProfile.java deleted file mode 100644 index 574b8a494c..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/profile/representations/IdpUserProfile.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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.userprofile.profile.representations; - -import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; -import org.keycloak.userprofile.UserProfileProvider; -import org.keycloak.userprofile.profile.AbstractUserProfile; - - -/** - * @author Markus Till - */ -public class IdpUserProfile extends AbstractUserProfile { - - private final SerializedBrokeredIdentityContext user; - - public IdpUserProfile(SerializedBrokeredIdentityContext user, UserProfileProvider profileProvider) { - super(user.getAttributes(), profileProvider); - this.user = user; - } - - @Override - public String getId() { - return user.getId(); - } - -} diff --git a/services/src/main/java/org/keycloak/userprofile/profile/representations/UserModelUserProfile.java b/services/src/main/java/org/keycloak/userprofile/profile/representations/UserModelUserProfile.java deleted file mode 100644 index 67a1863003..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/profile/representations/UserModelUserProfile.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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.userprofile.profile.representations; - -import org.keycloak.models.UserModel; -import org.keycloak.userprofile.UserProfileProvider; -import org.keycloak.userprofile.profile.AbstractUserProfile; - -/** - * @author Markus Till - */ -public class UserModelUserProfile extends AbstractUserProfile { - - - public UserModelUserProfile(UserModel user, UserProfileProvider profileProvider) { - super(user.getAttributes(), profileProvider); - this.user = user; - } - - private final UserModel user; - - @Override - public String getId() { - return user.getId(); - } - -} diff --git a/services/src/main/java/org/keycloak/userprofile/profile/representations/UserRepresentationUserProfile.java b/services/src/main/java/org/keycloak/userprofile/profile/representations/UserRepresentationUserProfile.java deleted file mode 100644 index 9b720dfb72..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/profile/representations/UserRepresentationUserProfile.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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.userprofile.profile.representations; - -import org.keycloak.models.UserModel; -import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.userprofile.UserProfileAttributes; -import org.keycloak.userprofile.UserProfileProvider; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * @author Markus Till - */ -public class UserRepresentationUserProfile extends AttributeUserProfile { - - - public UserRepresentationUserProfile(UserRepresentation user, UserProfileProvider profileProvider) { - super(flattenUserRepresentation(user), profileProvider); - } - - public UserRepresentationUserProfile(UserRepresentation user) { - super(flattenUserRepresentation(user), null); - } - - private static UserProfileAttributes flattenUserRepresentation(UserRepresentation user) { - Map> attrs = new HashMap<>(); - - if (user.getAttributes() != null) attrs.putAll(user.getAttributes()); - - if (user.getUsername() != null) - attrs.put(UserModel.USERNAME, Collections.singletonList(user.getUsername())); - else - attrs.remove(UserModel.USERNAME); - - if (user.getEmail() != null) - attrs.put(UserModel.EMAIL, Collections.singletonList(user.getEmail())); - else - attrs.remove(UserModel.EMAIL); - - if (user.getUsername() != null) - attrs.put(UserModel.USERNAME, Collections.singletonList(user.getUsername())); - - if (user.getLastName() != null) - attrs.put(UserModel.LAST_NAME, Collections.singletonList(user.getLastName())); - - if (user.getFirstName() != null) - attrs.put(UserModel.FIRST_NAME, Collections.singletonList(user.getFirstName())); - - if (user.getEmail() != null) - attrs.put(UserModel.EMAIL, Collections.singletonList(user.getEmail())); - - return new UserProfileAttributes(attrs); - } - -} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/userprofile/utils/UserUpdateHelper.java b/services/src/main/java/org/keycloak/userprofile/utils/UserUpdateHelper.java deleted file mode 100644 index 2213228869..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/utils/UserUpdateHelper.java +++ /dev/null @@ -1,168 +0,0 @@ -/* - * 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.userprofile.utils; - -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.UserModel; -import org.keycloak.models.utils.KeycloakModelUtils; -import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.userprofile.LegacyUserProfileProviderFactory; -import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.UserProfileAttributes; -import org.keycloak.userprofile.UserProfileProvider; -import org.keycloak.userprofile.profile.representations.UserRepresentationUserProfile; -import org.keycloak.userprofile.validation.UserUpdateEvent; - -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * @author Markus Till - */ -public class UserUpdateHelper { - - - public static void updateRegistrationProfile(RealmModel realm, UserModel currentUser, UserProfile updatedUser) { - register(UserUpdateEvent.RegistrationProfile, realm, currentUser, updatedUser); - } - - public static void updateRegistrationUserCreation(RealmModel realm, UserModel currentUser, UserProfile updatedUser) { - register(UserUpdateEvent.RegistrationUserCreation, realm, currentUser, updatedUser); - } - - public static void updateIdpReview(RealmModel realm, UserModel userModelDelegate, UserProfile updatedProfile) { - update(UserUpdateEvent.IdpReview, realm, userModelDelegate, updatedProfile.getAttributes(), false); - } - - public static void updateUserProfile(RealmModel realm, UserModel user, UserProfile updatedProfile) { - update(UserUpdateEvent.UpdateProfile, realm, user, updatedProfile.getAttributes(), false); - } - - public static void updateAccount(RealmModel realm, UserModel user, UserProfile updatedProfile) { - update(UserUpdateEvent.Account, realm, user, updatedProfile); - } - - /** - *

This method should be used when account is updated through the old console where the behavior is different - * than when using the new Account REST API and console in regards to how user attributes are managed. - * - * @deprecated Remove this method as soon as the old console is no longer part of the distribution - * @param realm - * @param user - * @param updatedProfile - */ - @Deprecated - public static void updateAccountOldConsole(RealmModel realm, UserModel user, UserProfile updatedProfile) { - update(UserUpdateEvent.Account, realm, user, updatedProfile.getAttributes(), false); - } - - public static void updateUserResource(KeycloakSession session, UserModel user, UserRepresentation rep, boolean removeExistingAttributes) { - UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class, LegacyUserProfileProviderFactory.PROVIDER_ID); - RealmModel realm = session.getContext().getRealm(); - UserRepresentationUserProfile userProfile = new UserRepresentationUserProfile(rep, profileProvider); - update(UserUpdateEvent.UserResource, realm, user, userProfile.getAttributes(), removeExistingAttributes); - } - - /** - * will update the user model with the profile values, all missing attributes in the new profile will be removed on the user model - * @param userUpdateEvent - * @param realm - * @param currentUser - * @param updatedUser - */ - private static void update(UserUpdateEvent userUpdateEvent, RealmModel realm, UserModel currentUser, UserProfile updatedUser) { - update(userUpdateEvent, realm, currentUser, updatedUser.getAttributes(), true); - } - - /** - * will update the user model with the profile values, attributes which are missing will be ignored - * @param userUpdateEvent - * @param realm - * @param currentUser - * @param updatedUser - */ - private static void register(UserUpdateEvent userUpdateEvent, RealmModel realm, UserModel currentUser, UserProfile updatedUser) { - update(userUpdateEvent, realm, currentUser, updatedUser.getAttributes(), false); - } - - private static void update(UserUpdateEvent userUpdateEvent, RealmModel realm, UserModel currentUser, UserProfileAttributes updatedUser, boolean removeMissingAttributes) { - - if (updatedUser == null || updatedUser.size() == 0) - return; - - filterAttributes(userUpdateEvent, realm, updatedUser); - - updateAttributes(currentUser, updatedUser, removeMissingAttributes); - } - - private static void filterAttributes(UserUpdateEvent userUpdateEvent, RealmModel realm, UserProfileAttributes updatedUser) { - //The Idp review does not respect "isEditUserNameAllowed" therefore we have to miss the check here - if (!userUpdateEvent.equals(UserUpdateEvent.IdpReview)) { - //This step has to be done before email is assigned to the username if isRegistrationEmailAsUsername is set - //Otherwise email change will not reflect in username changes. - if (updatedUser.getFirstAttribute(UserModel.USERNAME) != null && !realm.isEditUsernameAllowed()) { - updatedUser.removeAttribute(UserModel.USERNAME); - } - } - - if (updatedUser.getFirstAttribute(UserModel.EMAIL) != null && updatedUser.getFirstAttribute(UserModel.EMAIL).isEmpty()) { - updatedUser.removeAttribute(UserModel.EMAIL); - updatedUser.setAttribute(UserModel.EMAIL, Collections.singletonList(null)); - } - - if (updatedUser.getFirstAttribute(UserModel.EMAIL) != null && realm.isRegistrationEmailAsUsername()) { - updatedUser.removeAttribute(UserModel.USERNAME); - updatedUser.setAttribute(UserModel.USERNAME, Collections.singletonList(updatedUser.getFirstAttribute(UserModel.EMAIL))); - } - } - - private static void updateAttributes(UserModel currentUser, UserProfileAttributes attributes, boolean removeMissingAttributes) { - for (Map.Entry> attr : attributes.entrySet()) { - List currentValue = currentUser.getAttributeStream(attr.getKey()).collect(Collectors.toList()); - //In case of username we need to provide lower case values - List updatedValue = attr.getKey().equals(UserModel.USERNAME) ? AttributeToLower(attr.getValue()) : attr.getValue(); - if (currentValue.size() != updatedValue.size() || !currentValue.containsAll(updatedValue)) { - currentUser.setAttribute(attr.getKey(), updatedValue); - } - } - if (removeMissingAttributes) { - Set attrsToRemove = new HashSet<>(currentUser.getAttributes().keySet()); - attrsToRemove.removeAll(attributes.keySet()); - - for (String attr : attrsToRemove) { - if (attributes.isReadOnlyAttribute(attr)) { - continue; - } - currentUser.removeAttribute(attr); - } - - } - } - - private static List AttributeToLower(List attr) { - if (attr.size() == 1 && attr.get(0) != null) - return Collections.singletonList(KeycloakModelUtils.toLowerCaseSafe(attr.get(0))); - return attr; - } - -} diff --git a/services/src/main/java/org/keycloak/userprofile/validation/AttributeValidator.java b/services/src/main/java/org/keycloak/userprofile/validation/AttributeValidator.java deleted file mode 100644 index d22e4a3ba0..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/validation/AttributeValidator.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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.userprofile.validation; - -import java.util.List; - -/** - * @author Markus Till - */ -public class AttributeValidator { - String attributeKey; - List validators; - - public AttributeValidator(String attributeKey, List validators) { - this.validators = validators; - this.attributeKey = attributeKey; - } - -} diff --git a/services/src/main/java/org/keycloak/userprofile/validation/AttributeValidatorBuilder.java b/services/src/main/java/org/keycloak/userprofile/validation/AttributeValidatorBuilder.java deleted file mode 100644 index 0f86febdeb..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/validation/AttributeValidatorBuilder.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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.userprofile.validation; - -import org.keycloak.userprofile.UserProfileContext; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.BiFunction; - -/** - * @author Markus Till - */ -public class AttributeValidatorBuilder { - ValidationChainBuilder validationChainBuilder; - String attributeKey; - List validations = new ArrayList<>(); - - public AttributeValidatorBuilder(ValidationChainBuilder validationChainBuilder) { - this.validationChainBuilder = validationChainBuilder; - } - - /** - * This method is for validating first value of the specified attribute. It is sufficient for all the single-valued attributes - * - * @param messageKey Key of the error message to be displayed when validation fails - * @param validationFunction Function, which does the actual validation logic. The "String" argument is the new value of the particular attribute. - * @return this - */ - public AttributeValidatorBuilder addSingleAttributeValueValidationFunction(String messageKey, BiFunction validationFunction) { - BiFunction, UserProfileContext, Boolean> wrappedValidationFunction = (attrValues, context) -> { - String singleValue = attrValues == null ? null : attrValues.get(0); - return validationFunction.apply(singleValue, context); - }; - this.validations.add(new Validator(messageKey, wrappedValidationFunction)); - return this; - } - - public AttributeValidatorBuilder addValidationFunction(String messageKey, BiFunction, UserProfileContext, Boolean> validationFunction) { - this.validations.add(new Validator(messageKey, validationFunction)); - return this; - } - - public AttributeValidatorBuilder forAttribute(String attributeKey) { - this.attributeKey = attributeKey; - return this; - } - - public ValidationChainBuilder build() { - this.validationChainBuilder.addValidatorConfig(new AttributeValidator(attributeKey, this.validations)); - return this.validationChainBuilder; - } - -} diff --git a/services/src/main/java/org/keycloak/userprofile/validation/StaticValidators.java b/services/src/main/java/org/keycloak/userprofile/validation/StaticValidators.java deleted file mode 100644 index cda5386088..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/validation/StaticValidators.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * 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.userprofile.validation; - -import org.jboss.logging.Logger; -import org.keycloak.common.util.ObjectUtil; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.UserModel; -import org.keycloak.services.validation.Validation; -import org.keycloak.userprofile.LegacyUserProfileProvider; -import org.keycloak.userprofile.UserProfileContext; - -import java.util.List; -import java.util.function.BiFunction; - -/** - * Functions are supposed to return: - * - true if validation success - * - false if validation fails - * - * @author Markus Till - */ -public class StaticValidators { - - private static final Logger logger = Logger.getLogger(StaticValidators.class); - - public static BiFunction isBlank() { - return (value, context) -> - value==null || !Validation.isBlank(value); - } - - public static BiFunction isEmailValid() { - return (value, context) -> - Validation.isBlank(value) || Validation.isEmailValid(value); - } - - public static BiFunction userNameExists(KeycloakSession session) { - return (value, context) -> { - if (Validation.isBlank(value)) return true; - return !(context.getCurrentProfile() != null - && !value.equals(context.getCurrentProfile().getAttributes().getFirstAttribute(UserModel.USERNAME)) - && session.users().getUserByUsername(session.getContext().getRealm(), value) != null); - }; - } - - public static BiFunction isUserMutable(RealmModel realm) { - return (value, context) -> { - if (Validation.isBlank(value)) return true; - return !(!realm.isEditUsernameAllowed() - && context.getCurrentProfile() != null - && !value.equals(context.getCurrentProfile().getAttributes().getFirstAttribute(UserModel.USERNAME)) - ); - }; - } - - public static BiFunction checkUsernameExists(boolean externalCondition) { - return (value, context) -> - !(externalCondition && Validation.isBlank(value)); - } - - - public static BiFunction doesEmailExistAsUsername(KeycloakSession session) { - return (value, context) -> { - if (Validation.isBlank(value)) return true; - RealmModel realm = session.getContext().getRealm(); - if (!realm.isDuplicateEmailsAllowed()) { - UserModel userByEmail = session.users().getUserByEmail(realm, value); - return !(realm.isRegistrationEmailAsUsername() && userByEmail != null && context.getCurrentProfile() != null && !userByEmail.getId().equals(context.getCurrentProfile().getId())); - } - return true; - }; - } - - public static BiFunction isEmailDuplicated(KeycloakSession session) { - return (value, context) -> { - if (Validation.isBlank(value)) return true; - RealmModel realm = session.getContext().getRealm(); - if (!realm.isDuplicateEmailsAllowed()) { - UserModel userByEmail = session.users().getUserByEmail(realm, value); - // check for duplicated email - return !(userByEmail != null && (context.getCurrentProfile() == null || !userByEmail.getId().equals(context.getCurrentProfile().getId()))); - } - return true; - }; - } - - public static BiFunction doesEmailExist(KeycloakSession session) { - return (value, context) -> - !(value != null - && !session.getContext().getRealm().isDuplicateEmailsAllowed() - && session.users().getUserByEmail(session.getContext().getRealm(), value) != null); - } - - public static BiFunction, UserProfileContext, Boolean> isReadOnlyAttributeUnchanged(String attributeName) { - return (newAttrValues, context) -> { - if (newAttrValues == null) { - return true; - } - List existingAttrValues = context.getCurrentProfile() == null ? null : context.getCurrentProfile().getAttributes().getAttribute(attributeName); - boolean result = ObjectUtil.isEqualOrBothNull(newAttrValues, existingAttrValues); - - if (!result) { - logger.warnf("Attempt to edit denied attribute '%s' of user '%s'", attributeName, context.getCurrentProfile() == null ? "new user" : context.getCurrentProfile().getAttributes().getFirstAttribute(UserModel.USERNAME)); - } - return result; - }; - } - -} diff --git a/services/src/main/java/org/keycloak/userprofile/validation/ValidationChain.java b/services/src/main/java/org/keycloak/userprofile/validation/ValidationChain.java deleted file mode 100644 index 1f346a81ef..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/validation/ValidationChain.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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.userprofile.validation; - -import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.UserProfileContext; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -/** - * @author Markus Till - */ -public class ValidationChain { - List attributeValidators; - - public ValidationChain(List attributeValidators) { - this.attributeValidators = attributeValidators; - } - - public List validate(UserProfileContext updateContext, UserProfile updatedProfile) { - List overallResults = new ArrayList<>(); - for (AttributeValidator attribute : attributeValidators) { - List validationResults = new ArrayList<>(); - - String attributeKey = attribute.attributeKey; - List attributeValues = updatedProfile.getAttributes().getAttribute(attributeKey); - - List existingAttrValues = updateContext.getCurrentProfile() == null ? null : updateContext.getCurrentProfile().getAttributes().getAttribute(attributeKey); - boolean attributeChanged = !Objects.equals(attributeValues, existingAttrValues); - for (Validator validator : attribute.validators) { - validationResults.add(new ValidationResult(validator.function.apply(attributeValues, updateContext), validator.errorType)); - } - - overallResults.add(new AttributeValidationResult(attributeKey, attributeChanged, validationResults)); - } - - return overallResults; - } - -} diff --git a/services/src/main/java/org/keycloak/userprofile/validation/ValidationChainBuilder.java b/services/src/main/java/org/keycloak/userprofile/validation/ValidationChainBuilder.java deleted file mode 100644 index 3a5e2df7e6..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/validation/ValidationChainBuilder.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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.userprofile.validation; - -import java.util.HashMap; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * @author Markus Till - */ -public class ValidationChainBuilder { - - Map attributeConfigs = new HashMap<>(); - - public static ValidationChainBuilder builder() { - return new ValidationChainBuilder(); - } - - public AttributeValidatorBuilder addAttributeValidator() { - return new AttributeValidatorBuilder(this); - } - - public ValidationChain build() { - return new ValidationChain(this.attributeConfigs.values().stream().collect(Collectors.toList())); - } - - public void addValidatorConfig(AttributeValidator validator) { - if (attributeConfigs.containsKey(validator.attributeKey)) { - attributeConfigs.get(validator.attributeKey).validators.addAll(validator.validators); - } else { - attributeConfigs.put(validator.attributeKey, validator); - } - } -} diff --git a/services/src/main/java/org/keycloak/userprofile/validation/Validator.java b/services/src/main/java/org/keycloak/userprofile/validation/Validator.java deleted file mode 100644 index d5840a6652..0000000000 --- a/services/src/main/java/org/keycloak/userprofile/validation/Validator.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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.userprofile.validation; - -import org.keycloak.userprofile.UserProfileContext; - -import java.util.List; -import java.util.function.BiFunction; - -/** - * @author Markus Till - */ -public class Validator { - String errorType; - BiFunction, UserProfileContext, Boolean> function; - - public Validator(String errorType, BiFunction, UserProfileContext, Boolean> function) { - this.function = function; - this.errorType = errorType; - } - -} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory index 746140b347..c23196f894 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory @@ -15,4 +15,4 @@ # limitations under the License. # -org.keycloak.userprofile.LegacyUserProfileProviderFactory +org.keycloak.userprofile.legacy.DefaultUserProfileProvider diff --git a/services/src/test/java/org/keycloak/userprofile/validation/ValidationChainTest.java b/services/src/test/java/org/keycloak/userprofile/validation/ValidationChainTest.java deleted file mode 100644 index e70848ef06..0000000000 --- a/services/src/test/java/org/keycloak/userprofile/validation/ValidationChainTest.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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.userprofile.validation; - -import static org.keycloak.userprofile.profile.UserProfileContextFactory.forProfile; - -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.keycloak.models.UserModel; -import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.userprofile.profile.DefaultUserProfileContext; -import org.keycloak.userprofile.UserProfile; -import org.keycloak.userprofile.profile.representations.UserRepresentationUserProfile; - -import java.util.Collections; -import java.util.stream.Collectors; - -public class ValidationChainTest { - - ValidationChainBuilder builder; - ValidationChain testchain; - UserProfile user; - DefaultUserProfileContext updateContext; - UserRepresentation rep = new UserRepresentation(); - - @Before - public void setUp() throws Exception { - builder = ValidationChainBuilder.builder() - .addAttributeValidator().forAttribute("FAKE_FIELD") - .addSingleAttributeValueValidationFunction("FAKE_FIELD_ERRORKEY", (value, updateUserProfileContext) -> !value.equals("content")).build() - .addAttributeValidator().forAttribute("firstName") - .addSingleAttributeValueValidationFunction("FIRST_NAME_FIELD_ERRORKEY", (value, updateUserProfileContext) -> true).build(); - - //default user content - rep.singleAttribute(UserModel.FIRST_NAME, "firstName"); - rep.singleAttribute(UserModel.LAST_NAME, "lastName"); - rep.singleAttribute(UserModel.EMAIL, "email"); - rep.singleAttribute("FAKE_FIELD", "content"); - rep.singleAttribute("NULLABLE_FIELD", null); - - updateContext = forProfile(UserUpdateEvent.RegistrationProfile); - - } - - @Test - public void validate() { - testchain = builder.build(); - UserProfileValidationResult results = new UserProfileValidationResult(testchain.validate(updateContext, new UserRepresentationUserProfile(rep)), null); - Assert.assertEquals(true, results.hasFailureOfErrorType("FAKE_FIELD_ERRORKEY")); - Assert.assertEquals(false, results.hasFailureOfErrorType("FIRST_NAME_FIELD_ERRORKEY")); - Assert.assertEquals(true, results.getValidationResults().stream().filter(o -> o.getField().equals("firstName")).collect(Collectors.toList()).get(0).isValid()); - Assert.assertEquals(2, results.getValidationResults().size()); - - } - - @Test - public void mergedConfig() { - testchain = builder.addAttributeValidator().forAttribute("FAKE_FIELD") - .addSingleAttributeValueValidationFunction("FAKE_FIELD_ERRORKEY_1", (value, updateUserProfileContext) -> false).build() - .addAttributeValidator().forAttribute("FAKE_FIELD") - .addSingleAttributeValueValidationFunction("FAKE_FIELD_ERRORKEY_2", (value, updateUserProfileContext) -> false).build().build(); - - UserProfileValidationResult results = new UserProfileValidationResult(testchain.validate(updateContext, new UserRepresentationUserProfile(rep)), null); - Assert.assertEquals(true, results.hasFailureOfErrorType("FAKE_FIELD_ERRORKEY_1")); - Assert.assertEquals(true, results.hasFailureOfErrorType("FAKE_FIELD_ERRORKEY_2")); - Assert.assertEquals(true, results.getValidationResults().stream().filter(o -> o.getField().equals("firstName")).collect(Collectors.toList()).get(0).isValid()); - Assert.assertEquals(true, results.hasAttributeChanged("firstName")); - - } - - @Test - public void emptyChain() { - UserProfileValidationResult results = new UserProfileValidationResult(ValidationChainBuilder.builder().build().validate(updateContext,new UserRepresentationUserProfile(rep) ), null); - Assert.assertEquals(Collections.emptyList(), results.getValidationResults()); - } -} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserMapStorage.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserMapStorage.java index 16f5304728..5778abc245 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserMapStorage.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/UserMapStorage.java @@ -116,12 +116,12 @@ public class UserMapStorage implements UserLookupProvider.Streams, UserStoragePr user = new AbstractUserAdapterFederatedStorage.Streams(session, realm, model) { @Override public String getUsername() { - return username; + return username.toLowerCase(); } @Override public void setUsername(String innerUsername) { - if (! Objects.equals(innerUsername, username)) { + if (! Objects.equals(innerUsername, username.toLowerCase())) { throw new RuntimeException("Unsupported"); } } diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileModel.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileModel.java new file mode 100644 index 0000000000..e3c8ad8f20 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileModel.java @@ -0,0 +1,34 @@ +/* + * + * * 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.testsuite.user.profile.config; + +import org.keycloak.component.ComponentModel; +import org.keycloak.userprofile.UserProfileProvider; + +/** + * @author Pedro Igor + */ +public class DeclarativeUserProfileModel extends ComponentModel { + + public DeclarativeUserProfileModel() { + setProviderId(DeclarativeUserProfileProvider.ID); + setProviderType(UserProfileProvider.class.getName()); + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileProvider.java new file mode 100644 index 0000000000..2a2d23af6b --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/DeclarativeUserProfileProvider.java @@ -0,0 +1,409 @@ +/* + * + * * 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.testsuite.user.profile.config; + +import static org.keycloak.common.util.ObjectUtil.isBlank; +import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.readConfig; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.StreamUtil; +import org.keycloak.component.AmphibianProviderFactory; +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.userprofile.AttributeContext; +import org.keycloak.userprofile.AttributeMetadata; +import org.keycloak.userprofile.AttributeValidatorMetadata; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileMetadata; +import org.keycloak.userprofile.UserProfileProvider; +import org.keycloak.userprofile.legacy.AbstractUserProfileProvider; +import org.keycloak.userprofile.legacy.Validators; + +/** + * {@link UserProfileProvider} loading configuration from the changeable JSON + * file stored in component config. Parsed configuration is cached. + * + * @author Pedro Igor + * @author Vlastimil Elias + */ +public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider + implements AmphibianProviderFactory { + + public static final String ID = "declarative-userprofile-provider"; + public static final String UP_PIECES_COUNT_COMPONENT_CONFIG_KEY = "config-pieces-count"; + 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 final String SYSTEM_DEFAULT_CONFIG_RESOURCE = "keycloak-default-user-profile.json"; + + private String defaultRawConfig; + + public DeclarativeUserProfileProvider() { + // for reflection + } + + public DeclarativeUserProfileProvider(KeycloakSession session, + Map metadataRegistry) { + super(session, metadataRegistry); + } + + @Override + public String getId() { + return ID; + } + + @Override + protected DeclarativeUserProfileProvider create(KeycloakSession session, + Map metadataRegistry) { + return new DeclarativeUserProfileProvider(session, metadataRegistry); + } + + @Override + protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata, KeycloakSession session) { + ComponentModel model = getComponentModelOrCreate(session); + Map metadataMap = model.getNote(PARSED_CONFIG_COMPONENT_KEY); + + // not cached, create a note with cache + if (metadataMap == null) { + metadataMap = new HashMap<>(); + model.setNote(PARSED_CONFIG_COMPONENT_KEY, metadataMap); + } + + return metadataMap.computeIfAbsent(metadata.getContext(), + (context) -> decorateUserProfileForCache(metadata, model)); + } + + @Override + public String getHelpText() { + return null; + } + + @Override + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) + throws ComponentValidationException { + String upConfigJson = getConfigJsonFromComponentModel(model); + + if (!isBlank(upConfigJson)) { + try { + UPConfig upc = readConfig(new ByteArrayInputStream(upConfigJson.getBytes("UTF-8"))); + List errors = UPConfigUtils.validate(upc); + + if (!errors.isEmpty()) { + throw new ComponentValidationException( + "UserProfile configuration is invalid: " + errors.toString()); + } + } catch (IOException e) { + throw new ComponentValidationException( + "UserProfile configuration is invalid due to JSON parsing error: " + e.getMessage(), e); + } + } + + // delete cache so new config is parsed and applied next time it is required + // throught #configureUserProfile(metadata, session) + if (model != null) { + model.removeNote(PARSED_CONFIG_COMPONENT_KEY); + } + } + + @Override + public String getConfiguration() { + String cfg = getConfigJsonFromComponentModel(getComponentModel()); + + if (isBlank(cfg)) { + return defaultRawConfig; + } + + return cfg; + } + + @Override + public void setConfiguration(String configuration) { + ComponentModel component = getComponentModel(); + + removeConfigJsonFromComponentModel(component); + + if (!isBlank(configuration)) { + // store new parts + List parts = UPConfigUtils.getChunks(configuration, 3800); + MultivaluedHashMap config = component.getConfig(); + + config.putSingle(UP_PIECES_COUNT_COMPONENT_CONFIG_KEY, "" + parts.size()); + + int i = 0; + + for (String part : parts) { + config.putSingle(UP_PIECE_COMPONENT_CONFIG_KEY_BASE + (i++), part); + } + } + + session.getContext().getRealm().updateComponent(component); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + //TODO: We should avoid blocking operations during startup. Need to review this. + try (InputStream is = getClass().getResourceAsStream(SYSTEM_DEFAULT_CONFIG_RESOURCE)) { + defaultRawConfig = StreamUtil.readString(is, Charset.defaultCharset()); + } catch (IOException cause) { + throw new RuntimeException("Failed to load default user profile config file", cause); + } + } + + @Override + public List getConfigProperties() { + return Collections.emptyList(); + } + + public ComponentModel getComponentModel() { + return getComponentModelOrCreate(session); + } + + /** + * Decorate basic metadata provided from {@link AbstractUserProfileProvider} + * based on 'per realm' configuration. This method is called for each + * {@link UserProfileContext} in each realm, and metadata are cached then and + * this method is called again only if configuration changes. + * + * @param metadata base to be decorated based on configuration loaded from + * component model + * @param model component model to get "per realm" configuration from + * @return decorated metadata + */ + private UserProfileMetadata decorateUserProfileForCache(UserProfileMetadata metadata, ComponentModel model) { + UserProfileContext context = metadata.getContext(); + UPConfig parsedConfig = getParsedConfig(model); + + if (parsedConfig == null) { + return metadata; + } + + // need to clone otherwise changes to profile config are going to be reflected + // in the default config + UserProfileMetadata decoratedMetadata = metadata.clone(); + + for (UPAttribute attrConfig : parsedConfig.getAttributes()) { + String attributeName = attrConfig.getName(); + List validators = new ArrayList<>(); + Map> validationsConfig = attrConfig.getValidations(); + + if (validationsConfig != null) { + for (Map.Entry> vc : validationsConfig.entrySet()) { + validators.add(createConfiguredValidator(attrConfig, vc.getKey(), vc.getValue())); + } + } + + UPAttributeRequired rc = attrConfig.getRequired(); + Predicate required = AttributeMetadata.ALWAYS_FALSE; + + if (rc != null && !(UserModel.USERNAME.equals(attributeName) || UserModel.EMAIL.equals(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())) { + validators.add(createRequiredValidator(attrConfig)); + required = AttributeMetadata.ALWAYS_TRUE; + } 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) -> attributePredicateAuthFlowRequestedScope(rc.getScopes()); + validators.add(createRequiredValidator(attrConfig)); + } + } + + Predicate readOnly = AttributeMetadata.ALWAYS_FALSE; + UPAttributePermissions permissions = attrConfig.getPermissions(); + + if (permissions != null) { + List editRoles = permissions.getEdit(); + + if (editRoles != null && !editRoles.isEmpty()) { + readOnly = ac -> !UPConfigUtils.isRoleForContext(ac.getContext(), editRoles); + } + } + + Map annotations = attrConfig.getAnnotations(); + + if (UserModel.USERNAME.equals(attributeName) || UserModel.EMAIL.equals(attributeName)) { + // add format validators for special attributes which may exist from parent + if (!validators.isEmpty()) { + List atts = decoratedMetadata.getAttribute(attributeName); + if (atts.isEmpty()) { + // attribute metadata doesn't exist so we have to add it. We keep it optional as Abstract base doesn't require it. + decoratedMetadata.addAttribute(attributeName, validators, readOnly).addAnnotations(annotations); + } else { + // only add configured validators and annotations if attribute metadata exist + atts.stream().forEach(c -> c.addValidator(validators).addAnnotations(annotations)); + } + } + } else { + decoratedMetadata.addAttribute(attributeName, validators, readOnly, required).addAnnotations(annotations); + } + } + + return decoratedMetadata; + + } + + /** + * Get parsed config file configured in model. Default one used if not + * configured. + * + * @param model to take config from + * @return parsed configuration + */ + private UPConfig getParsedConfig(ComponentModel model) { + String rawConfig = getConfigJsonFromComponentModel(model); + + if (!isBlank(rawConfig)) { + try { + return readConfig(new ByteArrayInputStream(rawConfig.getBytes("UTF-8"))); + } catch (IOException e) { + throw new RuntimeException("UserProfile config for realm " + session.getContext().getRealm().getName() + + " is invalid:" + e.getMessage(), e); + } + } + + return null; + } + + /** + * Predicate to select attributes for Authentication flow cases where requested + * scopes (including configured Default client scopes) are compared to set of + * scopes from user profile configuration. + *

+ * This patches problem with some auth flows (eg. register) where + * authSession.getClientScopes() doesn't work correctly! + * + * @param scopesConfigured to match + * @return true if at least one requested scope matches at least one configured + * scope + */ + private boolean attributePredicateAuthFlowRequestedScope(List scopesConfigured) { + // never match out of auth flow + if (session.getContext().getAuthenticationSession() == null) { + return false; + } + + return getAuthFlowRequestedScopeNames().stream().anyMatch(scopesConfigured::contains); + } + + private Set getAuthFlowRequestedScopeNames() { + String requestedScopesString = session.getContext().getAuthenticationSession() + .getClientNote(OIDCLoginProtocol.SCOPE_PARAM); + return TokenManager + .getRequestedClientScopes(requestedScopesString, session.getContext().getAuthenticationSession().getClient()) + .map((csm) -> csm.getName()) + .collect(Collectors.toSet()); + } + + /** + * Get componenet to store our "per realm" configuration into. + * + * @param session to be used, and take realm from + * @return componenet + */ + private ComponentModel getComponentModelOrCreate(KeycloakSession session) { + RealmModel realm = session.getContext().getRealm(); + return realm.getComponentsStream(realm.getId(), UserProfileProvider.class.getName()).findAny() + .orElseGet(() -> realm.addComponentModel(new DeclarativeUserProfileModel())); + } + + /** + * Create validator for 'required' validation. + * + * @return validator + */ + private AttributeValidatorMetadata createRequiredValidator(UPAttribute attrConfig) { + String msg = "missing" + UPConfigUtils.capitalizeFirstLetter(attrConfig.getName()) + "Message"; + return Validators.create(msg, Validators.requiredByAttributeMetadata()); + } + + /** + * Create validator for validation configured in the user profile config. + * + * @param attrConfig to create validator for + * @return validator + */ + private AttributeValidatorMetadata createConfiguredValidator(UPAttribute attrConfig, + String validator, Map validatorConfig) { + // TODO UserProfile - integrate Validation SPI + if ("length".equals(validator)) + return Validators.create("badLenght" + UPConfigUtils.capitalizeFirstLetter(attrConfig.getName()) + "Message", + Validators.length(validatorConfig)); + else if ("emailFormat".equals(validator)) + return Validators.create("invalidEmailMessage", Validators.isEmailValid()); + else + throw new RuntimeException("Unsupported UserProfile validator " + validator); + } + + private String getConfigJsonFromComponentModel(ComponentModel model) { + if (model == null) + return null; + + int count = model.get(UP_PIECES_COUNT_COMPONENT_CONFIG_KEY, 0); + if (count < 1) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < count; i++) { + String v = model.get(UP_PIECE_COMPONENT_CONFIG_KEY_BASE + i); + if (v != null) + sb.append(v); + } + + return sb.toString(); + } + + private void removeConfigJsonFromComponentModel(ComponentModel model) { + if (model == null) + return; + + int count = model.get(UP_PIECES_COUNT_COMPONENT_CONFIG_KEY, 0); + if (count < 1) { + return; + } + + for (int i = 0; i < count; i++) { + model.getConfig().remove(UP_PIECE_COMPONENT_CONFIG_KEY_BASE + i); + } + model.getConfig().remove(UP_PIECES_COUNT_COMPONENT_CONFIG_KEY); + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttribute.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttribute.java new file mode 100644 index 0000000000..eea0742932 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttribute.java @@ -0,0 +1,91 @@ +/* + * 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.testsuite.user.profile.config; + +import java.util.HashMap; +import java.util.Map; + +/** + * Configuration of the Attribute. + * + * @author Vlastimil Elias + * + */ +public class UPAttribute { + + private String name; + /** key in the Map is name of the validator, value is its configuration */ + private Map> validations; + private Map annotations; + /** null means it is not required */ + private UPAttributeRequired required; + /** null means everyone can view and edit the attribute */ + private UPAttributePermissions permissions; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name != null ? name.trim() : null; + } + + public Map> getValidations() { + return validations; + } + + public void setValidations(Map> validations) { + this.validations = validations; + } + + public Map getAnnotations() { + return annotations; + } + + public void setAnnotations(Map annotations) { + this.annotations = annotations; + } + + public UPAttributeRequired getRequired() { + return required; + } + + public void setRequired(UPAttributeRequired required) { + this.required = required; + } + + public UPAttributePermissions getPermissions() { + return permissions; + } + + public void setPermissions(UPAttributePermissions permissions) { + this.permissions = permissions; + } + + public void addValidation(String validator, Map config) { + if (validations == null) { + validations = new HashMap<>(); + } + validations.put(validator, config); + } + + @Override + public String toString() { + return "UPAttribute [name=" + name + ", permissions=" + permissions + ", required=" + required + ", validations=" + validations + ", annotations=" + + annotations + "]"; + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributePermissions.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributePermissions.java new file mode 100644 index 0000000000..7f53481330 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributePermissions.java @@ -0,0 +1,53 @@ +/* + * 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.testsuite.user.profile.config; + +import java.util.List; + +/** + * Configuration of permissions for the attribute + * + * @author Vlastimil Elias + * + */ +public class UPAttributePermissions { + + private List view; + private List edit; + + public List getView() { + return view; + } + + public void setView(List view) { + this.view = view; + } + + public List getEdit() { + return edit; + } + + public void setEdit(List edit) { + this.edit = edit; + } + + @Override + public String toString() { + return "UPAttributePermissions [view=" + view + ", edit=" + edit + "]"; + } + +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributeRequired.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributeRequired.java new file mode 100644 index 0000000000..dc60d57a0f --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPAttributeRequired.java @@ -0,0 +1,66 @@ +/* + * 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.testsuite.user.profile.config; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Config of the rules when attribute is required. + * + * @author Vlastimil Elias + * + */ +public class UPAttributeRequired { + + private List roles; + private List scopes; + + /** + * Check if this config means that the attribute is ALWAYS required. + * + * @return true if the attribute is always required + */ + @JsonIgnore + public boolean isAlways() { + return (roles == null || roles.isEmpty()) && (scopes == null || scopes.isEmpty()); + } + + public List getRoles() { + return roles; + } + + public void setRoles(List roles) { + this.roles = roles; + } + + public List getScopes() { + return scopes; + } + + public void setScopes(List scopes) { + this.scopes = scopes; + } + + + @Override + public String toString() { + return "UPAttributeRequired [isAlways=" + isAlways() + ", roles=" + roles + ", scopes=" + scopes + "]"; + } + +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfig.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfig.java new file mode 100644 index 0000000000..7b12b50046 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfig.java @@ -0,0 +1,54 @@ +/* + * 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.testsuite.user.profile.config; + +import java.util.ArrayList; +import java.util.List; + +/** + * Configuration of the User Profile for one realm. + * + * @author Vlastimil Elias + * + */ +public class UPConfig { + + private List attributes; + + public List getAttributes() { + return attributes; + } + + public void setAttributes(List attributes) { + this.attributes = attributes; + } + + public UPConfig addAttribute(UPAttribute attribute) { + if (attributes == null) { + attributes = new ArrayList<>(); + } + + attributes.add(attribute); + + return this; + } + + @Override + public String toString() { + return "UPConfig [attributes=" + attributes + "]"; + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfigUtils.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfigUtils.java new file mode 100644 index 0000000000..5e21972972 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/user/profile/config/UPConfigUtils.java @@ -0,0 +1,223 @@ +/* + * 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.testsuite.user.profile.config; + +import static org.keycloak.common.util.ObjectUtil.isBlank; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.util.JsonSerialization; + +/** + * Utility methods to work with User Profile Configurations + * + * @author Vlastimil Elias + * + */ +public class UPConfigUtils { + + public static final String ROLE_USER = "user"; + public static final String ROLE_ADMIN = "admin"; + + private static final Set PSEUDOROLES = new HashSet<>(); + + static { + PSEUDOROLES.add(ROLE_ADMIN); + PSEUDOROLES.add(ROLE_USER); + } + + + /** + * Load configuration from JSON file. + *

+ * Configuration is not validated, use {@link #validate(UPConfig)} to validate it and get list of errors. + * + * @param is JSON file to be loaded + * @return object representation of the configuration + * @throws IOException if JSON configuration can't be loaded (eg due to JSON format errors etc) + */ + public static UPConfig readConfig(InputStream is) throws IOException { + return JsonSerialization.readValue(is, UPConfig.class); + } + + /** + * Validate object representation of the configuration. Validations: + *

    + *
  • defaultProfile is defined and exists in profiles + *
  • parent exists for type + *
  • type exists for attribute + *
  • validator (from Validator SPI) exists for validation and it's config is correct + *
+ * + * @param config to validate + * @return list of errors, empty if no error found + */ + public static List validate(UPConfig config) { + List errors = new ArrayList<>(); + + if (config.getAttributes() != null) { + Set attNamesCache = new HashSet<>(); + config.getAttributes().forEach((attribute) -> validate(attribute, errors, attNamesCache)); + } else { + errors.add("UserProfile configuration without 'attributes' section is not allowed"); + } + + return errors; + } + + /** + * Validate attribute configuration + * + * @param attributeConfig config to be validated + * @param errors to add error message in if something is invalid + */ + private static void validate(UPAttribute attributeConfig, List errors, Set attNamesCache) { + String attributeName = attributeConfig.getName(); + if (isBlank(attributeName)) { + errors.add("Attribute configuration without 'name' is not allowed"); + } else { + if (attNamesCache.contains(attributeName)) { + errors.add("Duplicit attribute configuration with 'name':'" + attributeName + "'"); + } else { + attNamesCache.add(attributeName); + if(!isValidAttributeName(attributeName)) { + errors.add("Invalid attribute name (only letters, numbers and '.' '_' '-' special characters allowed): " + attributeName + "'"); + } + } + } + if (attributeConfig.getValidations() != null) { + attributeConfig.getValidations().forEach((validator, validatorConfig) -> validateValidationConfig(validator, validatorConfig, attributeName, errors)); + } + if (attributeConfig.getPermissions() != null) { + if (attributeConfig.getPermissions().getView() != null) { + validateRoles(attributeConfig.getPermissions().getView(), "permissions.view", errors, attributeName); + } + if (attributeConfig.getPermissions().getEdit() != null) { + validateRoles(attributeConfig.getPermissions().getEdit(), "permissions.edit", errors, attributeName); + } + } + if (attributeConfig.getRequired() != null) { + validateRoles(attributeConfig.getRequired().getRoles(), "required.roles", errors, attributeName); + } + } + + /** + * @param attributeName to validate + * @return + */ + static boolean isValidAttributeName(String attributeName) { + return Pattern.matches("[a-zA-Z0-9\\._\\-]+", attributeName); + } + + /** + * Validate list of configured roles - must contain only supported {@link #PSEUDOROLES} for now. + * + * @param roles to validate + * @param fieldName we are validating for use in error messages + * @param errors to ass error message into + * @param attributeName we are validating for use in erorr messages + */ + private static void validateRoles(List roles, String fieldName, List errors, String attributeName) { + if (roles != null) { + for (String role : roles) { + if (!PSEUDOROLES.contains(role)) { + errors.add("'" + fieldName + "' configuration for attribute '" + attributeName + "' contains unsupported role '" + role + "'"); + } + } + } + } + + /** + * Validate that validation configuration is correct + * + * @param validatorConfig config to be checked + * @param errors to add error message in if something is invalid + */ + private static void validateValidationConfig(String validator, Map validatorConfig, String attributeName, List errors) { + + if (isBlank(validator)) { + errors.add("Validation without 'validator' is defined for attribute '" + attributeName + "'"); + } else { + // TODO UserProfile - Validation SPI integration - check that the validator exists using Validation SPI + // TODO UserProfile - Validation SPI integration - check that the validation configuration is correct for given validator using Validation SPI + } + } + + /** + * Break string to substrings of given length + * + * @param src to break + * @param partLength + * @return list of string parts, never null (but can be empty if src is null) + */ + public static List getChunks(String src, int partLength) { + List ret = new ArrayList<>(); + if (src != null) { + int pieces = (src.length() / partLength) + 1; + for (int i = 0; i < pieces; i++) { + if ((i + 1) < pieces) + ret.add(src.substring(i * partLength, (i + 1) * partLength)); + else if (i == 0 || (i * partLength) < src.length()) + ret.add(src.substring(i * partLength)); + } + } + + return ret; + } + + /** + * Check if context CAN BE part of the AuthenticationFlow. + * + * @param context to check + * @return true if context CAN BE part of the auth flow + */ + public static boolean canBeAuthFlowContext(UserProfileContext context) { + return context != UserProfileContext.USER_API && context != UserProfileContext.ACCOUNT + && context != UserProfileContext.ACCOUNT_OLD; + } + + /** + * Check if roles configuration contains role given current context. + * + * @param context to be checked + * @param roles to be inspected + * @return true if roles list contains role representing checked context + */ + public static boolean isRoleForContext(UserProfileContext context, List roles) { + if (roles == null) + return false; + if (context == UserProfileContext.USER_API) + return roles.contains(ROLE_ADMIN); + else + return roles.contains(ROLE_USER); + } + + public static String capitalizeFirstLetter(String str) { + if (str == null || str.isEmpty()) + return str; + return str.substring(0, 1).toUpperCase() + str.substring(1); + } + +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory new file mode 100644 index 0000000000..7c61cabce3 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.userprofile.UserProfileProviderFactory @@ -0,0 +1,20 @@ +# +# /* +# * 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. +# */ +# + +org.keycloak.testsuite.user.profile.config.DeclarativeUserProfileProvider \ No newline at end of file diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml index 0d54eb7d67..e971230b67 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml @@ -21,6 +21,10 @@ + + + + diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/user/profile/config/keycloak-default-user-profile.json b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/user/profile/config/keycloak-default-user-profile.json new file mode 100644 index 0000000000..e614ff4063 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/user/profile/config/keycloak-default-user-profile.json @@ -0,0 +1,18 @@ +{ + "attributes": [ + { + "name": "username" + }, + { + "name": "email" + }, + { + "name": "firstName", + "required": {} + }, + { + "name": "lastName", + "required": {} + } + ] +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java index ef3aad8d4c..e0022246d1 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java @@ -120,8 +120,8 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { user = updateAndGet(user); assertEquals(user.getLastName(), "Bob"); - assertEquals(user.getFirstName(), originalFirstName); - assertEquals(user.getEmail(), originalEmail); + assertNull(user.getFirstName()); + assertNull(user.getEmail()); } finally { RealmRepresentation realmRep = adminClient.realm("test").toRepresentation(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterTest.java index 3c43ef7ce0..1cd83ea106 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RegisterTest.java @@ -93,7 +93,7 @@ public class RegisterTest extends AbstractTestRealmKeycloakTest { assertEquals("", registerPage.getPassword()); assertEquals("", registerPage.getPasswordConfirm()); - events.expectRegister("roleRichUser", "registerExistingUser@email") + events.expectRegister("rolerichuser", "registerExistingUser@email") .removeDetail(Details.EMAIL) .user((String) null).error("username_in_use").assertEvent(); } @@ -117,7 +117,7 @@ public class RegisterTest extends AbstractTestRealmKeycloakTest { assertEquals("", registerPage.getPassword()); assertEquals("", registerPage.getPasswordConfirm()); - events.expectRegister("registerExistingUser", "registerExistingUser@email") + events.expectRegister("registerexistinguser", "registerExistingUser@email") .removeDetail(Details.EMAIL) .user((String) null).error("email_in_use").assertEvent(); } @@ -281,7 +281,7 @@ public class RegisterTest extends AbstractTestRealmKeycloakTest { registerPage.register("firstName", "lastName", null, "registerUserMissingEmail", "password", "password"); registerPage.assertCurrent(); assertEquals("Please specify email.", registerPage.getInputAccountErrors().getEmailError()); - events.expectRegister("registerUserMissingEmail", null) + events.expectRegister("registerusermissingemail", null) .removeDetail("email") .error("invalid_registration").assertEvent(); } @@ -296,7 +296,7 @@ public class RegisterTest extends AbstractTestRealmKeycloakTest { registerPage.assertCurrent(); assertEquals("registerUserInvalidEmailemail", registerPage.getEmail()); assertEquals("Invalid email address.", registerPage.getInputAccountErrors().getEmailError()); - events.expectRegister("registerUserInvalidEmail", "registerUserInvalidEmailemail") + events.expectRegister("registeruserinvalidemail", "registerUserInvalidEmailemail") .error("invalid_registration").assertEvent(); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/AbstractUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/AbstractUserProfileTest.java new file mode 100644 index 0000000000..7c7049db70 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/AbstractUserProfileTest.java @@ -0,0 +1,241 @@ +/* + * + * * 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.testsuite.user.profile; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.user.profile.config.DeclarativeUserProfileProvider; +import org.keycloak.userprofile.UserProfileProvider; + +/** + * @author Pedro Igor + */ +public abstract class AbstractUserProfileTest extends AbstractTestRealmKeycloakTest { + + protected static void configureAuthenticationSession(KeycloakSession session) { + configureSessionRealm(session); + Set scopes = new HashSet<>(); + + scopes.add("customer"); + + configureAuthenticationSession(session, "client-a", scopes); + } + + protected static void configureAuthenticationSession(KeycloakSession session, String clientId, Set requestedScopes) { + RealmModel realm = session.getContext().getRealm(); + + session.getContext().setAuthenticationSession(createAuthenticationSession(realm.getClientByClientId(clientId), requestedScopes)); + } + + protected static RealmModel configureSessionRealm(KeycloakSession session) { + RealmModel realm = session.realms().getRealm(TEST_REALM_NAME); + + session.getContext().setRealm(realm); + + return realm; + } + + protected static DeclarativeUserProfileProvider getDynamicUserProfileProvider(KeycloakSession session) { + return (DeclarativeUserProfileProvider) session.getProvider(UserProfileProvider.class, DeclarativeUserProfileProvider.ID); + } + + protected static AuthenticationSessionModel createAuthenticationSession(ClientModel client, Set scopes) { + return new AuthenticationSessionModel() { + @Override + public String getTabId() { + return null; + } + + @Override + public RootAuthenticationSessionModel getParentSession() { + return null; + } + + @Override + public Map getExecutionStatus() { + return null; + } + + @Override + public void setExecutionStatus(String authenticator, ExecutionStatus status) { + + } + + @Override + public void clearExecutionStatus() { + + } + + @Override + public UserModel getAuthenticatedUser() { + return null; + } + + @Override + public void setAuthenticatedUser(UserModel user) { + + } + + @Override + public Set getRequiredActions() { + return null; + } + + @Override + public void addRequiredAction(String action) { + + } + + @Override + public void removeRequiredAction(String action) { + + } + + @Override + public void addRequiredAction(UserModel.RequiredAction action) { + + } + + @Override + public void removeRequiredAction(UserModel.RequiredAction action) { + + } + + @Override + public void setUserSessionNote(String name, String value) { + + } + + @Override + public Map getUserSessionNotes() { + return null; + } + + @Override + public void clearUserSessionNotes() { + + } + + @Override + public String getAuthNote(String name) { + return null; + } + + @Override + public void setAuthNote(String name, String value) { + + } + + @Override + public void removeAuthNote(String name) { + + } + + @Override + public void clearAuthNotes() { + + } + + @Override + public String getClientNote(String name) { + return null; + } + + @Override + public void setClientNote(String name, String value) { + + } + + @Override + public void removeClientNote(String name) { + + } + + @Override + public Map getClientNotes() { + return null; + } + + @Override + public void clearClientNotes() { + + } + + @Override + public Set getClientScopes() { + return scopes; + } + + @Override + public void setClientScopes(Set clientScopes) { + + } + + @Override + public String getRedirectUri() { + return null; + } + + @Override + public void setRedirectUri(String uri) { + + } + + @Override + public RealmModel getRealm() { + return null; + } + + @Override + public ClientModel getClient() { + return client; + } + + @Override + public String getAction() { + return null; + } + + @Override + public void setAction(String action) { + + } + + @Override + public String getProtocol() { + return null; + } + + @Override + public void setProtocol(String method) { + + } + }; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileConfigTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileConfigTest.java new file mode 100644 index 0000000000..d16d7f08b5 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileConfigTest.java @@ -0,0 +1,579 @@ +/* + * + * * 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.testsuite.user.profile; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.ROLE_USER; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.runonserver.RunOnServer; +import org.keycloak.testsuite.user.profile.config.DeclarativeUserProfileProvider; +import org.keycloak.testsuite.user.profile.config.UPAttribute; +import org.keycloak.testsuite.user.profile.config.UPAttributeRequired; +import org.keycloak.testsuite.user.profile.config.UPConfig; +import org.keycloak.testsuite.user.profile.config.UPConfigUtils; +import org.keycloak.testsuite.util.KeycloakModelUtils; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.ValidationException; +import org.keycloak.util.JsonSerialization; + +/** + * @author Pedro Igor + */ +public class UserProfileConfigTest extends AbstractUserProfileTest { + + protected static final String ATT_ADDRESS = "address"; + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + KeycloakModelUtils.createClient(testRealm, "client-a"); + KeycloakModelUtils.createClient(testRealm, "client-b"); + } + + @Test + public void testConfigurationSetInvalid() { + getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testConfigurationSetInvalid); + } + + private static void testConfigurationSetInvalid(KeycloakSession session) { + configureSessionRealm(session); + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + + try { + provider.setConfiguration("{\"validateConfigAttribute\": true}"); + fail("Should fail validation"); + } catch (ComponentValidationException ve) { + // OK + } + + } + + @Test + public void testConfigurationGetSet() { + getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testConfigurationGetSet); + } + + private static void testConfigurationGetSet(KeycloakSession session) throws IOException { + configureSessionRealm(session); + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + // generate big configuration to test slicing in the persistence/component config + UPConfig config = new UPConfig(); + for (int i = 0; i < 80; i++) { + UPAttribute attribute = new UPAttribute(); + attribute.setName(UserModel.USERNAME+i); + Map validatorConfig = new HashMap<>(); + validatorConfig.put("min", 3); + attribute.addValidation("length", validatorConfig); + config.addAttribute(attribute); + } + String newConfig = JsonSerialization.writeValueAsString(config); + + provider.setConfiguration(newConfig); + // assert config is persisted in 2 pieces + Assert.assertEquals("2", component.get(DeclarativeUserProfileProvider.UP_PIECES_COUNT_COMPONENT_CONFIG_KEY)); + // assert config is returned correctly + Assert.assertEquals(newConfig, provider.getConfiguration()); + } + + @Test + public void testConfigurationGetSetDefault() { + getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testConfigurationGetSetDefault); + } + + private static void testConfigurationGetSetDefault(KeycloakSession session) throws IOException { + configureSessionRealm(session); + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + + provider.setConfiguration(null); + + Assert.assertNull(provider.getComponentModel().get(DeclarativeUserProfileProvider.UP_PIECES_COUNT_COMPONENT_CONFIG_KEY)); + + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + Assert.assertTrue(component.getConfig().isEmpty()); + } + + @Test + public void testDefaultConfigForUpdateProfile() { + getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testDefaultConfigForUpdateProfile); + } + + private static void testDefaultConfigForUpdateProfile(KeycloakSession session) throws IOException { + configureSessionRealm(session); + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + + // reset configuration to default + provider.setConfiguration(null); + + // failed required validations + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, Collections.emptyMap()); + + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(UserModel.USERNAME)); + } + + // failed for blank values also + Map attributes = new HashMap<>(); + + attributes.put(UserModel.FIRST_NAME, ""); + attributes.put(UserModel.LAST_NAME, " "); + attributes.put(UserModel.EMAIL, ""); + + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(UserModel.USERNAME)); + assertTrue(ve.isAttributeOnError(UserModel.FIRST_NAME)); + assertTrue(ve.isAttributeOnError(UserModel.LAST_NAME)); + assertTrue(ve.isAttributeOnError(UserModel.EMAIL)); + } + + // all OK + attributes.put(UserModel.USERNAME, "jdoeusername"); + attributes.put(UserModel.FIRST_NAME, "John"); + attributes.put(UserModel.LAST_NAME, "Doe"); + attributes.put(UserModel.EMAIL, "jdoe@acme.org"); + + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + } + + @Test + public void testAdditionalValidationForUsername() { + getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testAdditionalValidationForUsername); + } + + private static void testAdditionalValidationForUsername(KeycloakSession session) throws IOException { + configureSessionRealm(session); + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(UserModel.USERNAME); + + Map validatorConfig = new HashMap<>(); + + validatorConfig.put("min", 4); + + attribute.addValidation("length", validatorConfig); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "us"); + + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(UserModel.USERNAME)); + assertTrue(ve.hasError("badLenghtUsernameMessage")); + } + + attributes.put(UserModel.USERNAME, "user"); + + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + profile.validate(); + + provider.setConfiguration(null); + + attributes.put(UserModel.USERNAME, "us"); + + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + profile.validate(); + } + + @Test + public void testFirstLastNameCanBeOptional() { + getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testFirstLastNameCanBeOptional); + } + + private static void testFirstLastNameCanBeOptional(KeycloakSession session) throws IOException { + + configureSessionRealm(session); + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + attribute.setName(UserModel.FIRST_NAME); + Map validatorConfig = new HashMap<>(); + validatorConfig.put("max", 4); + attribute.addValidation("length", validatorConfig); + config.addAttribute(attribute); + + attribute = new UPAttribute(); + attribute.setName(UserModel.LAST_NAME); + attribute.addValidation("length", validatorConfig); + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + + // not present attributes are OK + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + //empty attributes are OK + attributes.put(UserModel.FIRST_NAME, ""); + attributes.put(UserModel.LAST_NAME, ""); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + //filled attributes are OK + attributes.put(UserModel.FIRST_NAME, "John"); + attributes.put(UserModel.LAST_NAME, "Doe"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + // fails due to additional length validation so it is executed correctly + attributes.put(UserModel.FIRST_NAME, "JohnTooLong"); + attributes.put(UserModel.LAST_NAME, "DoeTooLong"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(UserModel.FIRST_NAME)); + assertTrue(ve.isAttributeOnError(UserModel.LAST_NAME)); + } + } + + @Test + public void testCustomAttribute() { + getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testCustomAttribute); + } + + private static void testCustomAttribute(KeycloakSession session) throws IOException { + configureSessionRealm(session); + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(ATT_ADDRESS); + + Map validatorConfig = new HashMap<>(); + + validatorConfig.put("min", 4); + + attribute.addValidation("length", validatorConfig); + + // make it ALWAYS required + UPAttributeRequired requirements = new UPAttributeRequired(); + attribute.setRequired(requirements); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + // fails on required validation + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + // fails on length validation + attributes.put(ATT_ADDRESS, "adr"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + // all OK + attributes.put(ATT_ADDRESS, "adress ok"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + } + + @Test + public void testRequiredByUserRole_USER() { + getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testRequiredByUserRole_USER); + } + + private static void testRequiredByUserRole_USER(KeycloakSession session) throws IOException { + configureSessionRealm(session); + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(ATT_ADDRESS); + + UPAttributeRequired requirements = new UPAttributeRequired(); + + List roles = new ArrayList<>(); + roles.add(ROLE_USER); + requirements.setRoles(roles); + + attribute.setRequired(requirements); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + + // fail on common contexts + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + profile = provider.create(UserProfileContext.ACCOUNT, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); + try { + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + // no fail on User API + profile = provider.create(UserProfileContext.USER_API, attributes); + profile.validate(); + } + + @Test + public void testRequiredByUserRole_ADMIN() { + getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testRequiredByUserRole_ADMIN); + } + + private static void testRequiredByUserRole_ADMIN(KeycloakSession session) throws IOException { + configureSessionRealm(session); + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(ATT_ADDRESS); + + UPAttributeRequired requirements = new UPAttributeRequired(); + + List roles = new ArrayList<>(); + roles.add(UPConfigUtils.ROLE_ADMIN); + requirements.setRoles(roles); + + attribute.setRequired(requirements); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + + // NO fail on common contexts + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + profile = provider.create(UserProfileContext.ACCOUNT, attributes); + profile.validate(); + + profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); + profile.validate(); + + // fail on User API + try { + profile = provider.create(UserProfileContext.USER_API, attributes); + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + } + + @Test + public void testRequiredByScope_clientDefaultScope() { + getTestingClient().server().run((RunOnServer) UserProfileConfigTest::testRequiredByScope_clientDefaultScope); + } + + private static void testRequiredByScope_clientDefaultScope(KeycloakSession session) throws IOException { + configureSessionRealm(session); + DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session); + ComponentModel component = provider.getComponentModel(); + + assertNotNull(component); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName(ATT_ADDRESS); + + UPAttributeRequired requirements = new UPAttributeRequired(); + + List scopes = new ArrayList<>(); + scopes.add("client-a"); + requirements.setScopes(scopes); + + attribute.setRequired(requirements); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "user"); + + // client with default scopes for which is attribute NOT configured as required + configureAuthenticationSession(session, "client-b", null); + + // no fail on User API nor Account console as they do not have scopes + UserProfile profile = provider.create(UserProfileContext.USER_API, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.ACCOUNT, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.ACCOUNT_OLD, attributes); + profile.validate(); + + // no fail on auth flow scopes when scope is not required + profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.REGISTRATION_USER_CREATION, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.IDP_REVIEW, attributes); + profile.validate(); + + // client with default scope for which is attribute configured as required + configureAuthenticationSession(session, "client-a", null); + + // no fail on User API nor Account console as they do not have scopes + profile = provider.create(UserProfileContext.USER_API, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.ACCOUNT, attributes); + profile.validate(); + profile = provider.create(UserProfileContext.ACCOUNT_OLD, attributes); + profile.validate(); + + // fail on auth flow scopes when scope is required + try { + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + try { + profile = provider.create(UserProfileContext.REGISTRATION_PROFILE, attributes); + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + try { + profile = provider.create(UserProfileContext.REGISTRATION_USER_CREATION, attributes); + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + try { + profile = provider.create(UserProfileContext.IDP_REVIEW, attributes); + profile.validate(); + fail("Should fail validation"); + } catch (ValidationException ve) { + assertTrue(ve.isAttributeOnError(ATT_ADDRESS)); + } + + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java new file mode 100644 index 0000000000..eab8fbfde8 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java @@ -0,0 +1,416 @@ +/* + * + * * 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.testsuite.user.profile; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.services.messages.Messages; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.runonserver.RunOnServer; +import org.keycloak.testsuite.user.profile.config.UPAttribute; +import org.keycloak.testsuite.user.profile.config.UPAttributeRequired; +import org.keycloak.testsuite.user.profile.config.UPConfig; +import org.keycloak.testsuite.util.ClientScopeBuilder; +import org.keycloak.testsuite.util.KeycloakModelUtils; +import org.keycloak.userprofile.Attributes; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileProvider; +import org.keycloak.userprofile.ValidationException; +import org.keycloak.util.JsonSerialization; + +/** + * @author Pedro Igor + */ +public class UserProfileTest extends AbstractUserProfileTest { + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.setClientScopes(Collections.singletonList(ClientScopeBuilder.create().name("customer").protocol("openid-connect").build())); + ClientRepresentation client = KeycloakModelUtils.createClient(testRealm, "client-a"); + client.setDefaultClientScopes(Collections.singletonList("customer")); + } + + @After + public void onAfter() { + getTestingClient().server().run((RunOnServer) UserProfileTest::resetConfiguration); + } + + private static void resetConfiguration(KeycloakSession session) { + configureSessionRealm(session); + getDynamicUserProfileProvider(session).setConfiguration(null); + } + + @Test + public void testIdempotentProfile() { + getTestingClient().server().run((RunOnServer) UserProfileTest::testIdempotentProfile); + } + + private static void testIdempotentProfile(KeycloakSession session) { + Map attributes = new HashMap<>(); + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + attributes.put(UserModel.USERNAME, "profiled-user"); + + // once created, profile attributes can not be changed + assertTrue(profile.getAttributes().contains(UserModel.USERNAME)); + assertNull(profile.getAttributes().getFirstValue(UserModel.USERNAME)); + } + + @Test + public void testCustomAttributeInAnyContext() { + getTestingClient().server().run((RunOnServer) UserProfileTest::testCustomAttributeInAnyContext); + } + + private static void testCustomAttributeInAnyContext(KeycloakSession session) { + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "profiled-user"); + + UserProfileProvider provider = getDynamicUserProfileProvider(session); + + provider.setConfiguration("{\"attributes\": [{\"name\": \"address\", \"required\": {}}]}"); + + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + try { + profile.validate(); + Assert.fail("Should fail validation"); + } catch (ValidationException ve) { + // address is mandatory + assertTrue(ve.isAttributeOnError("address")); + } + + assertThat(profile.getAttributes().nameSet(), + containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, "address")); + + attributes.put("address", "myaddress"); + + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + profile.validate(); + } + + @Test + public void testResolveProfile() { + getTestingClient().server().run((RunOnServer) UserProfileTest::testResolveProfile); + } + + private static void testResolveProfile(KeycloakSession session) { + configureAuthenticationSession(session); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, "profiled-user"); + + UserProfileProvider provider = getDynamicUserProfileProvider(session); + + provider.setConfiguration("{\"attributes\": [{\"name\": \"business.address\", \"required\": {\"scopes\": [\"customer\"]}}]}"); + + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + + profile.getAttributes(); + + try { + profile.validate(); + Assert.fail("Should fail validation"); + } catch (ValidationException ve) { + // address is mandatory + assertTrue(ve.isAttributeOnError("business.address")); + } + + attributes.put("business.address", "valid-address"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + + profile = provider.create(UserProfileContext.ACCOUNT, attributes); + profile.validate(); + } + + @Test + public void testValidation() { + getTestingClient().server().run((RunOnServer) UserProfileTest::failValidationWhenEmptyAttributes); + getTestingClient().server().run((RunOnServer) UserProfileTest::testAttributeValidation); + } + + private static void failValidationWhenEmptyAttributes(KeycloakSession session) { + Map attributes = new HashMap<>(); + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + UserProfile profile; + + try { + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + Assert.fail("Should fail validation"); + } catch (ValidationException ve) { + // username is mandatory + assertTrue(ve.isAttributeOnError(UserModel.USERNAME)); + } + + RealmModel realm = session.getContext().getRealm(); + + try { + attributes.clear(); + attributes.put(UserModel.EMAIL, "profile-user@keycloak.org"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + Assert.fail("Should fail validation"); + } catch (ValidationException ve) { + // username is mandatory + assertTrue(ve.isAttributeOnError(UserModel.USERNAME)); + } + + try { + realm.setRegistrationEmailAsUsername(true); + attributes.clear(); + attributes.put(UserModel.EMAIL, "profile-user@keycloak.org"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + profile.validate(); + } catch (ValidationException ve) { + Assert.fail("Should be OK email as username"); + } finally { + // we should probably avoid this kind of logic and make the test reset the realm to original state + realm.setRegistrationEmailAsUsername(false); + } + + attributes.clear(); + attributes.put(UserModel.USERNAME, "profile-user"); + provider.create(UserProfileContext.UPDATE_PROFILE, attributes).validate(); + } + + private static void testAttributeValidation(KeycloakSession session) { + Map attributes = new HashMap<>(); + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + + UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + List errors = new ArrayList<>(); + + assertFalse(profile.getAttributes().validate(UserModel.USERNAME, (Consumer) errors::add)); + assertTrue(errors.contains(Messages.MISSING_USERNAME)); + + errors.clear(); + attributes.clear(); + attributes.put(UserModel.EMAIL, "invalid"); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes); + assertFalse(profile.getAttributes().validate(UserModel.EMAIL, (Consumer) errors::add)); + assertTrue(errors.contains(Messages.INVALID_EMAIL)); + } + + @Test + public void testValidateComplianceWithUserProfile() { + getTestingClient().server().run((RunOnServer) UserProfileTest::testValidateComplianceWithUserProfile); + } + + private static void testValidateComplianceWithUserProfile(KeycloakSession session) throws IOException { + RealmModel realm = configureSessionRealm(session); + UserModel user = session.users().addUser(realm, "profiled-user"); + UserProfileProvider provider = getDynamicUserProfileProvider(session); + + UPConfig config = new UPConfig(); + UPAttribute attribute = new UPAttribute(); + + attribute.setName("address"); + + UPAttributeRequired requirements = new UPAttributeRequired(); + + attribute.setRequired(requirements); + + config.addAttribute(attribute); + + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + UserProfile profile = provider.create(UserProfileContext.ACCOUNT, user); + + try { + profile.validate(); + Assert.fail("Should fail validation"); + } catch (ValidationException ve) { + // username is mandatory + assertTrue(ve.isAttributeOnError("address")); + } + + user.setAttribute("address", Arrays.asList("fixed-address")); + + profile = provider.create(UserProfileContext.ACCOUNT, user); + + profile.validate(); + } + + @Test + public void testGetProfileAttributes() { + getTestingClient().server().run((RunOnServer) UserProfileTest::testGetProfileAttributes); + } + + private static void testGetProfileAttributes(KeycloakSession session) { + RealmModel realm = configureSessionRealm(session); + UserModel user = session.users().addUser(realm, org.keycloak.models.utils.KeycloakModelUtils.generateId()); + UserProfileProvider provider = getDynamicUserProfileProvider(session); + + provider.setConfiguration("{\"attributes\": [{\"name\": \"address\", \"required\": {}}]}"); + + UserProfile profile = provider.create(UserProfileContext.ACCOUNT, user); + Attributes attributes = profile.getAttributes(); + + assertThat(attributes.nameSet(), + containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, "address")); + + try { + profile.validate(); + Assert.fail("Should fail validation"); + } catch (ValidationException ve) { + // username is mandatory + assertTrue(ve.isAttributeOnError("address")); + } + + assertNotNull(attributes.getFirstValue(UserModel.USERNAME)); + assertNull(attributes.getFirstValue(UserModel.EMAIL)); + assertNull(attributes.getFirstValue(UserModel.FIRST_NAME)); + assertNull(attributes.getFirstValue(UserModel.LAST_NAME)); + assertNull(attributes.getFirstValue("address")); + + user.setAttribute("address", Arrays.asList("fixed-address")); + + profile = provider.create(UserProfileContext.ACCOUNT, user); + attributes = profile.getAttributes(); + + profile.validate(); + + assertNotNull(attributes.getFirstValue("address")); + } + + @Test + public void testCreateAndUpdateUser() { + getTestingClient().server().run((RunOnServer) UserProfileTest::testCreateAndUpdateUser); + } + + private static void testCreateAndUpdateUser(KeycloakSession session) { + UserProfileProvider provider = getDynamicUserProfileProvider(session); + Map attributes = new HashMap<>(); + String userName = org.keycloak.models.utils.KeycloakModelUtils.generateId(); + + attributes.put(UserModel.USERNAME, userName); + attributes.put("address", "fixed-address"); + + UserProfile profile = provider.create(UserProfileContext.ACCOUNT, attributes); + UserModel user = profile.create(); + + assertEquals(userName, user.getUsername()); + assertEquals("fixed-address", user.getFirstAttribute("address")); + + attributes.put(UserModel.FIRST_NAME, "Alice"); + attributes.put(UserModel.LAST_NAME, "In Chains"); + attributes.put(UserModel.EMAIL, "alice@keycloak.org"); + + profile = provider.create(UserProfileContext.ACCOUNT, attributes, user); + Set attributesUpdated = new HashSet<>(); + + profile.update((attributeName, userModel) -> assertTrue(attributesUpdated.add(attributeName))); + + assertThat(attributesUpdated, containsInAnyOrder(UserModel.FIRST_NAME, UserModel.LAST_NAME, UserModel.EMAIL)); + + configureAuthenticationSession(session); + + attributes.put("business.address", "fixed-business-address"); + profile = provider.create(UserProfileContext.ACCOUNT, attributes, user); + + attributesUpdated.clear(); + profile.update((attributeName, userModel) -> assertTrue(attributesUpdated.add(attributeName))); + + assertThat(attributesUpdated, containsInAnyOrder("business.address")); + + assertEquals("fixed-business-address", user.getFirstAttribute("business.address")); + } + + @Test + public void testReadonlyUpdates() { + getTestingClient().server().run((RunOnServer) UserProfileTest::testReadonlyUpdates); + } + + private static void testReadonlyUpdates(KeycloakSession session) { + configureSessionRealm(session); + + Map attributes = new HashMap<>(); + + attributes.put(UserModel.USERNAME, org.keycloak.models.utils.KeycloakModelUtils.generateId()); + attributes.put("address", Arrays.asList("fixed-address")); + attributes.put("department", Arrays.asList("sales")); + + UserProfileProvider provider = getDynamicUserProfileProvider(session); + + provider.setConfiguration("{\"attributes\": [{\"name\": \"department\", \"permissions\": {\"edit\": [\"admin\"]}}]}"); + + UserProfile profile = provider.create(UserProfileContext.ACCOUNT, attributes); + UserModel user = profile.create(); + + assertThat(profile.getAttributes().nameSet(), + containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, "address", "department")); + + assertNull(user.getFirstAttribute("department")); + + profile = provider.create(UserProfileContext.USER_API, attributes, user); + + Set attributesUpdated = new HashSet<>(); + + profile.update((attributeName, userModel) -> assertTrue(attributesUpdated.add(attributeName))); + + assertThat(attributesUpdated, containsInAnyOrder("department")); + + assertEquals("sales", user.getFirstAttribute("department")); + + attributes.put("department", "cannot-change"); + + profile = provider.create(UserProfileContext.ACCOUNT, attributes, user); + + profile.update(); + + assertEquals("sales", user.getFirstAttribute("department")); + + assertTrue(profile.getAttributes().isReadOnly("department")); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java new file mode 100644 index 0000000000..de3b74d453 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigParserTest.java @@ -0,0 +1,250 @@ +/* + * 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.testsuite.user.profile.config; + +import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.readConfig; +import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.validate; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.Assert; +import org.junit.Test; + +import com.fasterxml.jackson.databind.JsonMappingException; + +/** + * Unit test for {@link UPConfigParser} functionality + * + * @author Vlastimil Elias + * + */ +public class UPConfigParserTest { + + @Test + public void attributeNameIsValid() { + // few invalid cases + Assert.assertFalse(UPConfigUtils.isValidAttributeName("")); + Assert.assertFalse(UPConfigUtils.isValidAttributeName(" ")); + Assert.assertFalse(UPConfigUtils.isValidAttributeName("a b")); + Assert.assertFalse(UPConfigUtils.isValidAttributeName("a*b")); + Assert.assertFalse(UPConfigUtils.isValidAttributeName("a%b")); + Assert.assertFalse(UPConfigUtils.isValidAttributeName("a$b")); + + // few valid cases + Assert.assertTrue(UPConfigUtils.isValidAttributeName("a-b")); + Assert.assertTrue(UPConfigUtils.isValidAttributeName("a.b")); + Assert.assertTrue(UPConfigUtils.isValidAttributeName("a_b")); + Assert.assertTrue(UPConfigUtils.isValidAttributeName("a3B")); + } + + @Test + public void loadConfigurationFromJsonFile() throws IOException { + UPConfig config = readConfig(getValidConfigFileIS()); + + // only basic assertion to check config is loaded, more detailed tests follow + Assert.assertEquals(5, config.getAttributes().size()); + } + + @Test + public void parseConfigurationFile_OK() throws IOException { + UPConfig config = loadValidConfig(); + + Assert.assertNotNull(config); + + // assert *** attributes *** + Assert.assertEquals(5, config.getAttributes().size()); + UPAttribute att = config.getAttributes().get(1); + Assert.assertNotNull(att); + Assert.assertEquals("email", att.getName()); + // validation + Assert.assertEquals(3, att.getValidations().size()); + Assert.assertEquals(1, att.getValidations().get("length").size()); + Assert.assertEquals(255, att.getValidations().get("length").get("max")); + // annotations + Assert.assertEquals("userEmailFormFieldHint", att.getAnnotations().get("formHintKey")); + + att = config.getAttributes().get(4); + // required + Assert.assertNotNull(att.getRequired()); + Assert.assertFalse(att.getRequired().isAlways()); + Assert.assertNotNull(att.getRequired().getScopes()); + Assert.assertNotNull(att.getRequired().getRoles()); + Assert.assertEquals(2, att.getRequired().getRoles().size()); + + // permissions + att = config.getAttributes().get(3); + Assert.assertTrue(att.getRequired().isAlways()); + + // permissions + Assert.assertNotNull(att.getPermissions()); + Assert.assertNotNull(att.getPermissions().getEdit()); + Assert.assertEquals(1, att.getPermissions().getEdit().size()); + Assert.assertTrue(att.getPermissions().getEdit().contains("admin")); + Assert.assertNotNull(att.getPermissions().getView()); + Assert.assertEquals(2, att.getPermissions().getView().size()); + Assert.assertTrue(att.getPermissions().getView().contains("admin")); + Assert.assertTrue(att.getPermissions().getView().contains("user")); + } + + /** + * Parse valid JSON config from the test file for tests. + * + * @return valid config + * @throws IOException + */ + private UPConfig loadValidConfig() throws IOException { + return readConfig(getValidConfigFileIS()); + } + + private InputStream getValidConfigFileIS() { + return getClass().getResourceAsStream("test-OK.json"); + } + + @Test(expected = JsonMappingException.class) + public void parseConfigurationFile_invalidJsonFormat() throws IOException { + readConfig(getClass().getResourceAsStream("test-invalidJsonFormat.json")); + } + + @Test(expected = IOException.class) + public void parseConfigurationFile_invalidType() throws IOException { + readConfig(getClass().getResourceAsStream("test-invalidType.json")); + } + + @Test(expected = IOException.class) + public void parseConfigurationFile_unknownField() throws IOException { + readConfig(getClass().getResourceAsStream("test-unknownField.json")); + } + + @Test + public void validateConfiguration_OK() throws IOException { + List errors = validate(loadValidConfig()); + Assert.assertTrue(errors.isEmpty()); + } + + @Test + public void validateConfiguration_attributeNameErrors() throws IOException { + UPConfig config = loadValidConfig(); + + UPAttribute attConfig = config.getAttributes().get(1); + + attConfig.setName(null); + List errors = validate(config); + Assert.assertEquals(1, errors.size()); + + attConfig.setName(" "); + errors = validate(config); + Assert.assertEquals(1, errors.size()); + + // duplicate attribute name + attConfig.setName("firstName"); + errors = validate(config); + Assert.assertEquals(1, errors.size()); + + // attribute name format error - unallowed character + attConfig.setName("ema il"); + errors = validate(config); + Assert.assertEquals(1, errors.size()); + } + + @Test + public void validateConfiguration_attributePermissionsErrors() throws IOException { + UPConfig config = loadValidConfig(); + + UPAttribute attConfig = config.getAttributes().get(1); + + // no permissions configures at all + attConfig.setPermissions(null); + List errors = validate(config); + Assert.assertEquals(0, errors.size()); + + // no permissions structure fields configured + UPAttributePermissions permsConfig = new UPAttributePermissions(); + attConfig.setPermissions(permsConfig); + errors = validate(config); + Assert.assertTrue(errors.isEmpty()); + + // valid if both are present, even empty + permsConfig.setEdit(Collections.emptyList()); + permsConfig.setView(Collections.emptyList()); + attConfig.setPermissions(permsConfig); + errors = validate(config); + Assert.assertEquals(0, errors.size()); + + List withInvRole = new ArrayList<>(); + withInvRole.add("invalid"); + + // invalid role used for view + permsConfig.setView(withInvRole); + errors = validate(config); + Assert.assertEquals(1, errors.size()); + + // invalid role used for edit also + permsConfig.setEdit(withInvRole); + errors = validate(config); + Assert.assertEquals(2, errors.size()); + } + + @Test + public void validateConfiguration_attributeRequirementsErrors() throws IOException { + UPConfig config = loadValidConfig(); + + UPAttribute attConfig = config.getAttributes().get(1); + + // it is OK without requirements configures at all + attConfig.setRequired(null); + List errors = validate(config); + Assert.assertEquals(0, errors.size()); + + // it is OK with empty config as it means ALWAYS required + UPAttributeRequired reqConfig = new UPAttributeRequired(); + attConfig.setRequired(reqConfig); + errors = validate(config); + Assert.assertEquals(0, errors.size()); + Assert.assertTrue(reqConfig.isAlways()); + + List withInvRole = new ArrayList<>(); + withInvRole.add("invalid"); + + // invalid role used + reqConfig.setRoles(withInvRole);; + errors = validate(config); + Assert.assertEquals(1, errors.size()); + Assert.assertFalse(reqConfig.isAlways()); + + } + + @Test + public void validateConfiguration_attributeValidationsErrors() throws IOException { + UPConfig config = loadValidConfig(); + + Map> validationConfig = config.getAttributes().get(1).getValidations(); + + validationConfig.put(" ",null); + List errors = validate(config); + Assert.assertEquals(1, errors.size()); + + // TODO Validation SPI integration - test validation of the validator existence and validator config + // validationConfig.setValidator("unknownValidator"); + // errors = UPConfigUtils.validateConfiguration(config); + // Assert.assertEquals(1, errors.size()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigUtilsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigUtilsTest.java new file mode 100644 index 0000000000..2d8d89d59f --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/config/UPConfigUtilsTest.java @@ -0,0 +1,115 @@ +/* + * 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.testsuite.user.profile.config; + +import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.ROLE_ADMIN; +import static org.keycloak.testsuite.user.profile.config.UPConfigUtils.ROLE_USER; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.userprofile.UserProfileContext; + +/** + * Unit test for {@link UPConfigUtils} + * + * @author Vlastimil Elias + * + */ +public class UPConfigUtilsTest { + + @Test + public void canBeAuthFlowContext() { + Assert.assertFalse(UPConfigUtils.canBeAuthFlowContext(UserProfileContext.ACCOUNT)); + Assert.assertFalse(UPConfigUtils.canBeAuthFlowContext(UserProfileContext.ACCOUNT_OLD)); + Assert.assertFalse(UPConfigUtils.canBeAuthFlowContext(UserProfileContext.USER_API)); + + Assert.assertTrue(UPConfigUtils.canBeAuthFlowContext(UserProfileContext.IDP_REVIEW)); + Assert.assertTrue(UPConfigUtils.canBeAuthFlowContext(UserProfileContext.REGISTRATION_PROFILE)); + Assert.assertTrue(UPConfigUtils.canBeAuthFlowContext(UserProfileContext.REGISTRATION_USER_CREATION)); + Assert.assertTrue(UPConfigUtils.canBeAuthFlowContext(UserProfileContext.UPDATE_PROFILE)); + } + + @Test + public void isRoleForContext() { + + Assert.assertFalse(UPConfigUtils.isRoleForContext(UserProfileContext.ACCOUNT, null)); + + List roles = new ArrayList<>(); + roles.add(ROLE_ADMIN); + Assert.assertTrue(UPConfigUtils.isRoleForContext(UserProfileContext.USER_API, roles)); + Assert.assertFalse(UPConfigUtils.isRoleForContext(UserProfileContext.ACCOUNT, roles)); + Assert.assertFalse(UPConfigUtils.isRoleForContext(UserProfileContext.ACCOUNT_OLD, roles)); + Assert.assertFalse(UPConfigUtils.isRoleForContext(UserProfileContext.UPDATE_PROFILE, roles)); + + roles = new ArrayList<>(); + roles.add(ROLE_USER); + Assert.assertFalse(UPConfigUtils.isRoleForContext(UserProfileContext.USER_API, roles)); + Assert.assertTrue(UPConfigUtils.isRoleForContext(UserProfileContext.ACCOUNT, roles)); + Assert.assertTrue(UPConfigUtils.isRoleForContext(UserProfileContext.ACCOUNT_OLD, roles)); + Assert.assertTrue(UPConfigUtils.isRoleForContext(UserProfileContext.IDP_REVIEW, roles)); + Assert.assertTrue(UPConfigUtils.isRoleForContext(UserProfileContext.REGISTRATION_PROFILE, roles)); + + // both in roles + roles.add(ROLE_ADMIN); + Assert.assertTrue(UPConfigUtils.isRoleForContext(UserProfileContext.USER_API, roles)); + Assert.assertTrue(UPConfigUtils.isRoleForContext(UserProfileContext.ACCOUNT, roles)); + Assert.assertTrue(UPConfigUtils.isRoleForContext(UserProfileContext.ACCOUNT_OLD, roles)); + Assert.assertTrue(UPConfigUtils.isRoleForContext(UserProfileContext.IDP_REVIEW, roles)); + Assert.assertTrue(UPConfigUtils.isRoleForContext(UserProfileContext.REGISTRATION_PROFILE, roles)); + } + + @Test + public void breakString() { + List ret = UPConfigUtils.getChunks(null, 2); + Assert.assertEquals(0, ret.size()); + + ret = UPConfigUtils.getChunks("", 2); + assertListContent(ret, ""); + + ret = UPConfigUtils.getChunks("1234567", 3); + assertListContent(ret, "123", "456", "7"); + + ret = UPConfigUtils.getChunks("12345678", 3); + assertListContent(ret, "123", "456", "78"); + + ret = UPConfigUtils.getChunks("123456789", 3); + assertListContent(ret, "123", "456", "789"); + } + + /** + * Assert list exactly contains all expected parts in given order + */ + private void assertListContent(List actual, String... expectedParts) { + int i = 0; + Assert.assertEquals(expectedParts.length, actual.size()); + for (String ep : expectedParts) { + Assert.assertEquals(ep, actual.get(i++)); + } + } + + @Test + public void capitalizeFirstLetter() { + Assert.assertNull(UPConfigUtils.capitalizeFirstLetter(null)); + Assert.assertEquals("",UPConfigUtils.capitalizeFirstLetter("")); + Assert.assertEquals("A",UPConfigUtils.capitalizeFirstLetter("a")); + Assert.assertEquals("AbcDefGh",UPConfigUtils.capitalizeFirstLetter("abcDefGh")); + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-OK.json b/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-OK.json new file mode 100644 index 0000000000..f048eead20 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-OK.json @@ -0,0 +1,58 @@ +{ + "attributes": [ + { + "name":"username", + "validations": { + "length" : { "min": 3, "max": 80 } + } + },{ + "name":"email ", + "validations": { + "length" : { "max": 255 }, + "emailFormat": {}, + "emailDomainDenyList": {} + }, + "required": { + "roles" : ["user", "admin"] + }, + "annotations": { + "formHintKey" : "userEmailFormFieldHint", + "anotherKey" : 10, + "yetAnotherKey" : "some value" + } + },{ + "name":"firstName", + "validations": { + "length": { "max": 255 } + }, + "permissions": { + "view": ["admin", "user"], + "edit": ["admin", "user"] + }, + "required": {} + }, { + "name":"lastName", + "validations": { + "length": { "max": 255 } + }, + "required": {}, + "permissions": { + "view": ["admin", "user"], + "edit": ["admin"] + } + },{ + "name":"phone", + "validations": { + "phoneNumberFormatInternational":{} + }, + "required": { + "scopes" : ["phone-1", "phone-2"], + "roles" : ["user", "admin"] + }, + "permissions": { + "view": ["admin", "user"], + "edit": ["admin"] + } + } + ] +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-invalidJsonFormat.json b/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-invalidJsonFormat.json new file mode 100644 index 0000000000..67bdb5bd12 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-invalidJsonFormat.json @@ -0,0 +1,11 @@ +{ + "attributes": [ + { + "name":"n1" + "name2" : "" + }, + { + "name":"n2" + } + ] +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-invalidType.json b/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-invalidType.json new file mode 100644 index 0000000000..636033b619 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-invalidType.json @@ -0,0 +1,3 @@ +{ + "attributes": {} +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-unknownField.json b/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-unknownField.json new file mode 100644 index 0000000000..8691e728b6 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/org/keycloak/testsuite/user/profile/config/test-unknownField.json @@ -0,0 +1,5 @@ +{ + "attributes": [ + ], + "unknown" : {} +} \ No newline at end of file