[KEYCLOAK-17399] - Review User Profile SPI

Co-Authored-By: Vlastimil Elias <vlastimil.elias@worldonline.cz>
This commit is contained in:
Pedro Igor 2021-03-11 11:39:44 -03:00
parent 1c283cdebc
commit a0f8d2bc0e
80 changed files with 5032 additions and 2005 deletions

View file

@ -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<String, List<String>> toAttributes() {
Map<String, List<String>> 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;
}
}

View file

@ -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<String, Boolean> access) {
this.access = access;
}
public Map<String, List<String>> toAttributes() {
Map<String, List<String>> 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;
}
}

View file

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

View file

@ -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<UserEntity> {
@Override
public void setEmail(String email) {
if (ObjectUtil.isBlank(email)) {
email = null;
}
email = KeycloakModelUtils.toLowerCaseSafe(email);
user.setEmail(email, realm.isDuplicateEmailsAllowed());
}

View file

@ -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<K> extends AbstractUserModel<MapUserEntity<
@Override
public void setEmail(String email) {
email = KeycloakModelUtils.toLowerCaseSafe(email);
if (email != null && email.equals(entity.getEmail())) return;
if (email != null) {
if (email.equals(entity.getEmail())) {
return;
}
if (ObjectUtil.isBlank(email)) {
email = null;
}
}
boolean duplicatesAllowed = realm.isDuplicateEmailsAllowed();
if (!duplicatesAllowed && email != null && checkEmailUniqueness(realm, email)) {

View file

@ -0,0 +1,67 @@
/*
*
* * 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.List;
import java.util.Map;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class AttributeContext {
private final KeycloakSession session;
private final Map.Entry<String, List<String>> attribute;
private final UserModel user;
private final AttributeMetadata metadata;
private UserProfileContext context;
public AttributeContext(UserProfileContext context, KeycloakSession session, Map.Entry<String, List<String>> 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<String, List<String>> getAttribute() {
return attribute;
}
public UserModel getUser() {
return user;
}
public UserProfileContext getContext() {
return context;
}
public AttributeMetadata getMetadata() {
return metadata;
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class AttributeMetadata {
public static final Predicate<AttributeContext> ALWAYS_TRUE = context -> true;
public static final Predicate<AttributeContext> ALWAYS_FALSE = context -> false;
private final String attributeName;
private final Predicate<AttributeContext> selector;
private final Predicate<AttributeContext> readOnly;
/** Predicate to decide if attribute is required, it is handled as required if predicate is null */
private final Predicate<AttributeContext> required;
private List<AttributeValidatorMetadata> validators;
private Map<String, Object> annotations;
AttributeMetadata(String attributeName) {
this(attributeName, ALWAYS_TRUE, ALWAYS_FALSE, ALWAYS_TRUE);
}
AttributeMetadata(String attributeName, Predicate<AttributeContext> readOnly, Predicate<AttributeContext> required) {
this(attributeName, ALWAYS_TRUE, readOnly, required);
}
AttributeMetadata(String attributeName, Predicate<AttributeContext> selector) {
this(attributeName, selector, ALWAYS_FALSE, ALWAYS_TRUE);
}
AttributeMetadata(String attributeName, List<String> scopes, Predicate<AttributeContext> readOnly, Predicate<AttributeContext> 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<AttributeContext> selector, Predicate<AttributeContext> readOnly, Predicate<AttributeContext> 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<AttributeValidatorMetadata> getValidators() {
return validators;
}
public AttributeMetadata addValidator(List<AttributeValidatorMetadata> 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<String, Object> getAnnotations() {
return annotations;
}
public AttributeMetadata addAnnotations(Map<String, Object> 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;
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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);
}
}

View file

@ -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;
/**
* <p>This interface wraps the attributes associated with a user profile. Different operations are provided to access and
* manage these attributes.
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public interface Attributes {
/**
* Default value for attributes with no value set.
*/
List<String> 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<String> 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<String> getValues(String name);
/**
* Checks whether an attribute is read-only.
*
* @param key
*
* @return
*/
boolean isReadOnly(String key);
/**
* <Validates the attribute with the given {@code name}.
*
* @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
*/
boolean validate(String name, BiConsumer<Map.Entry<String, List<String>>, 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<String>... listeners) {
return validate(name, (attribute, error) -> {
for (Consumer<String> 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<String> nameSet();
/**
* Returns all attributes defined.
*
* @return the attributes
*/
Set<Map.Entry<String, List<String>>> attributeSet();
}

View file

@ -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;
/**
* <p>The default implementation for {@link Attributes}. Should be reused as much as possible by the different implementations
* of {@link UserProfileProvider}.
*
* <p>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).
*
* <p>This implementation is not specific to any user profile implementation.
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class DefaultAttributes extends HashMap<String, List<String>> 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<String, AttributeMetadata> metadataByAttribute;
private final UserModel user;
public DefaultAttributes(UserProfileContext context, Map<String, ?> 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<Entry<String, List<String>>, String>... listeners) {
Entry<String, List<String>> attribute = createAttribute(name);
List<AttributeMetadata> 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<AttributeValidatorMetadata> 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<Entry<String, List<String>>, String> consumer : listeners) {
consumer.accept(attribute, failingValidator.getMessage());
}
}
}
return failingValidators.isEmpty();
}
@Override
public List<String> getValues(String name) {
return getOrDefault(name, EMPTY_VALUE);
}
@Override
public boolean contains(String name) {
return containsKey(name);
}
@Override
public Set<String> nameSet() {
return keySet();
}
@Override
public Set<Entry<String, List<String>>> attributeSet() {
return entrySet();
}
private AttributeContext createAttributeContext(Entry<String, List<String>> 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<String, AttributeMetadata> configureMetadata(List<AttributeMetadata> attributes) {
Map<String, AttributeMetadata> 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<String, List<String>> createAttribute(String name) {
return new SimpleImmutableEntry<String, List<String>>(name, null) {
@Override
public List<String> getValue() {
List<String> 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<String, List<String>> normalizeAttributes(Map<String, ?> attributes) {
Map<String, List<String>> newAttributes = new HashMap<>();
RealmModel realm = session.getContext().getRealm();
if (attributes != null && !attributes.isEmpty()) {
for (Map.Entry<String, ?> 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<String> values;
if (value instanceof String) {
values = Collections.singletonList((String) value);
} else {
values = (List<String>) 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<String> 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<String> email = newAttributes.get(UserModel.EMAIL);
if (email != null && realm.isRegistrationEmailAsUsername()) {
newAttributes.put(UserModel.USERNAME, email);
}
return newAttributes;
}
/**
* <p>Checks whether an attribute is support by the profile configuration and the current context.
*
* <p>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<String, List<String>> attribute = createAttribute(attributeName);
for (AttributeValidatorMetadata validator : readonlyMetadata.getValidators()) {
if (!validator.validate(createAttributeContext(attribute, readonlyMetadata))) {
return true;
}
}
return false;
}
}

View file

@ -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;
/**
* <p>The default implementation for {@link UserProfile}. Should be reused as much as possible by the different implementations
* of {@link UserProfileProvider}.
*
* <p>This implementation is not specific to any user profile implementation.
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class DefaultUserProfile implements UserProfile {
private final Function<Attributes, UserModel> userSupplier;
private final Attributes attributes;
private boolean validated;
private UserModel user;
public DefaultUserProfile(Attributes attributes, Function<Attributes, UserModel> 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<String, UserModel>... changeListener) {
if (!validated) {
validate();
}
updateInternal(user, removeAttributes, changeListener);
}
private UserModel updateInternal(UserModel user, boolean removeAttributes, BiConsumer<String, UserModel>... changeListener) {
if (user == null) {
throw new RuntimeException("No user model provided for persisting changes");
}
try {
for (Map.Entry<String, List<String>> attribute : attributes.attributeSet()) {
String name = attribute.getKey();
if (attributes.isReadOnly(name)) {
continue;
}
List<String> currentValue = user.getAttributeStream(name).filter(Objects::nonNull).collect(Collectors.toList());
List<String> updatedValue = attribute.getValue().stream().filter(Objects::nonNull).collect(Collectors.toList());
if (currentValue.size() != updatedValue.size() || !currentValue.containsAll(updatedValue)) {
user.setAttribute(name, updatedValue);
for (BiConsumer<String, UserModel> 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<String> 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;
}
}

View file

@ -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)
* <p>An interface providing as an entry point for managing users.
*
* <p>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.
*
* <p>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.
*
* <p>A {@code UserProfile} instance can be obtained through the {@link UserProfileProvider}.
*
* @see UserProfileContext
* @see UserProfileProvider
* @author <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
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;
/**
* <p>Updates the {@link UserModel} associated with this instance. If no {@link UserModel} is associated with this instance, this operation has no effect.
*
* <p>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<String, UserModel>... changeListener) throws ValidationException;
/**
* <p>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<String, UserModel>... 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();
}

View file

@ -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<String, List<String>> {
private final UserProfileProvider profileProvider;
public UserProfileAttributes(Map<String, List<String>> attribtues,
UserProfileProvider profileProvider){
this.profileProvider = profileProvider;
this.putAll(attribtues);
}
public UserProfileAttributes(Map<String, List<String>> attribtues){
this(attribtues, null);
}
public void setAttribute(String key, List<String> 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<String> 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);
}
}

View file

@ -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;
/**
* <p>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.
*
* <p>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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
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;
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class UserProfileMetadata implements Cloneable {
private final UserProfileContext context;
private List<AttributeMetadata> attributes;
public UserProfileMetadata(UserProfileContext context) {
this.context = context;
}
public List<AttributeMetadata> getAttributes() {
return attributes;
}
public void addAttributes(AttributeMetadata... metadata) {
addAttributes(Arrays.asList(metadata));
}
public void addAttributes(List<AttributeMetadata> 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<AttributeValidatorMetadata> validators) {
return addAttribute(new AttributeMetadata(name).addValidator(validators));
}
public AttributeMetadata addAttribute(String name, List<AttributeValidatorMetadata> validator, Predicate<AttributeContext> required) {
return addAttribute(new AttributeMetadata(name, AttributeMetadata.ALWAYS_FALSE, required).addValidator(validator));
}
public AttributeMetadata addAttribute(String name, List<AttributeValidatorMetadata> validator, Predicate<AttributeContext> readOnly, Predicate<AttributeContext> 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<AttributeMetadata> 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();
}
}

View file

@ -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;
/**
* <p>The provider responsible for creating {@link UserProfile} instances.
*
* @see UserProfile
* @author <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public interface UserProfileProvider extends Provider {
UserProfileValidationResult validate(UserProfileContext updateContext, UserProfile updatedProfile);
/**
* <p>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);
/**
* <p>Creates a new {@link UserProfile} instance for a given {@code context} and {@code attributes} for validation purposes.
*
* <p>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<String, ?> attributes);
/**
* <p>Creates a new {@link UserProfile} instance for a given {@code context} and {@code attributes} for update purposes.
*
* <p>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<String, ?> 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);
}

View file

@ -22,6 +22,6 @@ import org.keycloak.provider.ProviderFactory;
/**
* @author <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public interface UserProfileProviderFactory extends ProviderFactory<UserProfileProvider> {
public interface UserProfileProviderFactory<U extends UserProfileProvider> extends ProviderFactory<U> {
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class ValidationException extends RuntimeException {
private final Map<String, List<Error>> errors = new HashMap<>();
public List<Error> 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<String> names = Arrays.asList(name);
return errors.values().stream().flatMap(Collection::stream)
.anyMatch(error -> names.contains(error.attribute.getKey()));
}
void addError(Error error) {
List<Error> errors = this.errors.computeIfAbsent(error.getMessage(), (k) -> new ArrayList<>());
errors.add(error);
}
public static class Error {
private final Map.Entry<String, List<String>> attribute;
private final String message;
public Error(Map.Entry<String, List<String>> 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;
}
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class AttributeValidationResult {
private final String attributeKey;
private final boolean changed;
List<ValidationResult> validationResults;
public List<ValidationResult> getValidationResults() {
return validationResults;
}
public List<ValidationResult> getFailedValidations() {
return validationResults == null ? null : validationResults.stream().filter(ValidationResult::isInvalid).collect(Collectors.toList());
}
public AttributeValidationResult(String attributeKey, boolean changed, List<ValidationResult> 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())));
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class UserProfileValidationResult {
List<AttributeValidationResult> attributeValidationResults;
private final UserProfile updatedProfile;
public UserProfileValidationResult(List<AttributeValidationResult> attributeValidationResults,
UserProfile updatedProfile) {
this.attributeValidationResults = attributeValidationResults;
this.updatedProfile = updatedProfile;
}
public List<AttributeValidationResult> getValidationResults() {
return attributeValidationResults;
}
public List<AttributeValidationResult> 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;
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public enum UserUpdateEvent {
UpdateProfile,
UserResource,
Account,
IdpReview,
RegistrationProfile,
RegistrationUserCreation
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
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;
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public interface Validator {
/**
* @returns true if validation success, false if validation fails
*/
boolean validate(AttributeContext context);
}

View file

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

View file

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

View file

@ -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<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
UserProfileValidationResult result = forIdpReview(userCtx, formData, context.getSession()).validate();
UserModelDelegate updatedProfile = new UserModelDelegate(null) {
List<FormMessage> errors = Validation.getFormErrorsFromValidation(result);
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;
@Override
public String getId() {
return userCtx.getId();
}
UserProfile updatedProfile = result.getProfile();
UserUpdateHelper.updateIdpReview(context.getRealm(), new UserModelDelegate(null) {
@Override
public Map<String, List<String>> 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<FormMessage> 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

View file

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

View file

@ -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<String, String> 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<FormMessage> 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<FormMessage> 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<String, String> 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);

View file

@ -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<FormMessage> 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<FormMessage> 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();
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class AttributeFormDataProcessor {
public static AttributeUserProfile process(MultivaluedMap<String, String> formData) {
Map<String, List<String>> 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<String> modelValue = new ArrayList<String>();
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<String, String> 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<String, String> formData, AttributeUserProfile rep) {
if (formData.getFirst(key) != null)
rep.getAttributes().setSingleAttribute(key, formData.getFirst(key));
}
private static void addOrSetValue(List<String> list, int index, String value) {
if (list.size() > index) {
list.set(index, value);
} else {
list.add(value);
}
}
}

View file

@ -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<FormMessage> errors = Validation.getFormErrorsFromValidation(result);
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
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<FormMessage> errors = Validation.getFormErrorsFromValidation(pve.getErrors());
if (!errors.isEmpty()) {
setReferrerOnPage();
Response.Status status = Status.OK;
if (result.hasFailureOfErrorType(Messages.READ_ONLY_USERNAME)) {
if (pve.hasError(Messages.READ_ONLY_USERNAME)) {
status = Response.Status.BAD_REQUEST;
} else if (result.hasFailureOfErrorType(Messages.EMAIL_EXISTS, Messages.USERNAME_EXISTS)) {
} else if (pve.hasError(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);
try {
// backward compatibility with old account console where attributes are not removed if missing
UserUpdateHelper.updateAccountOldConsole(realm, user, updatedProfile);
} 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);

View file

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

View file

@ -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 <velias@redhat.com>
*
*/
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();
}
}

View file

@ -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;
}
}
public static void updateUserFromRep(UserModel user, UserRepresentation rep, KeycloakSession session, boolean isUpdateExistingUser) {
return null;
}
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());

View file

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

View file

@ -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<FormMessage> validateRegistrationForm(KeycloakSession session, RealmModel realm, MultivaluedMap<String, String> formData, List<String> requiredCredentialTypes, PasswordPolicy policy) {
List<FormMessage> 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<FormMessage> errors, String field, String message){
errors.add(new FormMessage(field, message));
}
public static List<FormMessage> validateUpdateProfileForm(RealmModel realm, MultivaluedMap<String, String> formData) {
return validateUpdateProfileForm(realm, formData, realm.isEditUsernameAllowed());
}
public static List<FormMessage> validateUpdateProfileForm(RealmModel realm, MultivaluedMap<String, String> formData, boolean userNameRequired) {
List<FormMessage> 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<FormMessage> getFormErrorsFromValidation(UserProfileValidationResult results) {
List<FormMessage> errors = new ArrayList<>();
for (AttributeValidationResult result : results.getErrors()) {
result.getFailedValidations().forEach(o -> addError(errors, result.getField(), o.getErrorType()));
public static List<FormMessage> getFormErrorsFromValidation(List<ValidationException.Error> errors) {
List<FormMessage> messages = new ArrayList<>();
for (ValidationException.Error error : errors) {
addError(messages, error.getAttribute(), error.getMessage());
}
return errors;
return messages;
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
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()
);
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
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<String> readOnlyAttributes = new ArrayList<>(Arrays.asList(builtinReadOnlyAttributes));
if (readOnlyAttributesCfg != null) {
List<String> 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;
}
}

View file

@ -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;
/**
* <p>A base class for {@link UserProfileProvider} implementations providing the main hooks for customizations.
*
* @author <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public abstract class AbstractUserProfileProvider<U extends UserProfileProvider> implements UserProfileProvider, UserProfileProviderFactory<U> {
private static final Logger logger = Logger.getLogger(DefaultAttributes.class);
public static Pattern getRegexPatternString(String[] builtinReadOnlyAttributes) {
if (builtinReadOnlyAttributes != null) {
List<String> 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<String, List<String>> attribute = context.getAttribute();
String key = attribute.getKey();
if (!pattern.matcher(key).find()) {
return true;
}
List<String> values = attribute.getValue();
if (values == null) {
return true;
}
UserModel user = context.getUser();
List<String> 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<UserProfileContext, UserProfileMetadata> contextualMetadataRegistry;
protected final KeycloakSession session;
public AbstractUserProfileProvider() {
// for reflection
this(null, new HashMap<>());
}
public AbstractUserProfileProvider(KeycloakSession session, Map<UserProfileContext, UserProfileMetadata> 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<String, ?> attributes, UserModel user) {
return createUserProfile(context, attributes, user);
}
@Override
public UserProfile create(UserProfileContext context, Map<String, ?> 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<UserProfileContext, UserProfileMetadata> 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<Attributes, UserModel> createUserFactory() {
return new Function<Attributes, UserModel>() {
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<String, ?> 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<String, List<String>> attribute = context.getAttribute();
List<String> 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<String, List<String>> attribute = context.getAttribute();
List<String> 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<AttributeValidatorMetadata> 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<AttributeValidatorMetadata> 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<AttributeValidatorMetadata> 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;
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class DefaultUserProfileProvider extends AbstractUserProfileProvider<DefaultUserProfileProvider> {
private static final String PROVIDER_ID = "legacy-user-profile";
public DefaultUserProfileProvider() {
// for reflection
}
public DefaultUserProfileProvider(KeycloakSession session, Map<UserProfileContext, UserProfileMetadata> validators) {
super(session, validators);
}
@Override
protected DefaultUserProfileProvider create(KeycloakSession session, Map<UserProfileContext, UserProfileMetadata> metadataRegistry) {
return new DefaultUserProfileProvider(session, metadataRegistry);
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public int order() {
return 1;
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
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<String, List<String>> attribute = context.getAttribute();
List<String> 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<String, List<String>> attribute = context.getAttribute();
List<String> 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<String, List<String>> attribute = context.getAttribute();
List<String> 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<String, List<String>> attribute = context.getAttribute();
List<String> 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<String, List<String>> attribute = context.getAttribute();
List<String> 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<String, List<String>> attribute = context.getAttribute();
List<String> 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<String, List<String>> attribute = context.getAttribute();
List<String> 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<String, List<String>> attribute = context.getAttribute();
List<String> 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<String, List<String>> attribute = context.getAttribute();
List<String> 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<String, Object> config) {
return (context) -> {
Map.Entry<String, List<String>> attribute = context.getAttribute();
List<String> 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<String, List<String>> attribute = context.getAttribute();
List<String> values = attribute.getValue();
if (values == null || values.isEmpty()) {
return false;
}
String value = values.get(0);
return !Validation.isBlank(value);
};
}
}

View file

@ -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<String, List<String>> attributes, UserProfileProvider profileProvider) {
this.attributes = new UserProfileAttributes(attributes, profileProvider);
}
@Override
public UserProfileAttributes getAttributes() {
return this.attributes;
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
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);
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class UserProfileContextFactory {
public static DefaultUserProfileContext forIdpReview(SerializedBrokeredIdentityContext currentUser,
MultivaluedMap<String, String> 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<String, String> 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<String, String> 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<String, String> formData) {
UserProfileProvider profileProvider = getProfileProvider(session);
return new DefaultUserProfileContext(UserUpdateEvent.RegistrationUserCreation, null,
AttributeFormDataProcessor.toUserProfile(formData), profileProvider);
}
public static DefaultUserProfileContext forRegistrationProfile(KeycloakSession session,
MultivaluedMap<String, String> 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);
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class AccountUserRepresentationUserProfile extends AttributeUserProfile {
public AccountUserRepresentationUserProfile(UserRepresentation user, UserProfileProvider profileProvider) {
super(flattenUserRepresentation(user), profileProvider);
}
private static UserProfileAttributes flattenUserRepresentation(UserRepresentation user) {
Map<String, List<String>> 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);
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class AttributeUserProfile extends AbstractUserProfile {
public AttributeUserProfile(Map<String, List<String>> attributes, UserProfileProvider profileProvider) {
super(attributes, profileProvider);
}
public AttributeUserProfile(Map<String, List<String>> attributes) {
super(attributes, null);
}
@Override
public String getId() {
throw new NotSupportedException("No ID support");
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
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();
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
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();
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
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<String, List<String>> 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);
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
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);
}
/**
* <p>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<String, List<String>> attr : attributes.entrySet()) {
List<String> currentValue = currentUser.getAttributeStream(attr.getKey()).collect(Collectors.toList());
//In case of username we need to provide lower case values
List<String> 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<String> 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<String> AttributeToLower(List<String> attr) {
if (attr.size() == 1 && attr.get(0) != null)
return Collections.singletonList(KeycloakModelUtils.toLowerCaseSafe(attr.get(0)));
return attr;
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class AttributeValidator {
String attributeKey;
List<Validator> validators;
public AttributeValidator(String attributeKey, List<Validator> validators) {
this.validators = validators;
this.attributeKey = attributeKey;
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class AttributeValidatorBuilder {
ValidationChainBuilder validationChainBuilder;
String attributeKey;
List<Validator> 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<String, UserProfileContext, Boolean> validationFunction) {
BiFunction<List<String>, 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<List<String>, 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;
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class StaticValidators {
private static final Logger logger = Logger.getLogger(StaticValidators.class);
public static BiFunction<String, UserProfileContext, Boolean> isBlank() {
return (value, context) ->
value==null || !Validation.isBlank(value);
}
public static BiFunction<String, UserProfileContext, Boolean> isEmailValid() {
return (value, context) ->
Validation.isBlank(value) || Validation.isEmailValid(value);
}
public static BiFunction<String, UserProfileContext, Boolean> 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<String, UserProfileContext, Boolean> 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<String, UserProfileContext, Boolean> checkUsernameExists(boolean externalCondition) {
return (value, context) ->
!(externalCondition && Validation.isBlank(value));
}
public static BiFunction<String, UserProfileContext, Boolean> 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<String, UserProfileContext, Boolean> 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<String, UserProfileContext, Boolean> doesEmailExist(KeycloakSession session) {
return (value, context) ->
!(value != null
&& !session.getContext().getRealm().isDuplicateEmailsAllowed()
&& session.users().getUserByEmail(session.getContext().getRealm(), value) != null);
}
public static BiFunction<List<String>, UserProfileContext, Boolean> isReadOnlyAttributeUnchanged(String attributeName) {
return (newAttrValues, context) -> {
if (newAttrValues == null) {
return true;
}
List<String> 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;
};
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class ValidationChain {
List<AttributeValidator> attributeValidators;
public ValidationChain(List<AttributeValidator> attributeValidators) {
this.attributeValidators = attributeValidators;
}
public List<AttributeValidationResult> validate(UserProfileContext updateContext, UserProfile updatedProfile) {
List<AttributeValidationResult> overallResults = new ArrayList<>();
for (AttributeValidator attribute : attributeValidators) {
List<ValidationResult> validationResults = new ArrayList<>();
String attributeKey = attribute.attributeKey;
List<String> attributeValues = updatedProfile.getAttributes().getAttribute(attributeKey);
List<String> 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;
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class ValidationChainBuilder {
Map<String, AttributeValidator> 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);
}
}
}

View file

@ -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 <a href="mailto:markus.till@bosch.io">Markus Till</a>
*/
public class Validator {
String errorType;
BiFunction<List<String>, UserProfileContext, Boolean> function;
public Validator(String errorType, BiFunction<List<String>, UserProfileContext, Boolean> function) {
this.function = function;
this.errorType = errorType;
}
}

View file

@ -15,4 +15,4 @@
# limitations under the License.
#
org.keycloak.userprofile.LegacyUserProfileProviderFactory
org.keycloak.userprofile.legacy.DefaultUserProfileProvider

View file

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

View file

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

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class DeclarativeUserProfileModel extends ComponentModel {
public DeclarativeUserProfileModel() {
setProviderId(DeclarativeUserProfileProvider.ID);
setProviderType(UserProfileProvider.class.getName());
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
* @author Vlastimil Elias <velias@redhat.com>
*/
public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<DeclarativeUserProfileProvider>
implements AmphibianProviderFactory<DeclarativeUserProfileProvider> {
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<UserProfileContext, UserProfileMetadata> metadataRegistry) {
super(session, metadataRegistry);
}
@Override
public String getId() {
return ID;
}
@Override
protected DeclarativeUserProfileProvider create(KeycloakSession session,
Map<UserProfileContext, UserProfileMetadata> metadataRegistry) {
return new DeclarativeUserProfileProvider(session, metadataRegistry);
}
@Override
protected UserProfileMetadata configureUserProfile(UserProfileMetadata metadata, KeycloakSession session) {
ComponentModel model = getComponentModelOrCreate(session);
Map<UserProfileContext, UserProfileMetadata> 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<String> 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<String> parts = UPConfigUtils.getChunks(configuration, 3800);
MultivaluedHashMap<String, String> 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<ProviderConfigProperty> 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<AttributeValidatorMetadata> validators = new ArrayList<>();
Map<String, Map<String, Object>> validationsConfig = attrConfig.getValidations();
if (validationsConfig != null) {
for (Map.Entry<String, Map<String, Object>> vc : validationsConfig.entrySet()) {
validators.add(createConfiguredValidator(attrConfig, vc.getKey(), vc.getValue()));
}
}
UPAttributeRequired rc = attrConfig.getRequired();
Predicate<AttributeContext> 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<AttributeContext> readOnly = AttributeMetadata.ALWAYS_FALSE;
UPAttributePermissions permissions = attrConfig.getPermissions();
if (permissions != null) {
List<String> editRoles = permissions.getEdit();
if (editRoles != null && !editRoles.isEmpty()) {
readOnly = ac -> !UPConfigUtils.isRoleForContext(ac.getContext(), editRoles);
}
}
Map<String, Object> 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<AttributeMetadata> 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.
* <p>
* 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<String> scopesConfigured) {
// never match out of auth flow
if (session.getContext().getAuthenticationSession() == null) {
return false;
}
return getAuthFlowRequestedScopeNames().stream().anyMatch(scopesConfigured::contains);
}
private Set<String> 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<String, Object> 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);
}
}

View file

@ -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 <velias@redhat.com>
*
*/
public class UPAttribute {
private String name;
/** key in the Map is name of the validator, value is its configuration */
private Map<String, Map<String, Object>> validations;
private Map<String, Object> 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<String, Map<String, Object>> getValidations() {
return validations;
}
public void setValidations(Map<String, Map<String, Object>> validations) {
this.validations = validations;
}
public Map<String, Object> getAnnotations() {
return annotations;
}
public void setAnnotations(Map<String, Object> 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<String, Object> 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 + "]";
}
}

View file

@ -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 <velias@redhat.com>
*
*/
public class UPAttributePermissions {
private List<String> view;
private List<String> edit;
public List<String> getView() {
return view;
}
public void setView(List<String> view) {
this.view = view;
}
public List<String> getEdit() {
return edit;
}
public void setEdit(List<String> edit) {
this.edit = edit;
}
@Override
public String toString() {
return "UPAttributePermissions [view=" + view + ", edit=" + edit + "]";
}
}

View file

@ -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 <velias@redhat.com>
*
*/
public class UPAttributeRequired {
private List<String> roles;
private List<String> 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<String> getRoles() {
return roles;
}
public void setRoles(List<String> roles) {
this.roles = roles;
}
public List<String> getScopes() {
return scopes;
}
public void setScopes(List<String> scopes) {
this.scopes = scopes;
}
@Override
public String toString() {
return "UPAttributeRequired [isAlways=" + isAlways() + ", roles=" + roles + ", scopes=" + scopes + "]";
}
}

View file

@ -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 <velias@redhat.com>
*
*/
public class UPConfig {
private List<UPAttribute> attributes;
public List<UPAttribute> getAttributes() {
return attributes;
}
public void setAttributes(List<UPAttribute> 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 + "]";
}
}

View file

@ -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 <velias@redhat.com>
*
*/
public class UPConfigUtils {
public static final String ROLE_USER = "user";
public static final String ROLE_ADMIN = "admin";
private static final Set<String> PSEUDOROLES = new HashSet<>();
static {
PSEUDOROLES.add(ROLE_ADMIN);
PSEUDOROLES.add(ROLE_USER);
}
/**
* Load configuration from JSON file.
* <p>
* 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:
* <ul>
* <li>defaultProfile is defined and exists in profiles
* <li>parent exists for type
* <li>type exists for attribute
* <li>validator (from Validator SPI) exists for validation and it's config is correct
* </ul>
*
* @param config to validate
* @return list of errors, empty if no error found
*/
public static List<String> validate(UPConfig config) {
List<String> errors = new ArrayList<>();
if (config.getAttributes() != null) {
Set<String> 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<String> errors, Set<String> 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<String> roles, String fieldName, List<String> 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<String, Object> validatorConfig, String attributeName, List<String> 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<String> getChunks(String src, int partLength) {
List<String> 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<String> 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);
}
}

View file

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

View file

@ -21,6 +21,10 @@
</resources>
<dependencies>
<module name="com.fasterxml.jackson.core.jackson-core"/>
<module name="com.fasterxml.jackson.core.jackson-annotations"/>
<module name="com.fasterxml.jackson.core.jackson-databind"/>
<module name="com.fasterxml.jackson.datatype.jackson-datatype-jdk8"/>
<module name="com.fasterxml.jackson.jaxrs.jackson-jaxrs-json-provider"/>
<module name="javax.api"/>
<module name="javax.mail.api"/>
<module name="javax.ws.rs.api"/>

View file

@ -0,0 +1,18 @@
{
"attributes": [
{
"name": "username"
},
{
"name": "email"
},
{
"name": "firstName",
"required": {}
},
{
"name": "lastName",
"required": {}
}
]
}

View file

@ -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();

View file

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

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public abstract class AbstractUserProfileTest extends AbstractTestRealmKeycloakTest {
protected static void configureAuthenticationSession(KeycloakSession session) {
configureSessionRealm(session);
Set<String> scopes = new HashSet<>();
scopes.add("customer");
configureAuthenticationSession(session, "client-a", scopes);
}
protected static void configureAuthenticationSession(KeycloakSession session, String clientId, Set<String> 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<String> scopes) {
return new AuthenticationSessionModel() {
@Override
public String getTabId() {
return null;
}
@Override
public RootAuthenticationSessionModel getParentSession() {
return null;
}
@Override
public Map<String, ExecutionStatus> 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<String> 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<String, String> 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<String, String> getClientNotes() {
return null;
}
@Override
public void clearClientNotes() {
}
@Override
public Set<String> getClientScopes() {
return scopes;
}
@Override
public void setClientScopes(Set<String> 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) {
}
};
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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<String, Object> 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<String, Object> 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<String, Object> validatorConfig = new HashMap<>();
validatorConfig.put("min", 4);
attribute.addValidation("length", validatorConfig);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String> roles = new ArrayList<>();
roles.add(ROLE_USER);
requirements.setRoles(roles);
attribute.setRequired(requirements);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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<String> roles = new ArrayList<>();
roles.add(UPConfigUtils.ROLE_ADMIN);
requirements.setRoles(roles);
attribute.setRequired(requirements);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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<String> scopes = new ArrayList<>();
scopes.add("client-a");
requirements.setScopes(scopes);
attribute.setRequired(requirements);
config.addAttribute(attribute);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, Object> 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));
}
}
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> attributes = new HashMap<>();
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes);
List<String> errors = new ArrayList<>();
assertFalse(profile.getAttributes().validate(UserModel.USERNAME, (Consumer<String>) 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<String>) 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<String, Object> 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<String> 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<String, Object> 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<String> 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"));
}
}

View file

@ -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 <velias@redhat.com>
*
*/
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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String, Map<String, Object>> validationConfig = config.getAttributes().get(1).getValidations();
validationConfig.put(" ",null);
List<String> 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());
}
}

View file

@ -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 <velias@redhat.com>
*
*/
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<String> 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<String> 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<String> 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"));
}
}

View file

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

View file

@ -0,0 +1,11 @@
{
"attributes": [
{
"name":"n1"
"name2" : ""
},
{
"name":"n2"
}
]
}