From d0691b088403149bf78bc671ba1611978ab5ccdb Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Thu, 22 Jun 2023 19:42:02 -0300 Subject: [PATCH] Support for the locale user attribute Closes #21163 --- .../org/keycloak/userprofile/Attributes.java | 14 ++++- .../AbstractUserProfileProvider.java | 20 ++++++- .../userprofile/LegacyAttributes.java | 4 +- ...AccountRestServiceWithUserProfileTest.java | 41 +++++++++++++ .../testsuite/admin/DeclarativeUserTest.java | 30 ++++++++++ .../i18n/LoginPageWithUserProfileTest.java | 60 +++++++++++++++++++ .../user/profile/UserProfileTest.java | 12 ++-- 7 files changed, 169 insertions(+), 12 deletions(-) create mode 100755 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageWithUserProfileTest.java diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java b/server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java index 367f7f1f7e..4f8fd9f03e 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/Attributes.java @@ -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> toMap(); diff --git a/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java index e9d2f77473..b015ce1c54 100644 --- a/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java @@ -145,6 +145,11 @@ public abstract class AbstractUserProfileProvider 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 } 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 } 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 .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; + } } diff --git a/services/src/main/java/org/keycloak/userprofile/LegacyAttributes.java b/services/src/main/java/org/keycloak/userprofile/LegacyAttributes.java index 0c0aa44af1..e6514e5389 100644 --- a/services/src/main/java/org/keycloak/userprofile/LegacyAttributes.java +++ b/services/src/main/java/org/keycloak/userprofile/LegacyAttributes.java @@ -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()); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java index ded1b52c4e..c48ca77e6c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java @@ -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; @@ -367,6 +369,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); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/DeclarativeUserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/DeclarativeUserTest.java index 28abcff307..91efded5f3 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/DeclarativeUserTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/DeclarativeUserTest.java @@ -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); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageWithUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageWithUserProfileTest.java new file mode 100755 index 0000000000..30c9b1f979 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageWithUserProfileTest.java @@ -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 Michael Gerber + * @author Stan Silvert ssilvert@redhat.com (C) 2016 Red Hat Inc. + */ +@EnableFeature(Feature.DECLARATIVE_USER_PROFILE) +public class LoginPageWithUserProfileTest extends LoginPageTest { +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java index fab32e84e5..315291a8ca 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java @@ -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);