Support for the locale user attribute

Closes #21163
This commit is contained in:
Pedro Igor 2023-06-22 19:42:02 -03:00
parent db9b6c2152
commit d0691b0884
7 changed files with 169 additions and 12 deletions

View file

@ -142,7 +142,16 @@ public interface Attributes {
if (includeBuiltin) {
return true;
}
return !isRootAttribute(entry.getKey());
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));
}
@ -163,7 +172,8 @@ public interface Attributes {
return UserModel.USERNAME.equals(name)
|| UserModel.EMAIL.equals(name)
|| UserModel.FIRST_NAME.equals(name)
|| UserModel.LAST_NAME.equals(name);
|| UserModel.LAST_NAME.equals(name)
|| UserModel.LOCALE.equals(name);
}
Map<String, List<String>> toMap();

View file

@ -145,6 +145,11 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
return null;
}
private static boolean isInternationalizationEnabled(AttributeContext context) {
RealmModel realm = context.getSession().getContext().getRealm();
return realm.isInternationalizationEnabled();
}
/**
* There are the declarations for creating the built-in validations for read-only attributes. Regardless of the context where
* user profiles are used. They are related to internal attributes with hard conditions on them in terms of management.
@ -199,7 +204,7 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
}
addContextualProfileMetadata(configureUserProfile(createBrokeringProfile(readOnlyValidator)));
addContextualProfileMetadata(configureUserProfile(createDefaultProfile(ACCOUNT, readOnlyValidator)));
addContextualProfileMetadata(configureUserProfile(createAccountProfile(ACCOUNT, readOnlyValidator)));
addContextualProfileMetadata(configureUserProfile(createDefaultProfile(ACCOUNT_OLD, readOnlyValidator)));
addContextualProfileMetadata(configureUserProfile(createDefaultProfile(REGISTRATION_PROFILE, readOnlyValidator)));
addContextualProfileMetadata(configureUserProfile(createDefaultProfile(UPDATE_PROFILE, readOnlyValidator)));
@ -392,9 +397,11 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
}
readonlyValidators.add(createReadOnlyAttributeUnchangedValidator(adminReadOnlyAttributesPattern));
metadata.addAttribute(READ_ONLY_ATTRIBUTE_KEY, 1000, readonlyValidators);
metadata.addAttribute(UserModel.LOCALE, -1, AbstractUserProfileProvider::isInternationalizationEnabled, AbstractUserProfileProvider::isInternationalizationEnabled)
.setRequired(AttributeMetadata.ALWAYS_FALSE);
return metadata;
}
@ -415,4 +422,13 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
.build();
}
private UserProfileMetadata createAccountProfile(UserProfileContext context, AttributeValidatorMetadata readOnlyValidator) {
UserProfileMetadata defaultProfile = createDefaultProfile(context, readOnlyValidator);
defaultProfile.addAttribute(UserModel.LOCALE, -1, AbstractUserProfileProvider::isInternationalizationEnabled, AbstractUserProfileProvider::isInternationalizationEnabled)
.setRequired(AttributeMetadata.ALWAYS_FALSE);
return defaultProfile;
}
}

View file

@ -48,7 +48,7 @@ public class LegacyAttributes extends DefaultAttributes {
@Override
protected boolean isIncludeAttributeIfNotProvided(AttributeMetadata metadata) {
// user api expects that attributes are not updated if not provided when in legacy mode
return UserProfileContext.USER_API.equals(context);
// user api expects that built-in attributes are not updated if not provided when in legacy mode
return UserProfileContext.USER_API.equals(context) && !isRootAttribute(metadata.getName());
}
}

View file

@ -36,7 +36,9 @@ import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.UserModel;
import org.keycloak.representations.account.UserProfileAttributeMetadata;
import org.keycloak.representations.account.UserProfileMetadata;
import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
@ -368,6 +370,45 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
super.testUpdateSingleField();
}
@Test
public void testManageUserLocaleAttribute() throws IOException {
RealmRepresentation realmRep = testRealm().toRepresentation();
Boolean internationalizationEnabled = realmRep.isInternationalizationEnabled();
realmRep.setInternationalizationEnabled(false);
testRealm().update(realmRep);
UserRepresentation user = getUser();
try {
user.getAttributes().put(UserModel.LOCALE, List.of("pt_BR"));
user = updateAndGet(user);
assertNull(user.getAttributes().get(UserModel.LOCALE));
realmRep.setInternationalizationEnabled(true);
testRealm().update(realmRep);
user.getAttributes().put(UserModel.LOCALE, List.of("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));
UserProfileMetadata metadata = user.getUserProfileMetadata();
assertTrue(metadata.getAttributes().stream()
.map(UserProfileAttributeMetadata::getName)
.filter(UserModel.LOCALE::equals).findAny()
.isPresent()
);
} finally {
realmRep.setInternationalizationEnabled(internationalizationEnabled);
testRealm().update(realmRep);
user.getAttributes().remove(UserModel.LOCALE);
updateAndGet(user);
}
}
protected void setUserProfileConfiguration(String configuration) {
VerifyProfileTest.setUserProfileConfiguration(testRealm(), configuration);
}

View file

@ -8,6 +8,7 @@ import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.junit.Assert.assertEquals;
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.PERMISSIONS_ALL;
@ -21,6 +22,7 @@ import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.admin.client.resource.UsersResource;
import org.keycloak.common.Profile;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
@ -340,6 +342,34 @@ public class DeclarativeUserTest extends AbstractAdminTest {
});
}
@Test
public void testUserLocale() {
RealmRepresentation realmRep = realm.toRepresentation();
Boolean internationalizationEnabled = realmRep.isInternationalizationEnabled();
realmRep.setInternationalizationEnabled(true);
realm.update(realmRep);
try {
UserRepresentation user1 = new UserRepresentation();
user1.setUsername("user1");
user1.singleAttribute(UserModel.LOCALE, "pt_BR");
String user1Id = createUser(user1);
UserResource userResource = realm.users().get(user1Id);
user1 = userResource.toRepresentation();
assertEquals("pt_BR", user1.getAttributes().get(UserModel.LOCALE).get(0));
realmRep.setInternationalizationEnabled(false);
realm.update(realmRep);
user1 = userResource.toRepresentation();
assertNull(user1.getAttributes().get(UserModel.LOCALE));
} finally {
realmRep.setInternationalizationEnabled(internationalizationEnabled);
realm.update(realmRep);
}
}
private String createUser(UserRepresentation userRep) {
Response response = realm.users().create(userRep);
String createdId = ApiUtil.getCreatedId(response);

View file

@ -0,0 +1,60 @@
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.i18n;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertEquals;
import java.io.IOException;
import java.util.Arrays;
import java.util.Locale;
import jakarta.ws.rs.core.Response;
import org.apache.http.impl.client.CloseableHttpClient;
import org.jboss.arquillian.graphene.page.Page;
import org.jboss.resteasy.client.jaxrs.ResteasyClient;
import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder;
import org.jboss.resteasy.client.jaxrs.engines.ApacheHttpClient43Engine;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.adapters.HttpClientBuilder;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.common.Profile.Feature;
import org.keycloak.locale.LocaleSelectorProvider;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.LanguageComboboxAwarePage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
import org.keycloak.testsuite.pages.OAuthGrantPage;
import org.keycloak.testsuite.util.IdentityProviderBuilder;
import org.openqa.selenium.Cookie;
/**
* @author <a href="mailto:gerbermichi@me.com">Michael Gerber</a>
* @author Stan Silvert ssilvert@redhat.com (C) 2016 Red Hat Inc.
*/
@EnableFeature(Feature.DECLARATIVE_USER_PROFILE)
public class LoginPageWithUserProfileTest extends LoginPageTest {
}

View file

@ -332,7 +332,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
Attributes attributes = profile.getAttributes();
assertThat(attributes.nameSet(),
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, "address"));
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, UserModel.LOCALE, "address"));
try {
profile.validate();
@ -400,7 +400,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
Attributes attributes = profile.getAttributes();
assertThat(attributes.nameSet(),
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, "address", "second"));
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.FIRST_NAME, UserModel.LAST_NAME, UserModel.LOCALE, "address", "second"));
AttributeGroupMetadata companyAddressGroup = attributes.getMetadata("address").getAttributeGroupMetadata();
@ -517,7 +517,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
UserModel user = profile.create();
assertThat(profile.getAttributes().nameSet(),
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, "address", "department"));
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.LOCALE, "address", "department"));
assertNull(user.getFirstAttribute("department"));
@ -567,7 +567,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
UserModel user = profile.create();
assertThat(profile.getAttributes().nameSet(),
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL));
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.LOCALE));
profile = provider.create(UserProfileContext.USER_API, attributes, user);
@ -609,7 +609,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
UserModel user = profile.create();
assertThat(profile.getAttributes().nameSet(),
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL));
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.LOCALE));
profile = provider.create(UserProfileContext.USER_API, attributes, user);
@ -649,7 +649,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
UserModel user = profile.create();
assertThat(profile.getAttributes().nameSet(),
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, "address", "department", "phone"));
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.LOCALE, "address", "department", "phone"));
profile = provider.create(UserProfileContext.USER_API, attributes, user);