DefaultAttributes.java
/*
*
* * 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 static java.util.Collections.emptyList;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.jboss.logging.Logger;
import org.keycloak.common.util.CollectionUtil;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy;
import org.keycloak.storage.StorageId;
import org.keycloak.utils.StringUtil;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
import org.keycloak.validate.ValidatorConfig;
import org.keycloak.validate.validators.LengthValidator;
/**
* <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 class DefaultAttributes extends HashMap<String, List<String>> implements Attributes {
private static final Logger logger = Logger.getLogger(DefaultAttributes.class);
/**
* 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";
public static final String DEFAULT_MAX_LENGTH_ATTRIBUTES = "2048";
protected final UserProfileContext context;
protected final KeycloakSession session;
private final Map<String, AttributeMetadata> metadataByAttribute;
private final UPConfig upConfig;
protected final UserModel user;
private final Map<String, List<String>> unmanagedAttributes = new HashMap<>();
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(), profileMetadata);
this.upConfig = session.getProvider(UserProfileProvider.class).getConfiguration();
putAll(Collections.unmodifiableMap(normalizeAttributes(attributes)));
}
@Override
public boolean isReadOnly(String name) {
if (isReadOnlyFromMetadata(name) || isReadOnlyInternalAttribute(name)) {
return true;
}
if (!isManagedAttribute(name)) {
return !isAllowEditUnmanagedAttribute();
}
return getMetadata(name) == null;
}
private boolean isAllowEditUnmanagedAttribute() {
UnmanagedAttributePolicy unmanagedAttributesPolicy = upConfig.getUnmanagedAttributePolicy();
if (!isAllowUnmanagedAttribute()) {
return false;
}
switch (unmanagedAttributesPolicy) {
case ENABLED:
return true;
case ADMIN_EDIT:
return context.isAdminContext();
}
return false;
}
/**
* Checks whether an attribute is marked as read only by looking at its metadata.
*
* @param attributeName the attribute name
* @return @return {@code true} if the attribute is readonly. Otherwise, returns {@code false}
*/
protected boolean isReadOnlyFromMetadata(String attributeName) {
AttributeMetadata attributeMetadata = metadataByAttribute.get(attributeName);
if (attributeMetadata == null) {
return false;
}
return attributeMetadata.isReadOnly(createAttributeContext(attributeMetadata));
}
@Override
public boolean isRequired(String name) {
AttributeMetadata attributeMetadata = metadataByAttribute.get(name);
if (attributeMetadata == null) {
return false;
}
return attributeMetadata.isRequired(createAttributeContext(attributeMetadata));
}
@Override
public boolean validate(String name, Consumer<ValidationError>... 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(emptyList()));
metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(READ_ONLY_ATTRIBUTE_KEY))
.map(Collections::singletonList).orElse(emptyList()));
addDefaultValidators(name, metadatas);
Boolean result = null;
for (AttributeMetadata metadata : metadatas) {
AttributeContext attributeContext = createAttributeContext(attribute, metadata);
for (AttributeValidatorMetadata validator : metadata.getValidators()) {
ValidationContext vc = validator.validate(attributeContext);
if (vc.isValid()) {
continue;
}
if (user != null && metadata.isReadOnly(attributeContext)) {
List<String> value = user.getAttributeStream(name).filter(StringUtil::isNotBlank).collect(Collectors.toList());
List<String> newValue = attribute.getValue().stream().filter(StringUtil::isNotBlank).collect(Collectors.toList());
if (CollectionUtil.collectionEquals(value, newValue)) {
// allow update if the value was already wrong in the user and is read-only in this context
logger.debugf("User '%s' attribute '%s' has previous validation errors %s but is read-only in context %s.",
user.getUsername(), name, vc.getErrors(), attributeContext.getContext());
continue;
}
}
if (result == null) {
result = false;
}
if (listeners != null) {
for (ValidationError error : vc.getErrors()) {
for (Consumer<ValidationError> consumer : listeners) {
consumer.accept(error);
}
}
}
}
}
return result == null;
}
protected void addDefaultValidators(String name, List<AttributeMetadata> metadatas) {
addLengthValidatorIfNotSet(name, metadatas);
}
/**
* In case there are unmanaged attributes or attributes that don't have a length restrictions,
* add a default length restriction to avoid a denial of service by a caller.
*/
private void addLengthValidatorIfNotSet(String name, List<AttributeMetadata> metadatas) {
for (AttributeMetadata metadata : metadatas) {
for (AttributeValidatorMetadata validator : metadata.getValidators()) {
if (validator.getValidatorId().equals(LengthValidator.ID)) {
return;
}
}
}
AttributeMetadata am = new AttributeMetadata(name, -1);
Map<String, Object> vc = new HashMap<>();
vc.put(LengthValidator.KEY_MIN, "0");
vc.put(LengthValidator.KEY_MAX, DEFAULT_MAX_LENGTH_ATTRIBUTES);
am.addValidators(Collections.singletonList(new AttributeValidatorMetadata(LengthValidator.ID, new ValidatorConfig(vc))));
metadatas.add(am);
}
@Override
public List<String> get(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 Map<String, List<String>> getWritable() {
Map<String, List<String>> attributes = new HashMap<>(this);
for (String name : nameSet()) {
AttributeMetadata metadata = getMetadata(name);
RealmModel realm = session.getContext().getRealm();
if ((UserModel.USERNAME.equals(name) && realm.isRegistrationEmailAsUsername())
|| !isManagedAttribute(name)) {
continue;
}
if (metadata == null || !metadata.canEdit(createAttributeContext(metadata))) {
attributes.remove(name);
}
}
return attributes;
}
@Override
public AttributeMetadata getMetadata(String name) {
if (unmanagedAttributes.containsKey(name)) {
return createUnmanagedAttributeMetadata(name);
}
return Optional.ofNullable(metadataByAttribute.get(name))
.map(AttributeMetadata::clone)
.orElse(null);
}
@Override
public Map<String, List<String>> getReadable() {
Map<String, List<String>> attributes = new HashMap<>(this);
for (String name : nameSet()) {
AttributeMetadata metadata = getMetadata(name);
if (metadata == null) {
attributes.remove(name);
continue;
}
AttributeContext attributeContext = createAttributeContext(metadata);
if (!metadata.canView(attributeContext) || !metadata.isSelected(attributeContext)) {
attributes.remove(name);
}
}
return attributes;
}
@Override
public Map<String, List<String>> toMap() {
return Collections.unmodifiableMap(this);
}
private AttributeContext createAttributeContext(Entry<String, List<String>> attribute, AttributeMetadata metadata) {
return new AttributeContext(context, session, attribute, user, metadata, this);
}
private AttributeContext createAttributeContext(String attributeName, AttributeMetadata metadata) {
return new AttributeContext(context, session, createAttribute(attributeName), user, metadata, this);
}
protected AttributeContext createAttributeContext(AttributeMetadata metadata) {
return createAttributeContext(createAttribute(metadata.getName()), metadata);
}
private Map<String, AttributeMetadata> configureMetadata(List<AttributeMetadata> attributes, UserProfileMetadata profileMetadata) {
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))) {
metadatas.put(metadata.getName(), metadata);
}
}
metadatas.putAll(getUserStorageProviderMetadata(profileMetadata));
return metadatas;
}
private Map<String, AttributeMetadata> getUserStorageProviderMetadata(UserProfileMetadata profileMetadata) {
if (user == null || (StorageId.isLocalStorage(user.getId()) && user.getFederationLink() == null)) {
// new user or not a user from a storage provider other than local
return Collections.emptyMap();
}
String providerId = user.getFederationLink();
UserProvider userProvider = session.users();
if (userProvider instanceof UserProfileDecorator) {
// query the user provider from the source user storage provider for additional attribute metadata
UserProfileDecorator decorator = (UserProfileDecorator) userProvider;
return decorator.decorateUserProfile(providerId, profileMetadata).stream()
.collect(Collectors.toMap(AttributeMetadata::getName, Function.identity()));
}
return Collections.emptyMap();
}
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) {
for (Map.Entry<String, ?> entry : attributes.entrySet()) {
String name = entry.getKey();
if (!isSupportedAttribute(name)) {
if (!isManagedAttribute(name) && isAllowUnmanagedAttribute()) {
String normalizedName = normalizeAttributeName(name);
unmanagedAttributes.put(normalizedName, normalizeAttributeValues(normalizedName, entry.getValue()));
}
continue;
}
String normalizedName = normalizeAttributeName(name);
List<String> values = normalizeAttributeValues(normalizedName, entry.getValue());
newAttributes.put(normalizedName, Collections.unmodifiableList(values));
}
}
// the profile should always hold all attributes defined in the config
for (String attributeName : metadataByAttribute.keySet()) {
if (!isSupportedAttribute(attributeName) || newAttributes.containsKey(attributeName)) {
continue;
}
List<String> values = EMPTY_VALUE;
AttributeMetadata metadata = metadataByAttribute.get(attributeName);
if (user != null && isIncludeAttributeIfNotProvided(metadata)) {
values = normalizeAttributeValues(attributeName, user.getAttributes().getOrDefault(attributeName, EMPTY_VALUE));
}
newAttributes.put(attributeName, values);
}
if (user != null) {
List<String> username = newAttributes.getOrDefault(UserModel.USERNAME, emptyList());
if (username.isEmpty() && isReadOnly(UserModel.USERNAME)) {
setUserName(newAttributes, Collections.singletonList(user.getUsername()));
}
}
List<String> email = newAttributes.getOrDefault(UserModel.EMAIL, emptyList());
if (!email.isEmpty() && realm.isRegistrationEmailAsUsername()) {
setUserName(newAttributes, email);
if (user != null && isReadOnly(UserModel.EMAIL)) {
newAttributes.put(UserModel.EMAIL, Collections.singletonList(user.getEmail()));
setUserName(newAttributes, Collections.singletonList(user.getEmail()));
}
}
if (isAllowUnmanagedAttribute()) {
newAttributes.putAll(unmanagedAttributes);
}
return newAttributes;
}
private static String normalizeAttributeName(String name) {
if (name.startsWith(Constants.USER_ATTRIBUTES_PREFIX)) {
return name.substring(Constants.USER_ATTRIBUTES_PREFIX.length());
}
return name;
}
/**
* Intentionally kept to protected visibility to allow for custom normalization logic while clients adopt User Profile
*/
protected List<String> normalizeAttributeValues(String name, Object value) {
List<String> values;
if (value instanceof String) {
values = Collections.singletonList((String) value);
} else {
values = (List<String>) value;
}
Stream<String> valuesStream = Optional.ofNullable(values).orElse(EMPTY_VALUE).stream().filter(Objects::nonNull);
// do not normalize the username if a federated user because we need to respect the format from the external identity store
if ((UserModel.USERNAME.equals(name) && !isFederated()) || UserModel.EMAIL.equals(name)) {
valuesStream = valuesStream.map(KeycloakModelUtils::toLowerCaseSafe);
}
return valuesStream.collect(Collectors.toList());
}
protected boolean isAllowUnmanagedAttribute() {
UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy();
if (unmanagedAttributePolicy == null) {
// unmanaged attributes disabled
return false;
}
switch (unmanagedAttributePolicy) {
case ADMIN_EDIT:
case ADMIN_VIEW:
// unmanaged attributes only available through the admin context
return context.isAdminContext();
}
// allow unmanaged attributes if enabled to all contexts
return UnmanagedAttributePolicy.ENABLED.equals(unmanagedAttributePolicy);
}
protected void setUserName(Map<String, List<String>> newAttributes, List<String> values) {
newAttributes.put(UserModel.USERNAME, values);
}
protected boolean isIncludeAttributeIfNotProvided(AttributeMetadata metadata) {
return !metadata.canEdit(createAttributeContext(metadata));
}
/**
* <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
*/
protected boolean isSupportedAttribute(String name) {
if (READ_ONLY_ATTRIBUTE_KEY.equals(name)) {
return false;
}
if (isManagedAttribute(name)) {
return true;
}
return isReadOnlyInternalAttribute(name);
}
private boolean isManagedAttribute(String name) {
return metadataByAttribute.containsKey(normalizeAttributeName(name));
}
/**
* <p>Returns whether an attribute is read only based on the provider configuration (using provider config),
* usually related to internal attributes managed by the server.
*
* <p>For user-defined attributes, it should be preferable to use the user profile configuration.
*
* @param attributeName the attribute name
* @return {@code true} if the attribute is readonly. Otherwise, returns {@code false}
*/
protected 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;
}
AttributeContext attributeContext = createAttributeContext(attributeName, readonlyMetadata);
for (AttributeValidatorMetadata validator : readonlyMetadata.getValidators()) {
ValidationContext vc = validator.validate(attributeContext);
if (!vc.isValid()) {
return true;
}
}
return false;
}
@Override
public Map<String, List<String>> getUnmanagedAttributes() {
return unmanagedAttributes;
}
protected AttributeMetadata createUnmanagedAttributeMetadata(String name) {
return new AttributeMetadata(name, Integer.MAX_VALUE) {
final UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy();
@Override
public boolean canView(AttributeContext context) {
return canEdit(context)
|| (UnmanagedAttributePolicy.ADMIN_VIEW.equals(unmanagedAttributePolicy) && context.getContext().isAdminContext());
}
@Override
public boolean canEdit(AttributeContext context) {
return UnmanagedAttributePolicy.ENABLED.equals(unmanagedAttributePolicy)
|| (UnmanagedAttributePolicy.ADMIN_EDIT.equals(unmanagedAttributePolicy) && context.getContext().isAdminContext());
}
};
}
private boolean isFederated() {
return user != null && user.isFederated();
}
}