Refactoring user profile interfaces and consolidating user representation for both admin and account context

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2023-12-12 14:15:01 -03:00 committed by Marek Posolda
parent 1545b32a64
commit fa79b686b6
30 changed files with 760 additions and 609 deletions

View file

@ -17,128 +17,11 @@
package org.keycloak.representations.account;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import org.keycloak.json.StringListMapDeserializer;
import org.keycloak.representations.idm.UserProfileMetadata;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.keycloak.representations.idm.AbstractUserRepresentation;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class UserRepresentation {
public class UserRepresentation extends AbstractUserRepresentation {
private String id;
private String username;
private String firstName;
private String lastName;
private String email;
private boolean emailVerified;
private UserProfileMetadata userProfileMetadata;
@JsonDeserialize(using = StringListMapDeserializer.class)
private Map<String, List<String>> attributes;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public boolean isEmailVerified() {
return emailVerified;
}
public void setEmailVerified(boolean emailVerified) {
this.emailVerified = emailVerified;
}
public Map<String, List<String>> getAttributes() {
return attributes;
}
public void setAttributes(Map<String, List<String>> attributes) {
this.attributes = attributes;
}
public void singleAttribute(String name, String value) {
if (this.attributes == null) this.attributes=new HashMap<>();
attributes.put(name, (value == null ? new ArrayList<String>() : Arrays.asList(value)));
}
public String firstAttribute(String key) {
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;
}
public UserProfileMetadata getUserProfileMetadata() {
return userProfileMetadata;
}
public void setUserProfileMetadata(UserProfileMetadata userProfileMetadata) {
this.userProfileMetadata = userProfileMetadata;
}
}

View file

@ -0,0 +1,157 @@
/*
* Copyright 2023 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.representations.idm;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import org.keycloak.json.StringListMapDeserializer;
public abstract class AbstractUserRepresentation {
public static String USERNAME = "username";
public static String FIRST_NAME = "firstName";
public static String LAST_NAME = "lastName";
public static String EMAIL = "email";
public static String LOCALE = "locale";
protected String id;
protected String username;
protected String firstName;
protected String lastName;
protected String email;
protected Boolean emailVerified;
@JsonDeserialize(using = StringListMapDeserializer.class)
protected Map<String, List<String>> attributes;
private UserProfileMetadata userProfileMetadata;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Boolean isEmailVerified() {
return emailVerified;
}
public void setEmailVerified(Boolean emailVerified) {
this.emailVerified = emailVerified;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
/**
* Returns all the attributes set to this user except the root attributes.
*
* @return the user attributes.
*/
public Map<String, List<String>> getAttributes() {
return attributes;
}
/**
* Returns all the user attributes including the root attributes.
*
* @return all the user attributes.
*/
@JsonIgnore
public Map<String, List<String>> getRawAttributes() {
Map<String, List<String>> attrs = new HashMap<>(Optional.ofNullable(attributes).orElse(new HashMap<>()));
if (username != null)
attrs.put(USERNAME, Collections.singletonList(getUsername()));
else
attrs.remove(USERNAME);
if (email != null)
attrs.put(EMAIL, Collections.singletonList(getEmail()));
else
attrs.remove(EMAIL);
if (lastName != null)
attrs.put(LAST_NAME, Collections.singletonList(getLastName()));
if (firstName != null)
attrs.put(FIRST_NAME, Collections.singletonList(getFirstName()));
return attrs;
}
public void setAttributes(Map<String, List<String>> attributes) {
this.attributes = attributes;
}
@SuppressWarnings("unchecked")
public <R extends AbstractUserRepresentation> R singleAttribute(String name, String value) {
if (this.attributes == null) this.attributes=new HashMap<>();
attributes.put(name, (value == null ? Collections.emptyList() : Arrays.asList(value)));
return (R) this;
}
public String firstAttribute(String key) {
return this.attributes == null ? null : this.attributes.get(key) == null ? null : this.attributes.get(key).isEmpty()? null : this.attributes.get(key).get(0);
}
public void setUserProfileMetadata(UserProfileMetadata userProfileMetadata) {
this.userProfileMetadata = userProfileMetadata;
}
public UserProfileMetadata getUserProfileMetadata() {
return userProfileMetadata;
}
}

View file

@ -17,14 +17,7 @@
package org.keycloak.representations.idm;
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;
import java.util.Map;
import java.util.Set;
@ -32,24 +25,16 @@ import java.util.Set;
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class UserRepresentation {
public class UserRepresentation extends AbstractUserRepresentation{
protected String self; // link
protected String id;
protected String origin;
protected Long createdTimestamp;
protected String username;
protected Boolean enabled;
protected Boolean totp;
protected Boolean emailVerified;
protected String firstName;
protected String lastName;
protected String email;
protected String federationLink;
protected String serviceAccountClientId; // For rep, it points to clientId (not DB ID)
@JsonDeserialize(using = StringListMapDeserializer.class)
protected Map<String, List<String>> attributes;
protected List<CredentialRepresentation> credentials;
protected Set<String> disableableCredentialTypes;
protected List<String> requiredActions;
@ -66,7 +51,6 @@ public class UserRepresentation {
protected List<String> groups;
private Map<String, Boolean> access;
private UserProfileMetadata userProfileMetadata;
public String getSelf() {
return self;
@ -76,14 +60,6 @@ public class UserRepresentation {
this.self = self;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public Long getCreatedTimestamp() {
return createdTimestamp;
}
@ -92,38 +68,6 @@ public class UserRepresentation {
this.createdTimestamp = createdTimestamp;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Boolean isEnabled() {
return enabled;
}
@ -142,32 +86,6 @@ public class UserRepresentation {
this.totp = totp;
}
public Boolean isEmailVerified() {
return emailVerified;
}
public void setEmailVerified(Boolean emailVerified) {
this.emailVerified = emailVerified;
}
public Map<String, List<String>> getAttributes() {
return attributes;
}
public void setAttributes(Map<String, List<String>> attributes) {
this.attributes = attributes;
}
public UserRepresentation singleAttribute(String name, String value) {
if (this.attributes == null) this.attributes=new HashMap<>();
attributes.put(name, (value == null ? new ArrayList<String>() : Arrays.asList(value)));
return this;
}
public String firstAttribute(String key) {
return this.attributes == null ? null : this.attributes.get(key) == null ? null : this.attributes.get(key).isEmpty()? null : this.attributes.get(key).get(0);
}
public List<CredentialRepresentation> getCredentials() {
return credentials;
}
@ -289,36 +207,4 @@ 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;
}
public void setUserProfileMetadata(UserProfileMetadata userProfileMetadata) {
this.userProfileMetadata = userProfileMetadata;
}
public UserProfileMetadata getUserProfileMetadata() {
return userProfileMetadata;
}
}

View file

@ -32,8 +32,20 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
public class UPConfig {
public enum UnmanagedAttributePolicy {
/**
* Unmanaged attributes are enabled and available from any context.
*/
ENABLED,
/**
* Unmanaged attributes are only available as read-only and only through the management interfaces.
*/
ADMIN_VIEW,
/**
* Unmanaged attributes are only available as read-write and only through the management interfaces.
*/
ADMIN_EDIT
}

View file

@ -29,3 +29,17 @@ Proceed to https://www.keycloak.org/server/features[Enabling and disabling featu
The Keycloak CR now includes an `startOptimized` field, which may be used to override the default assumption about whether to use the `--optimized` flag for the start command.
As a result, you can use the CR to configure build time options also when a custom Keycloak image is used.
= Breaking changes to the User Profile SPI
In this release, there are changes to the User Profile SPI that might impact existing implementations based on this SPI. For more details, check the
link:{upgradingguide_link}[{upgradingguide_name}].
= Changes to the user representation in both Admin API and Account contexts
In this release, we are encapsulating the root user attributes (such as `username`, `email`, `firstName`, `lastName`, and `locale`) by moving them to a base/abstract class in order to align how these attributes
are marshalled and unmarshalled when using both Admin and Account REST APIs.
This strategy provides consistency in how attributes are managed by clients and makes sure they conform to the user profile
configuration set to a realm.
For more details, see link:{upgradingguide_link}[{upgradingguide_name}].

View file

@ -18,3 +18,28 @@ import AuthZ from 'keycloak-js/authz';
The `spi-truststore-file-*` options and the truststore related options `https-trust-store-*` are deprecated, please use the new default location for truststore material, `conf/truststores`, or specify your desired paths via the `truststore-paths` option. For details refer to the relevant https://www.keycloak.org/server/keycloak-truststore[guide].
The `tls-hostname-verifier` property should be used instead of the `spi-truststore-file-hostname-verification-policy` property.
= Breaking changes to the User Profile SPI
If you are using the User Profile SPI in your extension, you might be impacted by the API changes introduced in this release.
The `org.keycloak.userprofile.Attributes` interface includes the following changes:
* Method `getValues` was renamed to `get` to make it more aligned with the same operation from a regular Java `Map`
* Method `isRootAttribute` was moved to the utility class `org.keycloak.userprofile.UserProfileUtil.isRootAttribute`
* Method `getFirstValue` was renamed to `getFirst` to make it less verbose
* Method `getReadable(boolean)` was removed and now all attributes (including root attributes) are returned whenever they have read rights.
= Changes to the user representation in both Admin API and Account contexts
Both `org.keycloak.representations.idm.UserRepresentation` and `org.keycloak.representations.account.UserRepresentation` representation classes have changed
so that the root user attributes (such as `username`, `email`, `firstName`, `lastName`, and `locale`) have a consistent representation when fetching or sending
the representation payload to the Admin and Account APIS, respectively.
The `username`, `email`, `firstName`, `lastName`, and `locale` attributes were moved to a new `org.keycloak.representations.idm.AbstractUserRepresentation` base class.
Also the `getAttributes` method is targeted for representing only custom attributes, so you should not expect any root attribute in the map returned by this method. This method is
mainly targeted for clients when updating or fetching any custom attribute for a give user.
In order to resolve all the attributes including the root attributes, a new `getRawAttributes` method was added so that the resulting map also includes the root attributes. However,
this method is not available from the representation payload and it is targeted to be used by the server when managing user profiles.

View file

@ -1,11 +1,15 @@
package org.keycloak.admin.ui.rest;
import static java.util.Collections.emptyList;
import static java.util.Optional.ofNullable;
import static org.keycloak.userprofile.UserProfileContext.USER_API;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@ -18,6 +22,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.utils.StringUtil;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
@ -42,7 +47,7 @@ public class UserResource {
if (provider.isEnabled(realm)) {
UserProfile profile = provider.create(USER_API, user);
Map<String, List<String>> managedAttributes = profile.getAttributes().getReadable(false);
Map<String, List<String>> managedAttributes = profile.getAttributes().getReadable();
Map<String, List<String>> attributes = new HashMap<>(user.getAttributes());
UPConfig upConfig = provider.getConfiguration();
@ -56,10 +61,10 @@ public class UserResource {
attributes.remove(UserModel.USERNAME);
attributes.remove(UserModel.EMAIL);
attributes.remove(UserModel.FIRST_NAME);
attributes.remove(UserModel.LAST_NAME);
return attributes;
return attributes.entrySet().stream()
.filter(entry -> ofNullable(entry.getValue()).orElse(emptyList()).stream().anyMatch(StringUtil::isNotBlank))
.collect(Collectors.toMap(Entry::getKey, Entry::getValue));
}
return Collections.emptyMap();

View file

@ -19,6 +19,8 @@
package org.keycloak.userprofile;
import static java.util.Collections.emptyList;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@ -29,6 +31,8 @@ import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
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;
@ -38,6 +42,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy;
import org.keycloak.utils.StringUtil;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
@ -68,7 +73,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
private final Map<String, AttributeMetadata> metadataByAttribute;
private final UPConfig upConfig;
protected final UserModel user;
private Map<String, List<String>> unmanagedAttributes = new HashMap<>();
private final Map<String, List<String>> unmanagedAttributes = new HashMap<>();
public DefaultAttributes(UserProfileContext context, Map<String, ?> attributes, UserModel user,
UserProfileMetadata profileMetadata,
@ -82,28 +87,28 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
}
@Override
public boolean isReadOnly(String attributeName) {
if (!isManagedAttribute(attributeName)) {
public boolean isReadOnly(String name) {
if (!isManagedAttribute(name)) {
return !isAllowEditUnmanagedAttribute();
}
if (UserModel.USERNAME.equals(attributeName)) {
if (UserModel.USERNAME.equals(name)) {
if (isServiceAccountUser()) {
return true;
}
}
if (UserModel.EMAIL.equals(attributeName)) {
if (UserModel.EMAIL.equals(name)) {
if (isServiceAccountUser()) {
return false;
}
}
if (isReadOnlyFromMetadata(attributeName) || isReadOnlyInternalAttribute(attributeName)) {
if (isReadOnlyFromMetadata(name) || isReadOnlyInternalAttribute(name)) {
return true;
}
return getMetadata(attributeName) == null;
return getMetadata(name) == null;
}
private boolean isAllowEditUnmanagedAttribute() {
@ -156,9 +161,9 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
List<AttributeMetadata> metadatas = new ArrayList<>();
metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(attribute.getKey()))
.map(Collections::singletonList).orElse(Collections.emptyList()));
.map(Collections::singletonList).orElse(emptyList()));
metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(READ_ONLY_ATTRIBUTE_KEY))
.map(Collections::singletonList).orElse(Collections.emptyList()));
.map(Collections::singletonList).orElse(emptyList()));
Boolean result = null;
@ -172,12 +177,15 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
continue;
}
if (user != null && metadata.isReadOnly(attributeContext)
&& CollectionUtil.collectionEquals(user.getAttributeStream(name).collect(Collectors.toList()), attribute.getValue())) {
// allow update if the value was already wrong in the user and is read-only in this context
logger.warnf("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 (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) {
@ -198,7 +206,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
}
@Override
public List<String> getValues(String name) {
public List<String> get(String name) {
return getOrDefault(name, EMPTY_VALUE);
}
@ -236,21 +244,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
@Override
public AttributeMetadata getMetadata(String name) {
if (unmanagedAttributes.containsKey(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) && UserProfileContext.USER_API.equals(context.getContext()));
}
@Override
public boolean canEdit(AttributeContext context) {
return UnmanagedAttributePolicy.ENABLED.equals(unmanagedAttributePolicy)
|| (UnmanagedAttributePolicy.ADMIN_EDIT.equals(unmanagedAttributePolicy) && UserProfileContext.USER_API.equals(context.getContext()));
}
};
return createUnmanagedAttributeMetadata(name);
}
return Optional.ofNullable(metadataByAttribute.get(name))
@ -265,9 +259,14 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
for (String name : nameSet()) {
AttributeMetadata metadata = getMetadata(name);
if (metadata == null
|| !metadata.canView(createAttributeContext(metadata))
|| !metadata.isSelected(createAttributeContext(metadata))) {
if (metadata == null) {
attributes.remove(name);
continue;
}
AttributeContext attributeContext = createAttributeContext(metadata);
if (!metadata.canView(attributeContext) || !metadata.isSelected(attributeContext)) {
attributes.remove(name);
}
}
@ -277,7 +276,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
@Override
public Map<String, List<String>> toMap() {
return this;
return Collections.unmodifiableMap(this);
}
protected boolean isServiceAccountUser() {
@ -342,7 +341,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
if (!isSupportedAttribute(key)) {
if (!isManagedAttribute(key) && isAllowUnmanagedAttribute()) {
unmanagedAttributes.put(key, (List<String>) entry.getValue());
unmanagedAttributes.put(key, normalizeAttributeValues(key, entry.getValue()));
}
continue;
}
@ -351,18 +350,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
key = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length());
}
Object value = entry.getValue();
List<String> values;
if (value instanceof String) {
values = Collections.singletonList((String) value);
} else {
values = (List<String>) value;
}
if (UserModel.USERNAME.equals(key) || UserModel.EMAIL.equals(key)) {
values = values.stream().map(KeycloakModelUtils::toLowerCaseSafe).collect(Collectors.toList());
}
List<String> values = normalizeAttributeValues(key, entry.getValue());
newAttributes.put(key, Collections.unmodifiableList(values));
}
@ -378,28 +366,24 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
AttributeMetadata metadata = metadataByAttribute.get(attributeName);
if (user != null && isIncludeAttributeIfNotProvided(metadata)) {
values = user.getAttributes().getOrDefault(attributeName, EMPTY_VALUE);
values = normalizeAttributeValues(attributeName, user.getAttributes().getOrDefault(attributeName, EMPTY_VALUE));
}
newAttributes.put(attributeName, values);
}
if (user != null) {
List<String> username = newAttributes.getOrDefault(UserModel.USERNAME, Collections.emptyList());
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, Collections.emptyList());
List<String> email = newAttributes.getOrDefault(UserModel.EMAIL, emptyList());
if (!email.isEmpty() && realm.isRegistrationEmailAsUsername()) {
List<String> lowerCaseEmailList = email.stream()
.filter(Objects::nonNull)
.collect(Collectors.toList());
setUserName(newAttributes, lowerCaseEmailList);
setUserName(newAttributes, email);
if (user != null && isReadOnly(UserModel.EMAIL)) {
newAttributes.put(UserModel.EMAIL, Collections.singletonList(user.getEmail()));
@ -414,6 +398,24 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return newAttributes;
}
private 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);
if (UserModel.USERNAME.equals(name) || UserModel.EMAIL.equals(name)) {
valuesStream = valuesStream.map(KeycloakModelUtils::toLowerCaseSafe);
}
return valuesStream.collect(Collectors.toList());
}
private boolean isAllowUnmanagedAttribute() {
UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy();
@ -466,12 +468,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return true;
}
if (isReadOnlyInternalAttribute(name)) {
return true;
}
// checks whether the attribute is a core attribute
return isRootAttribute(name);
return isReadOnlyInternalAttribute(name);
}
private boolean isManagedAttribute(String name) {
@ -511,4 +508,22 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
public Map<String, List<String>> getUnmanagedAttributes() {
return unmanagedAttributes;
}
private 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) && UserProfileContext.USER_API.equals(context.getContext()));
}
@Override
public boolean canEdit(AttributeContext context) {
return UnmanagedAttributePolicy.ENABLED.equals(unmanagedAttributePolicy)
|| (UnmanagedAttributePolicy.ADMIN_EDIT.equals(unmanagedAttributePolicy) && UserProfileContext.USER_API.equals(context.getContext()));
}
};
}
}

View file

@ -19,6 +19,10 @@
package org.keycloak.userprofile;
import static org.keycloak.userprofile.UserProfileUtil.createUserProfileMetadata;
import static org.keycloak.userprofile.UserProfileUtil.isRootAttribute;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@ -31,7 +35,10 @@ import java.util.stream.Collectors;
import org.keycloak.common.util.CollectionUtil;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.idm.AbstractUserRepresentation;
import org.keycloak.storage.ReadOnlyException;
import org.keycloak.utils.StringUtil;
@ -45,10 +52,11 @@ import org.keycloak.utils.StringUtil;
*/
public final class DefaultUserProfile implements UserProfile {
protected final UserProfileMetadata metadata;
private final UserProfileMetadata metadata;
private final Function<Attributes, UserModel> userSupplier;
private final Attributes attributes;
private final KeycloakSession session;
private final boolean isUserProfileEnabled;
private boolean validated;
private UserModel user;
@ -59,6 +67,8 @@ public final class DefaultUserProfile implements UserProfile {
this.attributes = attributes;
this.user = user;
this.session = session;
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
isUserProfileEnabled = provider.isEnabled(session.getContext().getRealm());
}
@Override
@ -144,16 +154,27 @@ public final class DefaultUserProfile implements UserProfile {
attrsToRemove.removeAll(attributes.nameSet());
for (String attr : attrsToRemove) {
if (attributes.isReadOnly(attr)) {
for (String name : attrsToRemove) {
if (attributes.isReadOnly(name)) {
continue;
}
List<String> currentValue = user.getAttributeStream(attr).filter(Objects::nonNull).collect(Collectors.toList());
user.removeAttribute(attr);
List<String> currentValue = user.getAttributeStream(name).filter(Objects::nonNull).collect(Collectors.toList());
if (isRootAttribute(name)) {
if (UserModel.FIRST_NAME.equals(name)) {
user.setFirstName(null);
} else if (UserModel.LAST_NAME.equals(name)) {
user.setLastName(null);
} else if (UserModel.LOCALE.equals(name)) {
user.removeAttribute(name);
}
} else {
user.removeAttribute(name);
}
for (AttributeChangeListener listener : changeListener) {
listener.onChange(attr, user, currentValue);
listener.onChange(name, user, currentValue);
}
}
}
@ -168,11 +189,88 @@ public final class DefaultUserProfile implements UserProfile {
}
private boolean isCustomAttribute(String name) {
return !getAttributes().isRootAttribute(name);
return !isRootAttribute(name);
}
@Override
public Attributes getAttributes() {
return attributes;
}
@Override
public <R extends AbstractUserRepresentation> R toRepresentation() {
if (user == null) {
throw new IllegalStateException("Can not create the representation because the user is not yet created");
}
R rep = createUserRepresentation();
Map<String, List<String>> readable = attributes.getReadable();
Map<String, List<String>> attributesRep = new HashMap<>(readable);
// all the attributes here have read access and might be available in the representation
for (String name : readable.keySet()) {
List<String> values = attributesRep.getOrDefault(name, Collections.emptyList())
.stream().filter(StringUtil::isNotBlank)
.collect(Collectors.toList());
if (values.isEmpty()) {
// make sure empty attributes are not in the representation
attributesRep.remove(name);
continue;
}
if (isRootAttribute(name)) {
if (UserModel.LOCALE.equals(name)) {
// local is a special root attribute as it does not have a field in the user representation
// it should be available as a regular attribute if set
continue;
}
boolean isUnmanagedAttribute = isUserProfileEnabled && metadata.getAttribute(name).isEmpty();
String value = isUnmanagedAttribute ? null : values.stream().findFirst().orElse(null);
if (UserModel.USERNAME.equals(name)) {
rep.setUsername(value);
} else if (UserModel.EMAIL.equals(name)) {
rep.setEmail(value);
rep.setEmailVerified(user.isEmailVerified());
} else if (UserModel.FIRST_NAME.equals(name)) {
rep.setFirstName(value);
} else if (UserModel.LAST_NAME.equals(name)) {
rep.setLastName(value);
}
// we don't have root attributes as a regular attribute in the representation as they have their own fields
attributesRep.remove(name);
}
}
rep.setId(user.getId());
rep.setAttributes(attributesRep.isEmpty() ? null : attributesRep);
rep.setUserProfileMetadata(createUserProfileMetadata(session, this));
return rep;
}
@SuppressWarnings("unchecked")
private <R extends AbstractUserRepresentation> R createUserRepresentation() {
UserProfileContext context = metadata.getContext();
R rep;
if (UserProfileContext.USER_API.equals(context)) {
RealmModel realm = session.getContext().getRealm();
rep = (R) ModelToRepresentation.toRepresentation(session, realm, user);
} else {
// by default, we build the simplest representation without exposing much information about users
rep = (R) new org.keycloak.representations.account.UserRepresentation();
}
// reset the root attribute values so that they are calculated based on the user profile configuration
rep.setUsername(null);
rep.setEmail(null);
rep.setFirstName(null);
rep.setLastName(null);
return rep;
}
}

View file

@ -17,22 +17,38 @@
package org.keycloak.userprofile;
import java.util.function.BiConsumer;
import java.util.List;
import java.util.Map;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.AbstractUserRepresentation;
/**
* <p>An interface providing as an entry point for managing users.
* <p>An interface that serves an entry point for managing users and their attributes.
*
* <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.
* <p>A {@code UserProfile} provides methods for creating, and updating users as well as for accessing their attributes.
* All its operations are based the {@link UserProfileContext}. By taking the context into account, the state and behavior of
* {@link UserProfile} instances depend on the context they are associated with where creating, updating, validating, and
* accessing the attribute set of a user is based on the configuration (see {@link org.keycloak.representations.userprofile.config.UPConfig})
* and the constraints associated with a given context.
*
* <p>The {@link UserProfileContext} represents the different areas in Keycloak where users, and their attributes are managed.
* 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}:
*
* <p>A {@code UserProfile} instance can be obtained through the {@link UserProfileProvider}.
* <pre> {@code
* // resolve an existing user
* UserModel user = getExistingUser();
* // obtain the user profile provider
* UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
* // create a instance for managing the user profile through the USER_API context
* UserProfile profile = provider.create(USER_API, user);
* }</pre>
*
* <p>The {@link UserProfileProvider} provides different methods for creating {@link UserProfile} instances, each one
* target for a specific scenario such as creating a new user, updating an existing one, or only for accessing the attributes
* for an existing user as shown in the above example.
*
* @see UserProfileContext
* @see UserProfileProvider
@ -69,20 +85,23 @@ public interface UserProfile {
void update(boolean removeAttributes, AttributeChangeListener... changeListener) throws ValidationException;
/**
* <p>The same as {@link #update(boolean, BiConsumer[])} but forcing the removal of attributes.
* <p>The same as {@link #update(boolean, AttributeChangeListener...)}} 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(AttributeChangeListener... changeListener) throws ValidationException, RuntimeException {
default void update(AttributeChangeListener... changeListener) throws ValidationException {
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}.
* the same from the {@link UserModel} as they are based on the configurations set in the {@link org.keycloak.representations.userprofile.config.UPConfig} and
* the context this instance is based on.
*
* @return the attributes associated with this instance.
*/
Attributes getAttributes();
<R extends AbstractUserRepresentation> R toRepresentation();
}

View file

@ -20,11 +20,23 @@
package org.keycloak.userprofile;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.provider.ConfiguredProvider;
import org.keycloak.representations.idm.UserProfileAttributeGroupMetadata;
import org.keycloak.representations.idm.UserProfileAttributeMetadata;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.representations.userprofile.config.UPGroup;
import org.keycloak.validate.Validators;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -82,4 +94,70 @@ public class UserProfileUtil {
return true;
}
}
/**
* Returns whether the attribute with the given {@code name} is a root attribute.
*
* @param name the attribute name
* @return
*/
public static boolean isRootAttribute(String name) {
return UserModel.USERNAME.equals(name)
|| UserModel.EMAIL.equals(name)
|| UserModel.FIRST_NAME.equals(name)
|| UserModel.LAST_NAME.equals(name)
|| UserModel.LOCALE.equals(name);
}
public static org.keycloak.representations.idm.UserProfileMetadata createUserProfileMetadata(KeycloakSession session, UserProfile profile) {
Attributes profileAttributes = profile.getAttributes();
Map<String, List<String>> am = profileAttributes.getReadable();
if(am == null)
return null;
Map<String, List<String>> unmanagedAttributes = profileAttributes.getUnmanagedAttributes();
List<UserProfileAttributeMetadata> attributes = am.keySet().stream()
.map(profileAttributes::getMetadata)
.filter(Objects::nonNull)
.filter(attributeMetadata -> !unmanagedAttributes.containsKey(attributeMetadata.getName()))
.sorted(Comparator.comparingInt(AttributeMetadata::getGuiOrder))
.map(sam -> toRestMetadata(sam, session, profile))
.collect(Collectors.toList());
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UPConfig config = provider.getConfiguration();
List<UserProfileAttributeGroupMetadata> groups = config.getGroups().stream().map(new Function<UPGroup, UserProfileAttributeGroupMetadata>() {
@Override
public UserProfileAttributeGroupMetadata apply(UPGroup upGroup) {
return new UserProfileAttributeGroupMetadata(upGroup.getName(), upGroup.getDisplayHeader(), upGroup.getDisplayDescription(), upGroup.getAnnotations());
}
}).collect(Collectors.toList());
return new org.keycloak.representations.idm.UserProfileMetadata(attributes, groups);
}
private static UserProfileAttributeMetadata toRestMetadata(AttributeMetadata am, KeycloakSession session, UserProfile profile) {
String group = null;
if (am.getAttributeGroupMetadata() != null) {
group = am.getAttributeGroupMetadata().getName();
}
return new UserProfileAttributeMetadata(am.getName(),
am.getAttributeDisplayName(),
profile.getAttributes().isRequired(am.getName()),
profile.getAttributes().isReadOnly(am.getName()),
group,
am.getAnnotations(),
toValidatorMetadata(am, session));
}
private static Map<String, Map<String, Object>> toValidatorMetadata(AttributeMetadata am, KeycloakSession session){
// we return only validators which are instance of ConfiguredProvider. Others are expected as internal.
return am.getValidators() == null ? null : am.getValidators().stream()
.filter(avm -> (Validators.validator(session, avm.getValidatorId()) instanceof ConfiguredProvider))
.collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig));
}
}

View file

@ -19,20 +19,38 @@
package org.keycloak.userprofile;
import static java.util.Optional.ofNullable;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.keycloak.models.UserModel;
import org.keycloak.validate.ValidationError;
/**
* <p>This interface wraps the attributes associated with a user profile. Different operations are provided to access and
* manage these attributes.
*
* <p>Attributes are classified as:</p>
* <ul>
* <li>Managed
* <li>Unmanaged
* </ul>
*
* <p>A <i>managed</i> attribute is any attribute defined in the user profile configuration. Therefore, they are known by
* the server and can be managed accordingly.
*
* <p>A <i>unmanaged</i> attributes is any attribute <b>not</b> defined in the user profile configuration. Therefore, the server
* does not know about them and they cannot use capabilities provided by the server. However, they can still be managed by
* administrators by setting any of the {@link org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy}.
*
* <p>Any attribute available from this interface has a corresponding {@link AttributeMetadata}</p>. The metadata describes
* the settings for a given attribute so that the server can communicate to a caller the constraints
* (see {@link org.keycloak.representations.userprofile.config.UPConfig} and the availability of the attribute in
* a given {@link UserProfileContext}.
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public interface Attributes {
@ -49,8 +67,8 @@ public interface Attributes {
*
* @return the first value
*/
default String getFirstValue(String name) {
List<String> values = getValues(name);
default String getFirst(String name) {
List<String> values = ofNullable(get(name)).orElse(List.of());
if (values.isEmpty()) {
return null;
@ -66,16 +84,16 @@ public interface Attributes {
*
* @return the attribute values
*/
List<String> getValues(String name);
List<String> get(String name);
/**
* Checks whether an attribute is read-only.
*
* @param key
* @param name the attribute name
*
* @return
* @return {@code true} if the attribute is read-only. Otherwise, {@code false}
*/
boolean isReadOnly(String key);
boolean isReadOnly(String name);
/**
* Validates the attribute with the given {@code name}.
@ -105,7 +123,7 @@ public interface Attributes {
Set<String> nameSet();
/**
* Returns all attributes that can be written.
* Returns all the attributes with read-write permissions in a particular {@link UserProfileContext}.
*
* @return the attributes
*/
@ -131,52 +149,23 @@ public interface Attributes {
boolean isRequired(String name);
/**
* Similar to {{@link #getReadable(boolean)}} but with the possibility to add or remove
* the root attributes.
* Returns only the attributes that have read permissions in a particular {@link UserProfileContext}.
*
* @param includeBuiltin if the root attributes should be included.
* @return the attributes with read/write permission.
*/
default Map<String, List<String>> getReadable(boolean includeBuiltin) {
return getReadable().entrySet().stream().filter(entry -> {
if (includeBuiltin) {
return true;
}
if (isRootAttribute(entry.getKey())) {
if (UserModel.LOCALE.equals(entry.getKey()) && !entry.getValue().isEmpty()) {
// locale is different form of built-in attribute in the sense it is related to a
// specific feature (i18n) and does not have a top-level attribute in the user representation
// the locale should be available from the attribute map if not empty
return true;
}
return false;
}
return true;
}).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
/**
* Returns only the attributes that have read/write permissions.
*
* @return the attributes with read/write permission.
* @return the attributes with read permission.
*/
Map<String, List<String>> getReadable();
/**
* Returns whether the attribute with the given {@code name} is a root attribute.
* Returns the attributes as a {@link Map} that are accessible to a particular {@link UserProfileContext}.
*
* @param name the attribute name
* @return
* @return a map with all the attributes
*/
default boolean isRootAttribute(String name) {
return UserModel.USERNAME.equals(name)
|| UserModel.EMAIL.equals(name)
|| UserModel.FIRST_NAME.equals(name)
|| UserModel.LAST_NAME.equals(name)
|| UserModel.LOCALE.equals(name);
}
Map<String, List<String>> toMap();
/**
* Returns a {@link Map} holding any unmanaged attribute.
*
* @return a map with any unmanaged attribute
*/
Map<String, List<String>> getUnmanagedAttributes();
}

View file

@ -21,7 +21,7 @@ package org.keycloak.userprofile;
/**
* <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.
* available here representing the different areas 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.
@ -30,16 +30,39 @@ package org.keycloak.userprofile;
*/
public enum UserProfileContext {
/**
* In this context, a user profile is managed by themselves during an authentication flow such as when updating the user profile.
*/
UPDATE_PROFILE(true),
/**
* In this context, a user profile is managed through the management interface such as the Admin API.
*/
USER_API(false),
/**
* In this context, a user profile is managed by themselves through the account console.
*/
ACCOUNT(true),
/**
* In this context, a user profile is managed by themselves when authenticating through a broker.
*/
IDP_REVIEW(false),
/**
* In this context, a user profile is managed by themselves when registering to a realm.
*/
REGISTRATION(false),
/**
* In this context, a user profile is managed by themselves when updating their email through an application initiated action.
*/
UPDATE_EMAIL(false);
protected boolean resetEmailVerified;
private boolean resetEmailVerified;
private UserProfileContext(boolean resetEmailVerified){
UserProfileContext(boolean resetEmailVerified){
this.resetEmailVerified = resetEmailVerified;
}

View file

@ -166,7 +166,7 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
profile.update((attributeName, userModel, oldValue) -> {
if (attributeName.equals(UserModel.EMAIL)) {
context.getAuthenticationSession().setAuthNote(UPDATE_PROFILE_EMAIL_CHANGED, "true");
event.clone().event(EventType.UPDATE_EMAIL).detail(Details.CONTEXT, UserProfileContext.IDP_REVIEW.name()).detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, profile.getAttributes().getFirstValue(UserModel.EMAIL)).success();
event.clone().event(EventType.UPDATE_EMAIL).detail(Details.CONTEXT, UserProfileContext.IDP_REVIEW.name()).detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, profile.getAttributes().getFirst(UserModel.EMAIL)).success();
}
});
} catch (ValidationException pve) {
@ -187,7 +187,7 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
logger.debugf("Profile updated successfully after first authentication with identity provider '%s' for broker user '%s'.", brokerContext.getIdpConfig().getAlias(), userCtx.getUsername());
String newEmail = profile.getAttributes().getFirstValue(UserModel.EMAIL);
String newEmail = profile.getAttributes().getFirst(UserModel.EMAIL);
event.detail(Details.UPDATED_EMAIL, newEmail);

View file

@ -39,6 +39,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.ValidationException;
@ -71,11 +72,11 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
context.getEvent().detail(Details.REGISTER_METHOD, "form");
UserProfile profile = getOrCreateUserProfile(context, formData);
String email = profile.getAttributes().getFirstValue(UserModel.EMAIL);
String username = profile.getAttributes().getFirstValue(UserModel.USERNAME);
String firstName = profile.getAttributes().getFirstValue(UserModel.FIRST_NAME);
String lastName = profile.getAttributes().getFirstValue(UserModel.LAST_NAME);
Attributes attributes = profile.getAttributes();
String email = attributes.getFirst(UserModel.EMAIL);
String username = attributes.getFirst(UserModel.USERNAME);
String firstName = attributes.getFirst(UserModel.FIRST_NAME);
String lastName = attributes.getFirst(UserModel.LAST_NAME);
context.getEvent().detail(Details.EMAIL, email);
context.getEvent().detail(Details.USERNAME, username);
@ -92,7 +93,7 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
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));
context.getEvent().detail(Details.EMAIL, attributes.getFirst(UserModel.EMAIL));
}
if (pve.hasError(Messages.EMAIL_EXISTS)) {

View file

@ -168,7 +168,7 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor
public static void updateEmailNow(EventBuilder event, UserModel user, UserProfile emailUpdateValidationResult) {
String oldEmail = user.getEmail();
String newEmail = emailUpdateValidationResult.getAttributes().getFirstValue(UserModel.EMAIL);
String newEmail = emailUpdateValidationResult.getAttributes().getFirst(UserModel.EMAIL);
event.event(EventType.UPDATE_EMAIL).detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, newEmail);
emailUpdateValidationResult.update(false, new EventAuditingAttributeChangeListener(emailUpdateValidationResult, event));
}

View file

@ -24,12 +24,9 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -66,12 +63,9 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.provider.ConfiguredProvider;
import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation;
import org.keycloak.representations.idm.UserProfileAttributeMetadata;
import org.keycloak.representations.idm.UserProfileMetadata;
import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
@ -80,7 +74,6 @@ import org.keycloak.services.managers.Auth;
import org.keycloak.services.managers.UserConsentManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.account.resources.ResourcesService;
import org.keycloak.services.resources.admin.UserProfileResource;
import org.keycloak.services.util.ResolveRelative;
import org.keycloak.storage.ReadOnlyException;
import org.keycloak.theme.Theme;
@ -92,8 +85,6 @@ import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.EventAuditingAttributeChangeListener;
import org.keycloak.userprofile.ValidationException;
import org.keycloak.userprofile.ValidationException.Error;
import org.keycloak.utils.GroupUtils;
import org.keycloak.validate.Validators;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -142,38 +133,17 @@ public class AccountRestService {
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
UserModel user = auth.getUser();
UserRepresentation rep = new UserRepresentation();
rep.setId(user.getId());
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UserProfile profile = provider.create(UserProfileContext.ACCOUNT, user);
UserRepresentation rep = profile.toRepresentation();
rep.setAttributes(profile.getAttributes().getReadable(false));
addReadableBuiltinAttributes(user, rep, profile.getAttributes().getReadable(true).keySet());
if(userProfileMetadata == null || userProfileMetadata.booleanValue())
rep.setUserProfileMetadata(UserProfileResource.createUserProfileMetadata(session, profile));
if (userProfileMetadata != null && !userProfileMetadata) {
rep.setUserProfileMetadata(null);
}
return rep;
}
private void addReadableBuiltinAttributes(UserModel user, UserRepresentation rep, Set<String> readableAttributes) {
setIfReadable(UserModel.USERNAME, readableAttributes, rep::setUsername, user::getUsername);
setIfReadable(UserModel.FIRST_NAME, readableAttributes, rep::setFirstName, user::getFirstName);
setIfReadable(UserModel.LAST_NAME, readableAttributes, rep::setLastName, user::getLastName);
setIfReadable(UserModel.EMAIL, readableAttributes, rep::setEmail, user::getEmail);
// emailVerified is readable when email is readable
setIfReadable(UserModel.EMAIL, readableAttributes, rep::setEmailVerified, user::isEmailVerified);
}
private <T> void setIfReadable(String attributeName, Set<String> readableAttributes, Consumer<T> setter, Supplier<T> getter) {
if (readableAttributes.contains(attributeName)) {
setter.accept(getter.get());
}
}
@Path("/")
@POST
@Consumes(MediaType.APPLICATION_JSON)
@ -185,7 +155,7 @@ public class AccountRestService {
event.event(EventType.UPDATE_PROFILE).detail(Details.CONTEXT, UserProfileContext.ACCOUNT.name());
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
UserProfile profile = profileProvider.create(UserProfileContext.ACCOUNT, rep.toAttributes(), auth.getUser());
UserProfile profile = profileProvider.create(UserProfileContext.ACCOUNT, rep.getRawAttributes(), auth.getUser());
try {

View file

@ -16,13 +16,9 @@
*/
package org.keycloak.services.resources.admin;
import static org.keycloak.userprofile.UserProfileUtil.createUserProfileMetadata;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
@ -117,56 +113,4 @@ public class UserProfileResource {
return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build();
}
public static UserProfileMetadata createUserProfileMetadata(KeycloakSession session, UserProfile profile) {
Attributes profileAttributes = profile.getAttributes();
Map<String, List<String>> am = profileAttributes.getReadable();
if(am == null)
return null;
Map<String, List<String>> unmanagedAttributes = profileAttributes.getUnmanagedAttributes();
List<UserProfileAttributeMetadata> attributes = am.keySet().stream()
.map(profileAttributes::getMetadata)
.filter(Objects::nonNull)
.filter(attributeMetadata -> !unmanagedAttributes.containsKey(attributeMetadata.getName()))
.sorted(Comparator.comparingInt(AttributeMetadata::getGuiOrder))
.map(sam -> toRestMetadata(sam, session, profile))
.collect(Collectors.toList());
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UPConfig config = provider.getConfiguration();
List<UserProfileAttributeGroupMetadata> groups = config.getGroups().stream().map(new Function<UPGroup, UserProfileAttributeGroupMetadata>() {
@Override
public UserProfileAttributeGroupMetadata apply(UPGroup upGroup) {
return new UserProfileAttributeGroupMetadata(upGroup.getName(), upGroup.getDisplayHeader(), upGroup.getDisplayDescription(), upGroup.getAnnotations());
}
}).collect(Collectors.toList());
return new UserProfileMetadata(attributes, groups);
}
private static UserProfileAttributeMetadata toRestMetadata(AttributeMetadata am, KeycloakSession session, UserProfile profile) {
String group = null;
if (am.getAttributeGroupMetadata() != null) {
group = am.getAttributeGroupMetadata().getName();
}
return new UserProfileAttributeMetadata(am.getName(),
am.getAttributeDisplayName(),
profile.getAttributes().isRequired(am.getName()),
profile.getAttributes().isReadOnly(am.getName()),
group,
am.getAnnotations(),
toValidatorMetadata(am, session));
}
private static Map<String, Map<String, Object>> toValidatorMetadata(AttributeMetadata am, KeycloakSession session){
// we return only validators which are instance of ConfiguredProvider. Others are expected as internal.
return am.getValidators() == null ? null : am.getValidators().stream()
.filter(avm -> (Validators.validator(session, avm.getValidatorId()) instanceof ConfiguredProvider))
.collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig));
}
}

View file

@ -123,7 +123,6 @@ 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.services.resources.admin.UserProfileResource.createUserProfileMetadata;
import static org.keycloak.userprofile.UserProfileContext.USER_API;
/**
@ -184,7 +183,7 @@ public class UserResource {
wasPermanentlyLockedOut = session.getProvider(BruteForceProtector.class).isPermanentlyLockedOut(session, realm, user);
}
Map<String, List<String>> attributes = new HashMap<>(rep.toAttributes());
Map<String, List<String>> attributes = new HashMap<>(rep.getRawAttributes());
if (rep.getAttributes() == null) {
// include existing attributes in case no attributes are set so that validation takes into account the existing
@ -302,7 +301,9 @@ public class UserResource {
) {
auth.users().requireView(user);
UserRepresentation rep = ModelToRepresentation.toRepresentation(session, realm, user);
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UserProfile profile = provider.create(USER_API, user);
UserRepresentation rep = profile.toRepresentation();
if (realm.isIdentityFederationEnabled()) {
List<FederatedIdentityRepresentation> reps = getFederatedIdentities(user).collect(Collectors.toList());
@ -314,16 +315,8 @@ public class UserResource {
}
rep.setAccess(auth.users().getAccess(user));
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UserProfile profile = provider.create(USER_API, user);
Map<String, List<String>> readableAttributes = profile.getAttributes().getReadable(false);
if (rep.getAttributes() != null) {
rep.setAttributes(readableAttributes);
}
if (userProfileMetadata) {
rep.setUserProfileMetadata(createUserProfileMetadata(session, profile));
if (!userProfileMetadata) {
rep.setUserProfileMetadata(null);
}
return rep;

View file

@ -154,7 +154,7 @@ public class UsersResource {
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
UserProfile profile = profileProvider.create(USER_API, rep.toAttributes());
UserProfile profile = profileProvider.create(USER_API, rep.getRawAttributes());
try {
Response response = UserResource.validateUserProfile(profile, session, auth.adminAuth());

View file

@ -117,6 +117,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
}
return new DefaultAttributes(context, attributes, user, metadata, session);
}
return new LegacyAttributes(context, attributes, user, metadata, session);
}
@ -153,11 +154,11 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
@Override
public UserModel apply(Attributes attributes) {
if (user == null) {
String userName = attributes.getFirstValue(UserModel.USERNAME);
String userName = attributes.getFirst(UserModel.USERNAME);
// fallback to email in case email is allowed
if (userName == null) {
userName = attributes.getFirstValue(UserModel.EMAIL);
userName = attributes.getFirst(UserModel.EMAIL);
}
user = session.users().addUser(session.getContext().getRealm(), userName);

View file

@ -75,7 +75,7 @@ public class ImmutableAttributeValidator implements SimpleValidator {
return context;
}
List<String> email = attributeContext.getAttributes().getValues(UserModel.EMAIL);
List<String> email = attributeContext.getAttributes().get(UserModel.EMAIL);
if (UserModel.USERNAME.equals(attributeName) && collectionEquals(values, email)) {
return context;

View file

@ -70,7 +70,7 @@ public class UsernameMutationValidator implements SimpleValidator {
if (!realm.isEditUsernameAllowed() && user != null && !value.equals(user.getFirstAttribute(UserModel.USERNAME))) {
Attributes attributes = attributeContext.getAttributes();
if (realm.isRegistrationEmailAsUsername() && value.equals(attributes.getFirstValue(UserModel.EMAIL))) {
if (realm.isRegistrationEmailAsUsername() && value.equals(attributes.getFirst(UserModel.EMAIL))) {
// if username changed is because email as username is allowed so no validation should happen for update profile
// it is expected that username changes when attributes are normalized by the provider
return context;

View file

@ -19,6 +19,8 @@
package org.keycloak.testsuite.account;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import jakarta.ws.rs.BadRequestException;
@ -99,7 +101,7 @@ public class AccountRestServiceReadOnlyAttributesTest extends AbstractRestServic
private void testAccountUpdateAttributeExpectFailure(String attrName, boolean deniedForAdminAsWell) throws IOException {
// Attribute not yet supposed to be on the user
UserRepresentation user = SimpleHttp.doGet(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).asJson(UserRepresentation.class);
assertThat(user.getAttributes().keySet(), not(contains(attrName)));
assertThat(Optional.ofNullable(user.getAttributes()).orElse(Map.of()).keySet(), not(contains(attrName)));
// Assert not possible to add the attribute to the user
user.singleAttribute(attrName, "foo");
@ -147,7 +149,7 @@ public class AccountRestServiceReadOnlyAttributesTest extends AbstractRestServic
private void testAccountUpdateAttributeExpectSuccess(String attrName) throws IOException {
// Attribute not yet supposed to be on the user
UserRepresentation user = SimpleHttp.doGet(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).asJson(UserRepresentation.class);
assertThat(user.getAttributes().keySet(), not(contains(attrName)));
assertThat(Optional.ofNullable(user.getAttributes()).orElse(Map.of()).keySet(), not(contains(attrName)));
// Assert not possible to add the attribute to the user
user.singleAttribute(attrName, "foo");

View file

@ -79,6 +79,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import static org.hamcrest.CoreMatchers.is;
@ -278,7 +279,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
assertEquals("Brady", user.getLastName());
assertEquals("test-user@localhost", user.getEmail());
assertFalse(user.isEmailVerified());
assertTrue(user.getAttributes().isEmpty());
assertNull(user.getAttributes());
}
@Test
@ -288,7 +289,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
String originalFirstName = user.getFirstName();
String originalLastName = user.getLastName();
String originalEmail = user.getEmail();
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes());
user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>()));
try {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
@ -316,7 +317,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
user.setFirstName(originalFirstName);
user.setLastName(originalLastName);
user.setEmail(originalEmail);
user.setAttributes(originalAttributes);
SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse();
System.out.println(response.asString());
assertEquals(204, response.getStatus());
@ -379,7 +379,8 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
String originalFirstName = user.getFirstName();
String originalLastName = user.getLastName();
String originalEmail = user.getEmail();
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes());
assertNull(user.getAttributes());
user.setAttributes(new HashMap<>());
try {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
@ -417,7 +418,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
user.setFirstName(originalFirstName);
user.setLastName(originalLastName);
user.setEmail(originalEmail);
user.setAttributes(originalAttributes);
SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse();
System.out.println(response.asString());
assertEquals(204, response.getStatus());
@ -431,7 +431,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
String originalFirstName = user.getFirstName();
String originalLastName = user.getLastName();
String originalEmail = user.getEmail();
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes());
user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>()));
try {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
@ -460,12 +460,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
user = updateAndGet(user);
if (isDeclarativeUserProfile()) {
assertEquals(2, user.getAttributes().size());
assertTrue(user.getAttributes().get("attr1").isEmpty());
} else {
assertEquals(1, user.getAttributes().size());
}
assertEquals(1, user.getAttributes().size());
assertEquals(2, user.getAttributes().get("attr2").size());
assertThat(user.getAttributes().get("attr2"), containsInAnyOrder("val2", "val3"));
@ -522,7 +517,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
user.setFirstName(originalFirstName);
user.setLastName(originalLastName);
user.setEmail(originalEmail);
user.setAttributes(originalAttributes);
SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse();
System.out.println(response.asString());
assertEquals(204, response.getStatus());
@ -556,6 +550,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
public void testUpdateProfileCannotChangeThroughAttributes() throws IOException {
UserRepresentation user = getUser();
String originalUsername = user.getUsername();
user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>()));
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes());
try {

View file

@ -29,6 +29,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.Before;
import org.junit.Test;
@ -292,6 +293,7 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
String originalFirstName = user.getFirstName();
String originalLastName = user.getLastName();
String originalEmail = user.getEmail();
user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>()));
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes());
try {
@ -303,13 +305,13 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
user.setEmail("bobby@localhost");
user.setFirstName("Homer");
user.setLastName("Simpsons");
user.getAttributes().put("attr1", Collections.singletonList("val1"));
user.getAttributes().put("attr2", Collections.singletonList("val2"));
user.getAttributes().put("attr1", Collections.singletonList("val11"));
user.getAttributes().put("attr2", Collections.singletonList("val22"));
events.clear();
user = updateAndGet(user);
//skip login to the REST API event
events.poll();
events.expectAccount(EventType.UPDATE_PROFILE).user(user.getId())
.detail(Details.CONTEXT, UserProfileContext.ACCOUNT.name())
.detail(Details.PREVIOUS_EMAIL, originalEmail)
@ -318,7 +320,7 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
.detail(Details.PREVIOUS_LAST_NAME, originalLastName)
.detail(Details.UPDATED_FIRST_NAME, "Homer")
.detail(Details.UPDATED_LAST_NAME, "Simpsons")
.detail(Details.PREF_UPDATED+"attr2", "val2")
.detail(Details.PREF_UPDATED+"attr2", "val22")
.assertEvent();
events.assertEmpty();
@ -379,22 +381,23 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
realmRep.setInternationalizationEnabled(false);
testRealm().update(realmRep);
UserRepresentation user = getUser();
user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>()));
try {
user.getAttributes().put(UserModel.LOCALE, List.of("pt_BR"));
user = updateAndGet(user);
assertNull(user.getAttributes().get(UserModel.LOCALE));
assertNull(user.getAttributes());
realmRep.setInternationalizationEnabled(true);
testRealm().update(realmRep);
user.getAttributes().put(UserModel.LOCALE, List.of("pt_BR"));
user.singleAttribute(UserModel.LOCALE, "pt_BR");
user = updateAndGet(user);
assertEquals("pt_BR", user.getAttributes().get(UserModel.LOCALE).get(0));
user.getAttributes().remove(UserModel.LOCALE);
user = updateAndGet(user);
assertNull(user.getAttributes().get(UserModel.LOCALE));
assertNull(user.getAttributes());
UserProfileMetadata metadata = user.getUserProfileMetadata();
@ -406,7 +409,6 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
} finally {
realmRep.setInternationalizationEnabled(internationalizationEnabled);
testRealm().update(realmRep);
user.getAttributes().remove(UserModel.LOCALE);
updateAndGet(user);
}
}

View file

@ -37,6 +37,7 @@ import org.keycloak.userprofile.UserProfileProvider;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@ -52,7 +53,6 @@ import jakarta.ws.rs.core.Response;
@EnableFeature(value = Profile.Feature.DECLARATIVE_USER_PROFILE)
public class DeclarativeUserTest extends AbstractAdminTest {
private static final String LOCALE_ATTR_KEY = "locale";
private static final String TEST_REALM_USER_MANAGER_NAME = "test-realm-user-manager";
private static final String REQUIRED_ATTR_KEY = "required-attr";
@ -120,29 +120,6 @@ public class DeclarativeUserTest extends AbstractAdminTest {
}
}
@Test
public void testReturnAllConfiguredAttributesEvenIfNotSet() {
UserRepresentation user1 = new UserRepresentation();
user1.setUsername("user1");
user1.singleAttribute("attr1", "value1user1");
user1.singleAttribute("attr2", "value2user1");
String user1Id = createUser(user1);
user1 = realm.users().get(user1Id).toRepresentation();
Map<String, List<String>> attributes = user1.getAttributes();
assertEquals(4, attributes.size());
List<String> attr1 = attributes.get("attr1");
assertEquals(1, attr1.size());
assertEquals("value1user1", attr1.get(0));
List<String> attr2 = attributes.get("attr2");
assertEquals(1, attr2.size());
assertEquals("value2user1", attr2.get(0));
List<String> attrCustomA = attributes.get("custom-a");
assertTrue(attrCustomA.isEmpty());
assertTrue(attributes.containsKey("custom-a"));
assertTrue(attributes.containsKey("aName"));
}
@Test
public void testDoNotReturnAttributeIfNotReadble() {
UserRepresentation user1 = new UserRepresentation();
@ -153,7 +130,7 @@ public class DeclarativeUserTest extends AbstractAdminTest {
user1 = realm.users().get(user1Id).toRepresentation();
Map<String, List<String>> attributes = user1.getAttributes();
assertEquals(4, attributes.size());
assertEquals(2, attributes.size());
assertFalse(attributes.containsKey("custom-hidden"));
setUserProfileConfiguration(this.realm, "{\"attributes\": ["
@ -170,8 +147,8 @@ public class DeclarativeUserTest extends AbstractAdminTest {
user1 = realm.users().get(user1Id).toRepresentation();
attributes = user1.getAttributes();
assertEquals(5, attributes.size());
assertTrue(attributes.containsKey("custom-hidden"));
assertEquals(2, attributes.size());
assertFalse(attributes.containsKey("custom-hidden"));
}
@Test
@ -200,12 +177,17 @@ public class DeclarativeUserTest extends AbstractAdminTest {
UserResource userResource = realm.users().get(user1Id);
user1 = userResource.toRepresentation();
Map<String, List<String>> attributes = user1.getAttributes();
attributes.put("attr2", Collections.singletonList(""));
assertNull(user1.getAttributes());
user1.singleAttribute("attr2", "");
// should be able to update the user when a read-only attribute has an empty or null value
userResource.update(user1);
attributes.put("attr2", null);
user1 = userResource.toRepresentation();
assertNull(user1.getAttributes());
user1.setAttributes(new HashMap<>());
user1.getAttributes().put("attr2", null);
userResource.update(user1);
user1 = userResource.toRepresentation();
assertNull(user1.getAttributes());
}
@Test
@ -288,7 +270,7 @@ public class DeclarativeUserTest extends AbstractAdminTest {
realm.update(realmRep);
user1 = userResource.toRepresentation();
assertNull(user1.getAttributes().get(UserModel.LOCALE));
assertNull(user1.getAttributes());
} finally {
realmRep.setInternationalizationEnabled(internationalizationEnabled);
realm.update(realmRep);

View file

@ -1529,20 +1529,12 @@ public class UserTest extends AbstractAdminTest {
String user2Id = createUser(user2);
user1 = realm.users().get(user1Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(2, user1.getAttributes().size());
}
assertEquals(2, user1.getAttributes().size());
assertAttributeValue("value1user1", user1.getAttributes().get("attr1"));
assertAttributeValue("value2user1", user1.getAttributes().get("attr2"));
user2 = realm.users().get(user2Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user2.getAttributes().size());
} else {
assertEquals(2, user2.getAttributes().size());
}
assertEquals(2, user2.getAttributes().size());
assertAttributeValue("value1user2", user2.getAttributes().get("attr1"));
vals = user2.getAttributes().get("attr2");
assertEquals(2, vals.size());
@ -1554,11 +1546,7 @@ public class UserTest extends AbstractAdminTest {
updateUser(realm.users().get(user1Id), user1);
user1 = realm.users().get(user1Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(3, user1.getAttributes().size());
}
assertEquals(3, user1.getAttributes().size());
assertAttributeValue("value3user1", user1.getAttributes().get("attr1"));
assertAttributeValue("value2user1", user1.getAttributes().get("attr2"));
assertAttributeValue("value4user1", user1.getAttributes().get("attr3"));
@ -1567,11 +1555,7 @@ public class UserTest extends AbstractAdminTest {
updateUser(realm.users().get(user1Id), user1);
user1 = realm.users().get(user1Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(2, user1.getAttributes().size());
}
assertEquals(2, user1.getAttributes().size());
assertAttributeValue("value2user1", user1.getAttributes().get("attr2"));
assertAttributeValue("value4user1", user1.getAttributes().get("attr3"));
@ -1580,11 +1564,7 @@ public class UserTest extends AbstractAdminTest {
updateUser(realm.users().get(user1Id), user1);
user1 = realm.users().get(user1Id).toRepresentation();
assertNotNull(user1.getAttributes());
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(2, user1.getAttributes().size());
}
assertEquals(2, user1.getAttributes().size());
// empty attributes should remove attributes
user1.setAttributes(Collections.emptyMap());
@ -1602,21 +1582,13 @@ public class UserTest extends AbstractAdminTest {
realm.users().get(user1Id).update(user1);
user1 = realm.users().get(user1Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(2, user1.getAttributes().size());
}
assertEquals(2, user1.getAttributes().size());
user1.getAttributes().remove("foo");
realm.users().get(user1Id).update(user1);
user1 = realm.users().get(user1Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(1, user1.getAttributes().size());
}
assertEquals(1, user1.getAttributes().size());
}
@Test
@ -1669,11 +1641,7 @@ public class UserTest extends AbstractAdminTest {
user1 = realm.users().get(user1Id).toRepresentation();
assertEquals("foo", user1.getAttributes().get("usercertificate").get(0));
assertEquals("bar", user1.getAttributes().get("saml.persistent.name.id.for.foo").get(0));
if (isDeclarativeUserProfile()) {
assertTrue(user1.getAttributes().get(LDAPConstants.LDAP_ID).isEmpty());
} else {
assertFalse(user1.getAttributes().containsKey(LDAPConstants.LDAP_ID));
}
assertFalse(user1.getAttributes().containsKey(LDAPConstants.LDAP_ID));
}
@Test

View file

@ -18,6 +18,8 @@
package org.keycloak.testsuite.federation.ldap;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.testsuite.forms.VerifyProfileTest.disableDynamicUserProfile;
@ -71,14 +73,16 @@ public class LDAPAdminRestApiWithUserProfileTest extends LDAPAdminRestApiTest {
UserResource user = testRealm().users().get(newUserId);
UserRepresentation userRep = user.toRepresentation();
assertTrue(userRep.getAttributes().containsKey(LDAPConstants.LDAP_ID));
assertTrue(userRep.getAttributes().get(LDAPConstants.LDAP_ID).isEmpty());
assertNull(userRep.getAttributes());
userRep.singleAttribute(LDAPConstants.LDAP_ID, "");
user.update(userRep);
userRep = testRealm().users().get(newUserId).toRepresentation();
assertNull(userRep.getAttributes());
userRep.singleAttribute(LDAPConstants.LDAP_ID, null);
user.update(userRep);
userRep = testRealm().users().get(newUserId).toRepresentation();
assertNull(userRep.getAttributes());
try {
userRep.singleAttribute(LDAPConstants.LDAP_ID, "should-fail");

View file

@ -55,6 +55,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.LDAPConstants;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.AbstractUserRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
@ -114,6 +115,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
// create a user with attribute foo value 123 allowed by the profile now but disallowed later
UPConfig config = parseDefaultConfig();
config.addOrReplaceAttribute(new UPAttribute("foo", new UPAttributePermissions(Set.of(), Set.of(ROLE_ADMIN))));
config.getAttribute(UserModel.EMAIL).setPermissions(new UPAttributePermissions(Set.of(ROLE_USER), Set.of(ROLE_ADMIN)));
RealmResource realmRes = testRealm();
realmRes.users().userProfile().update(config);
@ -142,6 +144,18 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile.validate();
});
// it should work if foo is read-only in the context
getTestingClient().server(TEST_REALM_NAME).run(session -> {
RealmModel realm = session.getContext().getRealm();
UserModel user = session.users().getUserById(realm, userId);
user.setEmail(null);
UserProfileProvider provider = getUserProfileProvider(session);
Map<String, Object> attributes = new HashMap<>(user.getAttributes());
attributes.put("email", "");
UserProfile profile = provider.create(UserProfileContext.ACCOUNT, attributes, user);
profile.validate();
});
// it should fail if foo can be modified
getTestingClient().server(TEST_REALM_NAME).run(session -> {
RealmModel realm = session.getContext().getRealm();
@ -174,7 +188,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
// once created, profile attributes can not be changed
assertTrue(profile.getAttributes().contains(UserModel.USERNAME));
assertNull(profile.getAttributes().getFirstValue(UserModel.USERNAME));
assertNull(profile.getAttributes().getFirst(UserModel.USERNAME));
}
@Test
@ -413,11 +427,11 @@ public class UserProfileTest extends AbstractUserProfileTest {
assertTrue(ve.isAttributeOnError("address"));
}
assertNotNull(attributes.getFirstValue(UserModel.USERNAME));
assertNotNull(attributes.getFirstValue(UserModel.EMAIL));
assertNotNull(attributes.getFirstValue(UserModel.FIRST_NAME));
assertNotNull(attributes.getFirstValue(UserModel.LAST_NAME));
assertNull(attributes.getFirstValue("address"));
assertNotNull(attributes.getFirst(UserModel.USERNAME));
assertNotNull(attributes.getFirst(UserModel.EMAIL));
assertNotNull(attributes.getFirst(UserModel.FIRST_NAME));
assertNotNull(attributes.getFirst(UserModel.LAST_NAME));
assertNull(attributes.getFirst("address"));
user.setAttribute("address", Arrays.asList("fixed-address"));
@ -426,7 +440,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile.validate();
assertNotNull(attributes.getFirstValue("address"));
assertNotNull(attributes.getFirst("address"));
}
@Test
@ -1516,20 +1530,20 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile = provider.create(UserProfileContext.USER_API, user);
Attributes userAttributes = profile.getAttributes();
assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL));
assertEquals("Test Value", userAttributes.getFirstValue("test-attribute"));
assertEquals("changed", userAttributes.getFirstValue("foo"));
assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL));
assertEquals("Test Value", userAttributes.getFirst("test-attribute"));
assertEquals("changed", userAttributes.getFirst("foo"));
attributes.remove("foo");
attributes.put("test-attribute", userAttributes.getFirstValue("test-attribute"));
attributes.put("test-attribute", userAttributes.getFirst("test-attribute"));
profile = provider.create(UserProfileContext.USER_API, attributes, user);
profile.update(true);
profile = provider.create(UserProfileContext.USER_API, user);
userAttributes = profile.getAttributes();
// remove attribute if not set
assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL));
assertEquals("Test Value", userAttributes.getFirstValue("test-attribute"));
assertNull(userAttributes.getFirstValue("foo"));
assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL));
assertEquals("Test Value", userAttributes.getFirst("test-attribute"));
assertNull(userAttributes.getFirst("foo"));
config.addOrReplaceAttribute(new UPAttribute("test-attribute", new UPAttributePermissions(Set.of(), Set.of(ROLE_USER))));
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
@ -1539,8 +1553,8 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile = provider.create(UserProfileContext.USER_API, user);
userAttributes = profile.getAttributes();
// do not remove test-attribute because admin does not have write permissions
assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL));
assertEquals("Test Value", userAttributes.getFirstValue("test-attribute"));
assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL));
assertEquals("Test Value", userAttributes.getFirst("test-attribute"));
config.addOrReplaceAttribute(new UPAttribute("test-attribute", new UPAttributePermissions(Set.of(), Set.of(ROLE_USER, ROLE_ADMIN))));
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
@ -1550,8 +1564,8 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile = provider.create(UserProfileContext.USER_API, user);
userAttributes = profile.getAttributes();
// removes the test-attribute attribute because now admin has write permission
assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL));
assertNull(userAttributes.getFirstValue("test-attribute"));
assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL));
assertNull(userAttributes.getFirst("test-attribute"));
}
@Test
@ -1594,11 +1608,11 @@ public class UserProfileTest extends AbstractUserProfileTest {
}
private static void assertRemoveEmptyRootAttribute(Map<String, List<String>> attributes, UserModel user, Attributes upAttributes) {
assertNull(upAttributes.getFirstValue(UserModel.LAST_NAME));
assertNull(upAttributes.getFirst(UserModel.LAST_NAME));
assertNull(user.getLastName());
assertNull(upAttributes.getFirstValue(UserModel.EMAIL));
assertNull(upAttributes.getFirst(UserModel.EMAIL));
assertNull(user.getEmail());
assertEquals(upAttributes.getFirstValue(UserModel.FIRST_NAME), attributes.get(UserModel.FIRST_NAME).get(0));
assertEquals(upAttributes.getFirst(UserModel.FIRST_NAME), attributes.get(UserModel.FIRST_NAME).get(0));
}
@Test
@ -1693,6 +1707,77 @@ public class UserProfileTest extends AbstractUserProfileTest {
assertFalse(profile.getAttributes().isReadOnly("foo"));
}
@Test
public void testOptionalRootAttributesAsUnmanagedAttribute() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testOptionalRootAttributesAsUnmanagedAttribute);
}
private static void testOptionalRootAttributesAsUnmanagedAttribute(KeycloakSession session) throws IOException {
UPConfig config = parseDefaultConfig();
UserProfileProvider provider = getUserProfileProvider(session);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
Map<String, String> rawAttributes = new HashMap<>();
rawAttributes.put(UserModel.USERNAME, org.keycloak.models.utils.KeycloakModelUtils.generateId() + "@keycloak.org");
rawAttributes.put(UserModel.EMAIL, org.keycloak.models.utils.KeycloakModelUtils.generateId() + "@keycloak.org");
rawAttributes.put(UserModel.FIRST_NAME, "firstName");
rawAttributes.put(UserModel.LAST_NAME, "lastName");
UserProfile profile = provider.create(UserProfileContext.USER_API, rawAttributes);
UserModel user = profile.create();
assertEquals(rawAttributes.get(UserModel.FIRST_NAME), user.getFirstName());
assertEquals(rawAttributes.get(UserModel.LAST_NAME), user.getLastName());
AbstractUserRepresentation rep = profile.toRepresentation();
assertEquals(rawAttributes.get(UserModel.FIRST_NAME), rep.getFirstName());
assertEquals(rawAttributes.get(UserModel.LAST_NAME), rep.getLastName());
assertNull(rep.getAttributes());
config.removeAttribute(UserModel.FIRST_NAME);
config.removeAttribute(UserModel.LAST_NAME);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
profile = provider.create(UserProfileContext.USER_API, user);
Attributes attributes = profile.getAttributes();
assertNull(attributes.getFirst(UserModel.FIRST_NAME));
assertNull(attributes.getFirst(UserModel.LAST_NAME));
rep = profile.toRepresentation();
assertNull(rep.getFirstName());
assertNull(rep.getLastName());
assertNull(rep.getAttributes());
rawAttributes.put(UserModel.FIRST_NAME, "firstName");
rawAttributes.put(UserModel.LAST_NAME, "lastName");
config.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ADMIN_EDIT);
provider.setConfiguration(JsonSerialization.writeValueAsString(config));
profile = provider.create(UserProfileContext.USER_API, user);
attributes = profile.getAttributes();
assertEquals(rawAttributes.get(UserModel.FIRST_NAME), attributes.getFirst(UserModel.FIRST_NAME));
assertEquals(rawAttributes.get(UserModel.LAST_NAME), attributes.getFirst(UserModel.LAST_NAME));
rep = profile.toRepresentation();
assertNull(rep.getFirstName());
assertNull(rep.getLastName());
assertNull(rep.getAttributes());
rawAttributes.remove(UserModel.LAST_NAME);
rawAttributes.put(UserModel.FIRST_NAME, "firstName");
profile = provider.create(UserProfileContext.USER_API, rawAttributes, user);
attributes = profile.getAttributes();
assertEquals(rawAttributes.get(UserModel.FIRST_NAME), attributes.getFirst(UserModel.FIRST_NAME));
assertNull(attributes.getFirst(UserModel.LAST_NAME));
rep = profile.toRepresentation();
assertNull(rep.getFirstName());
assertNull(rep.getLastName());
assertNull(rep.getAttributes());
rawAttributes.put(UserModel.LAST_NAME, "lastNameChanged");
rawAttributes.put(UserModel.FIRST_NAME, "firstNameChanged");
profile = provider.create(UserProfileContext.USER_API, rawAttributes, user);
attributes = profile.getAttributes();
assertEquals(rawAttributes.get(UserModel.FIRST_NAME), attributes.getFirst(UserModel.FIRST_NAME));
assertEquals(rawAttributes.get(UserModel.LAST_NAME), attributes.getFirst(UserModel.LAST_NAME));
rep = profile.toRepresentation();
assertNull(rep.getFirstName());
assertNull(rep.getLastName());
assertNull(rep.getAttributes());
}
@Test
public void testAttributeNormalization() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testAttributeNormalization);
@ -1705,7 +1790,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
attributes.put(UserModel.EMAIL, "TesT@TesT.org");
UserProfile profile = provider.create(UserProfileContext.USER_API, attributes);
Attributes profileAttributes = profile.getAttributes();
assertEquals(attributes.get(UserModel.USERNAME).toLowerCase(), profileAttributes.getFirstValue(UserModel.USERNAME));
assertEquals(attributes.get(UserModel.EMAIL).toLowerCase(), profileAttributes.getFirstValue(UserModel.EMAIL));
assertEquals(attributes.get(UserModel.USERNAME).toLowerCase(), profileAttributes.getFirst(UserModel.USERNAME));
assertEquals(attributes.get(UserModel.EMAIL).toLowerCase(), profileAttributes.getFirst(UserModel.EMAIL));
}
}