keycloak/testsuite/model/src/test/java/org/keycloak/testsuite/model/user/UserModelTest.java

382 lines
17 KiB
Java

/*
* Copyright 2020 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.model.user;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.Constants;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RealmProvider;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
import org.keycloak.models.map.realm.MapRealmProviderFactory;
import org.keycloak.models.map.user.MapUserProviderFactory;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.UserStorageProviderFactory;
import org.keycloak.storage.UserStorageProviderModel;
import org.keycloak.storage.UserStorageUtil;
import org.keycloak.storage.user.UserRegistrationProvider;
import org.keycloak.testsuite.model.KeycloakModelTest;
import org.keycloak.testsuite.model.RequireProvider;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.hamcrest.Matchers;
import org.junit.Test;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
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.junit.Assume.assumeThat;
/**
*
* @author hmlnarik
*/
@RequireProvider(UserProvider.class)
@RequireProvider(RealmProvider.class)
public class UserModelTest extends KeycloakModelTest {
protected static final int NUM_GROUPS = 100;
private static final int FIRST_DELETED_USER_INDEX = 10;
private static final int LAST_DELETED_USER_INDEX = 90;
private static final int DELETED_USER_COUNT = LAST_DELETED_USER_INDEX - FIRST_DELETED_USER_INDEX;
private String realmId;
private String realm1Id;
private String realm2Id;
private final List<String> groupIds = new ArrayList<>(NUM_GROUPS);
private String userFederationId;
@Override
public void createEnvironment(KeycloakSession s) {
RealmModel realm = createRealm(s, "realm");
realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName()));
this.realmId = realm.getId();
IntStream.range(0, NUM_GROUPS).forEach(i -> {
groupIds.add(s.groups().createGroup(realm, "group-" + i).getId());
});
}
@Override
public void cleanEnvironment(KeycloakSession s) {
s.realms().removeRealm(realmId);
if (realm1Id != null) s.realms().removeRealm(realm1Id);
if (realm2Id != null) s.realms().removeRealm(realm2Id);
}
@Override
protected boolean isUseSameKeycloakSessionFactoryForAllThreads() {
return true;
}
private Void addRemoveUser(KeycloakSession session, int i) {
RealmModel realm = session.realms().getRealmByName("realm");
UserModel user = session.users().addUser(realm, "user-" + i);
IntStream.range(0, NUM_GROUPS / 20).forEach(gIndex -> {
user.joinGroup(session.groups().getGroupById(realm, groupIds.get((i + gIndex) % NUM_GROUPS)));
});
final UserModel obtainedUser = session.users().getUserById(realm, user.getId());
assertThat(obtainedUser, Matchers.notNullValue());
assertThat(obtainedUser.getUsername(), is("user-" + i));
Set<String> userGroupIds = obtainedUser.getGroupsStream().map(GroupModel::getName).collect(Collectors.toSet());
assertThat(userGroupIds, hasSize(NUM_GROUPS / 20));
assertThat(userGroupIds, hasItem("group-" + i));
assertThat(userGroupIds, hasItem("group-" + (i - 1 + (NUM_GROUPS / 20)) % NUM_GROUPS));
assertTrue(session.users().removeUser(realm, user));
assertFalse(session.users().removeUser(realm, user));
assertNull(session.users().getUserByUsername(realm, user.getUsername()));
return null;
}
@Test
@RequireProvider(value = UserProvider.class, only = {MapUserProviderFactory.PROVIDER_ID})
@RequireProvider(value = RealmProvider.class, only = {MapRealmProviderFactory.PROVIDER_ID})
public void testCaseSensitivityGetUserByUsername() {
realm1Id = inComittedTransaction((Function<KeycloakSession, String>) session -> {
RealmModel realm = session.realms().createRealm("realm1");
realm.setDefaultRole(session.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName()));
realm.setAttribute(Constants.REALM_ATTR_USERNAME_CASE_SENSITIVE, true);
return realm.getId();
});
withRealm(realm1Id, (session, realm) -> {
UserModel user1 = session.users().addUser(realm, "user");
UserModel user2 = session.users().addUser(realm, "USER");
assertThat(user1, not(nullValue()));
assertThat(user2, not(nullValue()));
assertThat(user1.getUsername(), equalTo("user"));
assertThat(user2.getUsername(), equalTo("USER"));
return null;
});
// try to query storage in a separate transaction to make sure that storage can handle case-sensitive usernames
withRealm(realm1Id, (session, realm) -> {
UserModel user1 = session.users().getUserByUsername(realm, "user");
UserModel user2 = session.users().getUserByUsername(realm, "USER");
assertThat(user1, not(nullValue()));
assertThat(user2, not(nullValue()));
assertThat(user1.getUsername(), equalTo("user"));
assertThat(user2.getUsername(), equalTo("USER"));
return null;
});
realm2Id = inComittedTransaction((Function<KeycloakSession, String>) session -> {
RealmModel realm = session.realms().createRealm("realm2");
realm.setDefaultRole(session.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName()));
realm.setAttribute(Constants.REALM_ATTR_USERNAME_CASE_SENSITIVE, false);
return realm.getId();
});
withRealm(realm2Id, (session, realm) -> {
UserModel user1 = session.users().addUser(realm, "user");
assertThat(user1, not(nullValue()));
try {
session.users().addUser(realm, "USER");
} catch (ModelDuplicateException e) {
return null; // expected
}
fail("ModelDuplicateException expected");
return null;
});
}
@Test
public void testAddRemoveUser() {
inRolledBackTransaction(1, this::addRemoveUser);
}
@Test
public void testAddRemoveUserConcurrent() {
IntStream.range(0,100).parallel().forEach(i -> inComittedTransaction(i, this::addRemoveUser));
}
@Test
public void testAddRemoveUsersInTheSameGroupConcurrent() {
final ConcurrentSkipListSet<String> userIds = new ConcurrentSkipListSet<>();
String groupId = groupIds.get(0);
// Create users and let them join first group
IntStream.range(0, 100).parallel().forEach(index -> inComittedTransaction(index, (session, i) -> {
final RealmModel realm = session.realms().getRealm(realmId);
final UserModel user = session.users().addUser(realm, "user-" + i);
user.joinGroup(session.groups().getGroupById(realm, groupId));
userIds.add(user.getId());
return null;
}));
inComittedTransaction(session -> {
final RealmModel realm = session.realms().getRealm(realmId);
final GroupModel group = session.groups().getGroupById(realm, groupId);
assertThat(session.users().getGroupMembersStream(realm, group).count(), is(100L));
});
// Some of the transactions may fail due to conflicts as there are many parallel request, so repeat until all users are removed
Set<String> remainingUserIds = new HashSet<>();
do {
userIds.stream().parallel().forEach(index -> inComittedTransaction(index, (session, userId) -> {
final RealmModel realm = session.realms().getRealm(realmId);
final UserModel user = session.users().getUserById(realm, userId);
log.debugf("Remove user %s: %s", userId, session.users().removeUser(realm, user));
return null;
}, null, (session, userId) -> remainingUserIds.add(userId) ));
userIds.clear();
userIds.addAll(remainingUserIds);
remainingUserIds.clear();
} while (! userIds.isEmpty());
inComittedTransaction(session -> {
final RealmModel realm = session.realms().getRealm(realmId);
final GroupModel group = session.groups().getGroupById(realm, groupId);
assertThat(session.users().getGroupMembersStream(realm, group).collect(Collectors.toList()), Matchers.empty());
});
}
@Test
@RequireProvider(UserStorageProvider.class)
public void testAddDirtyRemoveFederationUser() {
registerUserFederationWithRealm();
withRealm(realmId, (session, realm) -> session.users().addUser(realm, "user-A"));
// Remove user _from the federation_, simulates eg. user being removed from LDAP without Keycloak knowing
withRealm(realmId, (session, realm) -> {
final UserStorageProvider instance = getUserFederationInstance(session, realm);
log.debugf("Removing selected users from backend");
final UserModel user = session.users().getUserByUsername(realm, "user-A");
((UserRegistrationProvider) instance).removeUser(realm, user);
return null;
});
withRealm(realmId, (session, realm) -> {
if (UserStorageUtil.userCache(session) != null) {
UserStorageUtil.userCache(session).clear();
}
final UserModel user = session.users().getUserByUsername(realm, "user-A");
assertThat("User should not be found in the main store", user, Matchers.nullValue());
return null;
});
}
@Test
@RequireProvider(UserStorageProvider.class)
public void testAddDirtyRemoveFederationUsersInTheSameGroupConcurrent() {
final ConcurrentSkipListSet<String> userIds = new ConcurrentSkipListSet<>();
String groupId = groupIds.get(0);
registerUserFederationWithRealm();
// Create users and let them join first group
IntStream.range(0, 100).parallel().forEach(index -> inComittedTransaction(index, (session, i) -> {
final RealmModel realm = session.realms().getRealm(realmId);
final UserModel user = session.users().addUser(realm, "user-" + i);
user.joinGroup(session.groups().getGroupById(realm, groupId));
log.infof("Created user with id: %s", user.getId());
userIds.add(user.getId());
return null;
}));
// Remove users _from the federation_, simulates eg. user being removed from LDAP without Keycloak knowing
withRealm(realmId, (session, realm) -> {
UserStorageProvider instance = getUserFederationInstance(session, realm);
log.debugf("Removing selected users from backend");
IntStream.range(FIRST_DELETED_USER_INDEX, LAST_DELETED_USER_INDEX).forEach(j -> {
final UserModel user = session.users().getUserByUsername(realm, "user-" + j);
((UserRegistrationProvider) instance).removeUser(realm, user);
});
return null;
});
IntStream.range(0, 7).parallel().forEach(index -> withRealm(realmId, (session, realm) -> {
final GroupModel group = session.groups().getGroupById(realm, groupId);
assertThat(session.users().getGroupMembersStream(realm, group).count(), is(100L - DELETED_USER_COUNT));
return null;
}));
inComittedTransaction(session -> {
// If we are using cache, we need to invalidate all users because after removing users from external
// provider cache may not be cleared and it may be the case, that cache is the only place that is having
// a reference to removed users. Our importValidation method won't be called at all for removed users
// because they are not present in any storage. However, when we get users by id cache may still be hit
// since it is not alerted in any way when users are removed from external provider. Hence we need to clear
// the cache manually.
if (UserStorageUtil.userCache(session) != null) {
UserStorageUtil.userCache(session).clear();
}
return null;
});
// Now delete the users, and count those that were not found to be deleted. This should be equal to the number
// of users removed directly in the user federation.
// Some of the transactions may fail due to conflicts as there are many parallel request, so repeat until all users are removed
AtomicInteger notFoundUsers = new AtomicInteger();
Set<String> remainingUserIds = new HashSet<>();
do {
userIds.stream().parallel().forEach(index -> inComittedTransaction(index, (session, userId) -> {
final RealmModel realm = session.realms().getRealm(realmId);
final UserModel user = session.users().getUserById(realm, userId);
if (user != null) {
log.debugf("Deleting user: %s", userId);
session.users().removeUser(realm, user);
} else {
log.debugf("Failed deleting user: %s", userId);
notFoundUsers.incrementAndGet();
}
return null;
}, null, (session, userId) -> {
log.debugf("Could not delete user %s", userId);
remainingUserIds.add(userId);
}));
userIds.clear();
userIds.addAll(remainingUserIds);
remainingUserIds.clear();
} while (! userIds.isEmpty());
assertThat(notFoundUsers.get(), is(DELETED_USER_COUNT));
withRealm(realmId, (session, realm) -> {
final GroupModel group = session.groups().getGroupById(realm, groupId);
assertThat(session.users().getGroupMembersStream(realm, group).collect(Collectors.toList()), Matchers.empty());
return null;
});
}
private void registerUserFederationWithRealm() {
getParameters(UserStorageProviderModel.class).forEach(fs -> inComittedTransaction(session -> {
assumeThat("Cannot handle more than 1 user federation provider", userFederationId, Matchers.nullValue());
RealmModel realm = session.realms().getRealm(realmId);
fs.setParentId(realmId);
fs.setImportEnabled(true);
ComponentModel res = realm.addComponentModel(fs);
userFederationId = res.getId();
log.infof("Added %s user federation provider: %s", fs.getName(), userFederationId);
}));
}
private UserStorageProvider getUserFederationInstance(KeycloakSession session, final RealmModel realm) throws RuntimeException {
UserStorageProvider instance = (UserStorageProvider)session.getAttribute(userFederationId);
if (instance == null) {
ComponentModel model = realm.getComponent(userFederationId);
UserStorageProviderFactory factory = (UserStorageProviderFactory)session.getKeycloakSessionFactory().getProviderFactory(UserStorageProvider.class, model.getProviderId());
instance = factory.create(session, model);
if (instance == null) {
throw new RuntimeException("UserStorageProvideFactory (of type " + factory.getClass().getName() + ") produced a null instance");
}
session.enlistForClose(instance);
session.setAttribute(userFederationId, instance);
}
return instance;
}
}