KEYCLOAK-18700 - consistently record User profile attribute changes in

UPDATE_PROFILE event
This commit is contained in:
Vlastimil Elias 2021-08-23 14:23:39 +02:00 committed by Pedro Igor
parent 4fe7d6d318
commit 2be5f528e4
18 changed files with 451 additions and 71 deletions

View file

@ -21,10 +21,14 @@ package org.keycloak.events;
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public interface Details {
String PREF_PREVIOUS = "previous_";
String PREF_UPDATED = "updated_";
String CUSTOM_REQUIRED_ACTION="custom_required_action";
String CONTEXT = "context";
String EMAIL = "email";
String PREVIOUS_EMAIL = "previous_email";
String UPDATED_EMAIL = "updated_email";
String PREVIOUS_EMAIL = PREF_PREVIOUS + "email";
String UPDATED_EMAIL = PREF_UPDATED + "email";
String ACTION = "action";
String CODE_ID = "code_id";
String REDIRECT_URI = "redirect_uri";
@ -39,10 +43,10 @@ public interface Details {
String USERNAME = "username";
String FIRST_NAME = "first_name";
String LAST_NAME = "last_name";
String PREVIOUS_FIRST_NAME = "previous_first_name";
String UPDATED_FIRST_NAME = "updated_first_name";
String PREVIOUS_LAST_NAME = "previous_last_name";
String UPDATED_LAST_NAME = "updated_last_name";
String PREVIOUS_FIRST_NAME = PREF_PREVIOUS + "first_name";
String UPDATED_FIRST_NAME = PREF_UPDATED + "first_name";
String PREVIOUS_LAST_NAME = PREF_PREVIOUS + "last_name";
String UPDATED_LAST_NAME = PREF_UPDATED + "last_name";
String REMEMBER_ME = "remember_me";
String TOKEN_ID = "token_id";
String REFRESH_TOKEN_ID = "refresh_token_id";

View file

@ -26,12 +26,14 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -146,6 +148,35 @@ public class EventBuilder {
return this;
}
/**
* Add event detail where strings from the input Collection are filtered not to contain <code>null</code> and then joined using <code>::</code> character.
*
* @param key of the detail
* @param value, can be null
* @return builder for chaining
*/
public EventBuilder detail(String key, Collection<String> values) {
if (values == null || values.isEmpty()) {
return this;
}
return detail(key, values.stream().filter(Objects::nonNull).collect(Collectors.joining("::")));
}
/**
* Add event detail where strings from the input Stream are filtered not to contain <code>null</code> and then joined using <code>::</code> character.
*
* @param key of the detail
* @param value, can be null
* @return builder for chaining
*/
public EventBuilder detail(String key, Stream<String> values) {
if (values == null) {
return this;
}
return detail(key, values.filter(Objects::nonNull).collect(Collectors.joining("::")));
}
public EventBuilder removeDetail(String key) {
if (event.getDetails() != null) {
event.getDetails().remove(key);

View file

@ -0,0 +1,43 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.userprofile;
import java.util.List;
import org.keycloak.models.UserModel;
/**
* Interface of the user profile attribute change listener.
*
* @author Vlastimil Elias <velias@redhat.com>
*
* @see UserProfile#update(boolean, AttributeChangeListener...)
* @see UserProfile#update(AttributeChangeListener...)
*/
@FunctionalInterface
public interface AttributeChangeListener {
/**
* Method called for each user attribute change.
*
* @param name of the changed user attribute
* @param user model where new attribute value is applied already (can be null if attribute is removed)
* @param oldValue of the attribute before the change (can be null)
*/
void onChange(String name, UserModel user, List<String> oldValue);
}

View file

@ -24,7 +24,6 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.stream.Collectors;
@ -89,7 +88,7 @@ public final class DefaultUserProfile implements UserProfile {
}
@Override
public void update(boolean removeAttributes, BiConsumer<String, UserModel>... changeListener) {
public void update(boolean removeAttributes, AttributeChangeListener... changeListener) {
if (!validated) {
validate();
}
@ -97,7 +96,7 @@ public final class DefaultUserProfile implements UserProfile {
updateInternal(user, removeAttributes, changeListener);
}
private UserModel updateInternal(UserModel user, boolean removeAttributes, BiConsumer<String, UserModel>... changeListener) {
private UserModel updateInternal(UserModel user, boolean removeAttributes, AttributeChangeListener... changeListener) {
if (user == null) {
throw new RuntimeException("No user model provided for persisting changes");
}
@ -120,8 +119,8 @@ public final class DefaultUserProfile implements UserProfile {
user.setEmailVerified(false);
}
for (BiConsumer<String, UserModel> listener : changeListener) {
listener.accept(name, user);
for (AttributeChangeListener listener : changeListener) {
listener.onChange(name, user, currentValue);
}
}
}
@ -138,7 +137,13 @@ public final class DefaultUserProfile implements UserProfile {
if (this.attributes.isReadOnly(attr)) {
continue;
}
List<String> currentValue = user.getAttributeStream(attr).filter(Objects::nonNull).collect(Collectors.toList());
user.removeAttribute(attr);
for (AttributeChangeListener listener : changeListener) {
listener.onChange(attr, user, currentValue);
}
}
}
} catch (ModelException me) {

View file

@ -66,7 +66,7 @@ public interface UserProfile {
* @param changeListener a set of one or more listeners to listen for attribute changes
* @throws ValidationException in case of any validation error
*/
void update(boolean removeAttributes, BiConsumer<String, UserModel>... changeListener) throws ValidationException;
void update(boolean removeAttributes, AttributeChangeListener... changeListener) throws ValidationException;
/**
* <p>The same as {@link #update(boolean, BiConsumer[])} but forcing the removal of attributes.
@ -74,7 +74,7 @@ public interface UserProfile {
* @param changeListener a set of one or more listeners to listen for attribute changes
* @throws ValidationException in case of any validation error
*/
default void update(BiConsumer<String, UserModel>... changeListener) throws ValidationException, RuntimeException {
default void update(AttributeChangeListener... changeListener) throws ValidationException, RuntimeException {
update(true, changeListener);
}

View file

@ -107,7 +107,8 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
@Override
protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext userCtx, BrokeredIdentityContext brokerContext) {
EventBuilder event = context.getEvent();
event.event(EventType.UPDATE_PROFILE);
//velias: looks like UPDATE_PROFILE event is not fired. IMHO it should not be fired here as user record in keycloak is not changed, user doesn't exist yet
event.event(EventType.UPDATE_PROFILE).detail(Details.CONTEXT, UserProfileContext.IDP_REVIEW.name());
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
UserModelDelegate updatedProfile = new UserModelDelegate(null) {
@ -153,10 +154,10 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
try {
String oldEmail = userCtx.getEmail();
profile.update((attributeName, userModel) -> {
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.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().getFirstValue(UserModel.EMAIL)).success();
}
});
} catch (ValidationException pve) {

View file

@ -37,6 +37,7 @@ import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.ValidationException;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.EventAuditingAttributeChangeListener;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
@ -64,29 +65,16 @@ public class UpdateProfile implements RequiredActionProvider, RequiredActionFact
@Override
public void processAction(RequiredActionContext context) {
EventBuilder event = context.getEvent();
event.event(EventType.UPDATE_PROFILE);
event.event(EventType.UPDATE_PROFILE).detail(Details.CONTEXT, UserProfileContext.UPDATE_PROFILE.name());
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
UserModel user = context.getUser();
String oldFirstName = user.getFirstName();
String oldLastName = user.getLastName();
String oldEmail = user.getEmail();
UserProfileProvider provider = context.getSession().getProvider(UserProfileProvider.class);
UserProfile profile = provider.create(UserProfileContext.UPDATE_PROFILE, formData, user);
try {
// backward compatibility with old account console where attributes are not removed if missing
profile.update(false, (attributeName, userModel) -> {
if (attributeName.equals(UserModel.FIRST_NAME)) {
event.detail(Details.PREVIOUS_FIRST_NAME, oldFirstName).detail(Details.UPDATED_FIRST_NAME, user.getFirstName());
}
if (attributeName.equals(UserModel.LAST_NAME)) {
event.detail(Details.PREVIOUS_LAST_NAME, oldLastName).detail(Details.UPDATED_LAST_NAME, user.getLastName());
}
if (attributeName.equals(UserModel.EMAIL)) {
event.detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, user.getEmail());
}
});
profile.update(false, new EventAuditingAttributeChangeListener(profile, event));
context.success();
} catch (ValidationException pve) {

View file

@ -76,6 +76,7 @@ import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.ValidationException;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.EventAuditingAttributeChangeListener;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.CredentialHelper;
@ -364,28 +365,14 @@ public class AccountFormService extends AbstractSecuredLocalService {
UserModel user = auth.getUser();
String oldFirstName = user.getFirstName();
String oldLastName = user.getLastName();
String oldEmail = user.getEmail();
event.event(EventType.UPDATE_PROFILE).client(auth.getClient()).user(auth.getUser());
event.event(EventType.UPDATE_PROFILE).client(auth.getClient()).user(auth.getUser()).detail(Details.CONTEXT, UserProfileContext.ACCOUNT_OLD.name());
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
UserProfile profile = profileProvider.create(UserProfileContext.ACCOUNT_OLD, formData, user);
try {
// backward compatibility with old account console where attributes are not removed if missing
profile.update(false, (attributeName, userModel) -> {
if (attributeName.equals(UserModel.EMAIL)) {
event.detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, user.getEmail()).success();
}
if (attributeName.equals(UserModel.FIRST_NAME)) {
event.detail(Details.PREVIOUS_FIRST_NAME, oldFirstName).detail(Details.UPDATED_FIRST_NAME, user.getFirstName());
}
if (attributeName.equals(UserModel.LAST_NAME)) {
event.detail(Details.PREVIOUS_LAST_NAME, oldLastName).detail(Details.UPDATED_LAST_NAME, user.getLastName());
}
});
profile.update(false, new EventAuditingAttributeChangeListener(profile, event));
} catch (ValidationException pve) {
List<FormMessage> errors = Validation.getFormErrorsFromValidation(pve.getErrors());

View file

@ -52,6 +52,7 @@ import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.enums.AccountRestApiVersion;
import org.keycloak.common.util.StringPropertyReplacer;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventStoreProvider;
import org.keycloak.events.EventType;
@ -85,6 +86,7 @@ import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.EventAuditingAttributeChangeListener;
import org.keycloak.userprofile.ValidationException;
import org.keycloak.userprofile.ValidationException.Error;
import org.keycloak.validate.Validators;
@ -199,14 +201,14 @@ public class AccountRestService {
public Response updateAccount(UserRepresentation rep) {
auth.require(AccountRoles.MANAGE_ACCOUNT);
event.event(EventType.UPDATE_PROFILE).client(auth.getClient()).user(auth.getUser());
event.event(EventType.UPDATE_PROFILE).client(auth.getClient()).user(auth.getUser()).detail(Details.CONTEXT, UserProfileContext.ACCOUNT.name());
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
UserProfile profile = profileProvider.create(UserProfileContext.ACCOUNT, rep.toAttributes(), auth.getUser());
try {
profile.update();
profile.update(new EventAuditingAttributeChangeListener(profile, event));
event.success();

View file

@ -0,0 +1,63 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.userprofile;
import java.util.List;
import org.keycloak.events.Details;
import org.keycloak.events.Event;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.UserModel;
/**
* {@link AttributeChangeListener} to audit user profile attribute changes into {@link Event}.
*
* Adds info about user profile attribute change into {@link Event}'s detail field.
*
* @author Vlastimil Elias <velias@redhat.com>
*
* @see UserProfile#update(AttributeChangeListener...)
*/
public class EventAuditingAttributeChangeListener implements AttributeChangeListener {
private EventBuilder event;
private UserProfile profile;
/**
* @param profile used to read attribute configuration from
* @param event to add detail info into
*/
public EventAuditingAttributeChangeListener(UserProfile profile, EventBuilder event) {
super();
this.profile = profile;
this.event = event;
}
@Override
public void onChange(String attributeName, UserModel userModel, List<String> oldValue) {
if (attributeName.equals(UserModel.FIRST_NAME)) {
event.detail(Details.PREVIOUS_FIRST_NAME, oldValue).detail(Details.UPDATED_FIRST_NAME, userModel.getFirstName());
} else if (attributeName.equals(UserModel.LAST_NAME)) {
event.detail(Details.PREVIOUS_LAST_NAME, oldValue).detail(Details.UPDATED_LAST_NAME, userModel.getLastName());
} else if (attributeName.equals(UserModel.EMAIL)) {
event.detail(Details.PREVIOUS_EMAIL, oldValue).detail(Details.UPDATED_EMAIL, userModel.getEmail());
} else {
event.detail(Details.PREF_PREVIOUS + attributeName, oldValue).detail(Details.PREF_UPDATED + attributeName, userModel.getAttributeStream(attributeName));
}
}
}

View file

@ -70,6 +70,8 @@ import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UIUtils;
import org.keycloak.testsuite.util.URLUtils;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.userprofile.UserProfileContext;
import java.util.Collections;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
@ -713,7 +715,9 @@ public class AccountFormServiceTest extends AbstractTestRealmKeycloakTest {
Assert.assertEquals("New last", profilePage.getLastName());
Assert.assertEquals("new@email.com", profilePage.getEmail());
events.expectAccount(EventType.UPDATE_PROFILE).detail(Details.PREVIOUS_FIRST_NAME, "Tom").detail(Details.UPDATED_FIRST_NAME, "New first")
events.expectAccount(EventType.UPDATE_PROFILE)
.detail(Details.CONTEXT, UserProfileContext.ACCOUNT_OLD.name())
.detail(Details.PREVIOUS_FIRST_NAME, "Tom").detail(Details.UPDATED_FIRST_NAME, "New first")
.detail(Details.PREVIOUS_LAST_NAME, "Brady").detail(Details.UPDATED_LAST_NAME, "New last")
.detail(Details.PREVIOUS_EMAIL, "test-user@localhost").detail(Details.UPDATED_EMAIL, "new@email.com")
.assertEvent();

View file

@ -18,6 +18,7 @@ package org.keycloak.testsuite.account;
import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.UserResource;
@ -30,6 +31,8 @@ import org.keycloak.common.Profile;
import org.keycloak.common.enums.AccountRestApiVersion;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.OTPCredentialModel;
@ -55,6 +58,7 @@ import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentati
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.account.AccountCredentialResource;
import org.keycloak.services.util.ResolveRelative;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.admin.authentication.AbstractAuthenticationTest;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
@ -63,6 +67,7 @@ import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.TokenUtil;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.validate.validators.EmailValidator;
import javax.ws.rs.core.Response;
@ -88,6 +93,9 @@ import static org.junit.Assert.assertTrue;
@EnableFeature(value = Profile.Feature.WEB_AUTHN, skipRestart = true, onlyForProduct = true)
public class AccountRestServiceTest extends AbstractRestServiceTest {
@Rule
public AssertEvents events = new AssertEvents(this);
@Test
public void testGetUserProfileMetadata_EditUsernameAllowed() throws IOException {
@ -249,6 +257,58 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
}
@Test
public void testUpdateProfileEvent() throws IOException {
UserRepresentation user = getUser();
String originalUsername = user.getUsername();
String originalFirstName = user.getFirstName();
String originalLastName = user.getLastName();
String originalEmail = user.getEmail();
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes());
try {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
realmRep.setRegistrationEmailAsUsername(false);
adminClient.realm("test").update(realmRep);
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 = 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)
.detail(Details.UPDATED_EMAIL, "bobby@localhost")
.detail(Details.PREVIOUS_FIRST_NAME, originalFirstName)
.detail(Details.PREVIOUS_LAST_NAME, originalLastName)
.detail(Details.UPDATED_FIRST_NAME, "Homer")
.detail(Details.UPDATED_LAST_NAME, "Simpsons")
.assertEvent();
events.assertEmpty();
} finally {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
realmRep.setEditUsernameAllowed(true);
adminClient.realm("test").update(realmRep);
user.setUsername(originalUsername);
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());
}
}
@Test
public void testUpdateProfile() throws IOException {
UserRepresentation user = getUser();

View file

@ -25,17 +25,25 @@ import static org.keycloak.testsuite.forms.VerifyProfileTest.PERMISSIONS_ADMIN_E
import static org.keycloak.testsuite.forms.VerifyProfileTest.PERMISSIONS_ADMIN_ONLY;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
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.representations.account.UserProfileAttributeMetadata;
import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.forms.VerifyProfileTest;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.EventAuditingAttributeChangeListener;
/**
*
@ -164,6 +172,78 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
return uam.getValidators().get(validatorId);
}
@Test
public void testUpdateProfileEventWithAdditionalAttributesAuditing() throws IOException {
setUserProfileConfiguration("{\"attributes\": ["
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"attr1\"," + PERMISSIONS_ALL + "},"
+ "{\"name\": \"attr2\"," + PERMISSIONS_ALL + "}"
+ "]}");
UserRepresentation user = getUser();
String originalUsername = user.getUsername();
String originalFirstName = user.getFirstName();
String originalLastName = user.getLastName();
String originalEmail = user.getEmail();
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes());
try {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
realmRep.setRegistrationEmailAsUsername(false);
adminClient.realm("test").update(realmRep);
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 = 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)
.detail(Details.UPDATED_EMAIL, "bobby@localhost")
.detail(Details.PREVIOUS_FIRST_NAME, originalFirstName)
.detail(Details.PREVIOUS_LAST_NAME, originalLastName)
.detail(Details.UPDATED_FIRST_NAME, "Homer")
.detail(Details.UPDATED_LAST_NAME, "Simpsons")
.detail(Details.PREF_UPDATED+"attr2", "val2")
.assertEvent();
events.assertEmpty();
} finally {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
realmRep.setEditUsernameAllowed(true);
adminClient.realm("test").update(realmRep);
user.setUsername(originalUsername);
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());
}
}
@Test
public void testUpdateProfileEvent() throws IOException {
setUserProfileConfiguration("{\"attributes\": ["
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ "{\"name\": \"attr1\"," + PERMISSIONS_ALL + "},"
+ "{\"name\": \"attr2\"," + PERMISSIONS_ALL + "}"
+ "]}");
super.testUpdateProfileEvent();
}
@Test
@Override
public void testUpdateProfile() throws IOException {

View file

@ -38,6 +38,7 @@ import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginUpdateProfileEditUsernameAllowedPage;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.userprofile.UserProfileContext;
import static org.junit.Assert.assertFalse;
@ -358,7 +359,7 @@ public class RequiredActionUpdateProfileTest extends AbstractTestRealmKeycloakTe
updateProfilePage.update("New first", "New last", "new@email.com", "test-user@localhost");
events.expectRequiredAction(EventType.UPDATE_PROFILE).detail(Details.PREVIOUS_EMAIL, "test-user@localhost").detail(Details.UPDATED_EMAIL, "new@email.com").assertEvent();
events.expectRequiredAction(EventType.UPDATE_PROFILE).detail(Details.CONTEXT, UserProfileContext.UPDATE_PROFILE.name()).detail(Details.PREVIOUS_EMAIL, "test-user@localhost").detail(Details.UPDATED_EMAIL, "new@email.com").assertEvent();
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());

View file

@ -47,6 +47,7 @@ import org.keycloak.testsuite.forms.VerifyProfileTest;
import org.keycloak.testsuite.pages.AppPage.RequestType;
import org.keycloak.testsuite.util.ClientScopeBuilder;
import org.keycloak.testsuite.util.KeycloakModelUtils;
import org.keycloak.userprofile.EventAuditingAttributeChangeListener;
import org.openqa.selenium.By;
/**
@ -418,6 +419,12 @@ public class RequiredActionUpdateProfileWithUserProfileTest extends RequiredActi
//submit OK
updateProfilePage.updateWithDepartment("FirstCC", "LastCC", "DepartmentCC", USERNAME1, USERNAME1);
// we also test additional attribute configured to be audited in the event
events.expectRequiredAction(EventType.UPDATE_PROFILE)
.detail(Details.PREVIOUS_FIRST_NAME, "Tom").detail(Details.UPDATED_FIRST_NAME, "FirstCC")
.detail(Details.PREVIOUS_LAST_NAME, "Brady").detail(Details.UPDATED_LAST_NAME, "LastCC")
.detail(Details.PREF_UPDATED + "department", "DepartmentCC")
.assertEvent();
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
@ -451,6 +458,12 @@ public class RequiredActionUpdateProfileWithUserProfileTest extends RequiredActi
//submit OK
updateProfilePage.updateWithDepartment("FirstCC", "LastCC", "DepartmentCC", USERNAME1, USERNAME1);
events.expectRequiredAction(EventType.UPDATE_PROFILE).client(client_scope_optional.getClientId())
.detail(Details.PREVIOUS_FIRST_NAME, "Tom").detail(Details.UPDATED_FIRST_NAME, "FirstCC")
.detail(Details.PREVIOUS_LAST_NAME, "Brady").detail(Details.UPDATED_LAST_NAME, "LastCC")
.assertEvent();
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));

View file

@ -9,24 +9,30 @@ import com.google.common.collect.ImmutableMap;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.jboss.arquillian.drone.api.annotation.Drone;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.broker.provider.HardcodedUserSessionAttributeMapper;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.forms.VerifyProfileTest;
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
import org.keycloak.testsuite.util.MailServer;
import org.keycloak.testsuite.util.MailServerConfiguration;
import org.keycloak.testsuite.util.SecondBrowser;
import org.keycloak.userprofile.UserProfileContext;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebDriver;
@ -56,6 +62,9 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractInitializedBa
@SecondBrowser
protected WebDriver driver2;
@Rule
public AssertEvents events = new AssertEvents(this);
protected void enableDynamicUserProfile() {
RealmResource rr = adminClient.realm(bc.consumerRealmName());
@ -972,6 +981,84 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractInitializedBa
assertTrue(consumerRealm.users().get(linkedUserId).toRepresentation().isEmailVerified());
}
@Test
public void testEventsOnUpdateProfileNoEmailChange() {
updateExecutions(AbstractBrokerTest::setUpMissingUpdateProfileOnFirstLogin);
createUser(bc.providerRealmName(), "no-first-name", "password", null, "LastName", "no-first-name@localhost.com");
driver.navigate().to(getAccountUrl(getConsumerRoot(), bc.consumerRealmName()));
log.debug("Clicking social " + bc.getIDPAlias());
loginPage.clickSocial(bc.getIDPAlias());
waitForPage(driver, "sign in to", true);
Assert.assertTrue("Driver should be on the provider realm page right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.providerRealmName() + "/"));
log.debug("Logging in");
loginPage.login("no-first-name", "password");
waitForPage(driver, "update account information", false);
updateAccountInformationPage.assertCurrent();
updateAccountInformationPage.updateAccountInformation("FirstName", "LastName");
waitForAccountManagementTitle();
accountUpdateProfilePage.assertCurrent();
Assert.assertEquals("FirstName", accountUpdateProfilePage.getFirstName());
Assert.assertEquals("LastName", accountUpdateProfilePage.getLastName());
Assert.assertEquals("no-first-name@localhost.com", accountUpdateProfilePage.getEmail());
Assert.assertEquals("no-first-name", accountUpdateProfilePage.getUsername());
RealmRepresentation consumerRealmRep = adminClient.realm(bc.consumerRealmName()).toRepresentation();
events.expectAccount(EventType.LOGIN).realm(consumerRealmRep).user(Matchers.any(String.class)).session(Matchers.any(String.class))
.detail(Details.IDENTITY_PROVIDER_USERNAME, "no-first-name")
.detail(Details.REGISTER_METHOD, "broker")
.assertEvent(getFirstConsumerEvent());
}
@Test
public void testEventsOnUpdateProfileWithEmailChange() {
updateExecutions(AbstractBrokerTest::setUpMissingUpdateProfileOnFirstLogin);
createUser(bc.providerRealmName(), "no-first-name", "password", null, "LastName", "no-first-name@localhost.com");
driver.navigate().to(getAccountUrl(getConsumerRoot(), bc.consumerRealmName()));
log.debug("Clicking social " + bc.getIDPAlias());
loginPage.clickSocial(bc.getIDPAlias());
waitForPage(driver, "sign in to", true);
Assert.assertTrue("Driver should be on the provider realm page right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.providerRealmName() + "/"));
log.debug("Logging in");
loginPage.login("no-first-name", "password");
waitForPage(driver, "update account information", false);
updateAccountInformationPage.assertCurrent();
updateAccountInformationPage.updateAccountInformation("new-email@localhost.com","FirstName", "LastName");
waitForAccountManagementTitle();
accountUpdateProfilePage.assertCurrent();
Assert.assertEquals("FirstName", accountUpdateProfilePage.getFirstName());
Assert.assertEquals("LastName", accountUpdateProfilePage.getLastName());
Assert.assertEquals("new-email@localhost.com", accountUpdateProfilePage.getEmail());
Assert.assertEquals("no-first-name", accountUpdateProfilePage.getUsername());
RealmRepresentation consumerRealmRep = adminClient.realm(bc.consumerRealmName()).toRepresentation();
events.expectAccount(EventType.UPDATE_EMAIL).realm(consumerRealmRep).user((String)null).session((String) null)
.detail(Details.CONTEXT, UserProfileContext.IDP_REVIEW.name())
.detail(Details.IDENTITY_PROVIDER_USERNAME, "no-first-name")
.detail(Details.PREVIOUS_EMAIL, "no-first-name@localhost.com")
.detail(Details.UPDATED_EMAIL, "new-email@localhost.com")
.assertEvent(getFirstConsumerEvent());
events.expectAccount(EventType.LOGIN).realm(consumerRealmRep).user(Matchers.any(String.class)).session(Matchers.any(String.class))
.detail(Details.IDENTITY_PROVIDER_USERNAME, "no-first-name")
.detail(Details.REGISTER_METHOD, "broker")
.assertEvent(events.poll());
}
protected EventRepresentation getFirstConsumerEvent() {
String providerRealmId = adminClient.realm(bc.providerRealmName()).toRepresentation().getId();
EventRepresentation er = events.poll();
while(er != null && providerRealmId.equals(er.getRealmId())) {
er = events.poll();
}
return er;
}
/**
* Refers to in old test suite: org.keycloak.testsuite.broker.AbstractKeycloakIdentityProviderTest.testSuccessfulAuthenticationUpdateProfileOnMissing_missingEmail
@ -991,6 +1078,7 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractInitializedBa
loginPage.login("no-first-name", "password");
waitForPage(driver, "update account information", false);
updateAccountInformationPage.assertCurrent();
updateAccountInformationPage.updateAccountInformation("FirstName", "LastName");
waitForAccountManagementTitle();
@ -1000,7 +1088,6 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractInitializedBa
Assert.assertEquals("no-first-name@localhost.com", accountUpdateProfilePage.getEmail());
Assert.assertEquals("no-first-name", accountUpdateProfilePage.getUsername());
logoutFromRealm(getProviderRoot(), bc.providerRealmName());
logoutFromRealm(getConsumerRoot(), bc.consumerRealmName());
createUser(bc.providerRealmName(), "no-last-name", "password", "FirstName", null, "no-last-name@localhost.com");

View file

@ -48,7 +48,6 @@ import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.SetDefaultProvider;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.AppPage.RequestType;
import org.keycloak.testsuite.pages.LoginPage;
@ -58,7 +57,8 @@ import org.keycloak.testsuite.util.KeycloakModelUtils;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.userprofile.UserProfileSpi;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.EventAuditingAttributeChangeListener;
import org.openqa.selenium.By;
/**
@ -321,9 +321,12 @@ public class VerifyProfileTest extends AbstractTestRealmKeycloakTest {
verifyProfilePage.update("First", "Last", "Department");
//event after profile is updated
// we also test additional attribute configured to be audited in the event
events.expectRequiredAction(EventType.UPDATE_PROFILE).user(user5Id)
.detail(Details.CONTEXT, UserProfileContext.UPDATE_PROFILE.name())
.detail(Details.PREVIOUS_FIRST_NAME, "ExistingFirst").detail(Details.UPDATED_FIRST_NAME, "First")
.detail(Details.PREVIOUS_LAST_NAME, "ExistingLast").detail(Details.UPDATED_LAST_NAME, "Last")
.detail(Details.PREF_UPDATED+"department", "Department")
.assertEvent();
}

View file

@ -43,7 +43,6 @@ import java.util.function.Consumer;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.common.Profile;
import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.Constants;
@ -53,13 +52,9 @@ import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.messages.Messages;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.SetDefaultProvider;
import org.keycloak.testsuite.runonserver.RunOnServer;
import org.keycloak.userprofile.AttributeGroupMetadata;
import org.keycloak.userprofile.DeclarativeUserProfileProvider;
import org.keycloak.userprofile.UserProfileSpi;
import org.keycloak.userprofile.config.UPAttribute;
import org.keycloak.userprofile.config.UPAttributePermissions;
import org.keycloak.userprofile.config.UPAttributeRequired;
@ -459,8 +454,15 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile = provider.create(UserProfileContext.ACCOUNT, attributes, user);
Set<String> attributesUpdated = new HashSet<>();
Map<String, String> attributesUpdatedOldValues = new HashMap<>();
attributesUpdatedOldValues.put(UserModel.FIRST_NAME, "Joe");
attributesUpdatedOldValues.put(UserModel.LAST_NAME, "Doe");
profile.update((attributeName, userModel) -> assertTrue(attributesUpdated.add(attributeName)));
profile.update((attributeName, userModel, oldValue) -> {
assertTrue(attributesUpdated.add(attributeName));
assertEquals(attributesUpdatedOldValues.get(attributeName), getSingleValue(oldValue));
assertEquals(attributes.get(attributeName), userModel.getFirstAttribute(attributeName));
});
assertThat(attributesUpdated, containsInAnyOrder(UserModel.FIRST_NAME, UserModel.LAST_NAME, UserModel.EMAIL));
@ -470,13 +472,19 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile = provider.create(UserProfileContext.ACCOUNT, attributes, user);
attributesUpdated.clear();
profile.update((attributeName, userModel) -> assertTrue(attributesUpdated.add(attributeName)));
profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
assertThat(attributesUpdated, containsInAnyOrder("business.address"));
assertEquals("fixed-business-address", user.getFirstAttribute("business.address"));
}
private static String getSingleValue(List<String> vals) {
if(vals==null || vals.isEmpty())
return null;
return vals.get(0);
}
@Test
public void testReadonlyUpdates() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testReadonlyUpdates);
@ -505,7 +513,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
Set<String> attributesUpdated = new HashSet<>();
profile.update((attributeName, userModel) -> assertTrue(attributesUpdated.add(attributeName)));
profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
assertThat(attributesUpdated, containsInAnyOrder("department"));
@ -556,7 +564,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
Set<String> attributesUpdated = new HashSet<>();
profile.update((attributeName, userModel) -> assertTrue(attributesUpdated.add(attributeName)));
profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
assertThat(attributesUpdated, containsInAnyOrder("department", "address", "phone"));
provider.setConfiguration("{\"attributes\": [{\"name\": \"department\", \"permissions\": {\"edit\": [\"admin\"]}},"
@ -566,7 +574,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
attributes.put("department", "foo");
attributes.put("phone", "foo");
profile = provider.create(UserProfileContext.USER_API, attributes, user);
profile.update((attributeName, userModel) -> assertTrue(attributesUpdated.add(attributeName)));
profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
assertThat(attributesUpdated, containsInAnyOrder("department", "phone"));
assertTrue(user.getAttributes().containsKey("address"));
@ -578,7 +586,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
attributes.put("address", "bar");
attributesUpdated.clear();
profile = provider.create(UserProfileContext.USER_API, attributes, user);
profile.update((attributeName, userModel) -> assertTrue(attributesUpdated.add(attributeName)));
profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
assertThat(attributesUpdated, containsInAnyOrder("address"));
assertEquals("bar", user.getFirstAttribute("address"));
assertEquals("foo", user.getFirstAttribute("phone"));
@ -587,7 +595,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
attributes.remove("address");
attributesUpdated.clear();
profile = provider.create(UserProfileContext.USER_API, attributes, user);
profile.update((attributeName, userModel) -> assertTrue(attributesUpdated.add(attributeName)));
profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
assertThat(attributesUpdated, containsInAnyOrder("address"));
assertFalse(user.getAttributes().containsKey("address"));
assertTrue(user.getAttributes().containsKey("phone"));
@ -597,7 +605,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
attributes.put(prefixedAttributeName, "foo");
attributesUpdated.clear();
profile = provider.create(UserProfileContext.USER_API, attributes, user);
profile.update((attributeName, userModel) -> assertTrue(attributesUpdated.add(attributeName)));
profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
assertTrue(attributesUpdated.isEmpty());
assertFalse(user.getAttributes().containsKey("prefixedAttributeName"));
}