mirror of https://github.com/apache/cassandra
Merge branch 'cassandra-4.1' into cassandra-5.0
* cassandra-4.1: Rate limit password changes
This commit is contained in:
commit
a2e55684de
|
|
@ -15,6 +15,7 @@ Merged from 4.1:
|
||||||
* Disk usage guardrail cannot be disabled when failure threshold is reached (CASSANDRA-21057)
|
* Disk usage guardrail cannot be disabled when failure threshold is reached (CASSANDRA-21057)
|
||||||
* ReadCommandController should close fast to avoid deadlock when building secondary index (CASSANDRA-19564)
|
* ReadCommandController should close fast to avoid deadlock when building secondary index (CASSANDRA-19564)
|
||||||
Merged from 4.0:
|
Merged from 4.0:
|
||||||
|
* Rate limit password changes (CASSANDRA-21202)
|
||||||
* Node does not send multiple inflight echos (CASSANDRA-18866)
|
* Node does not send multiple inflight echos (CASSANDRA-18866)
|
||||||
* Obsolete expired SSTables before compaction starts (CASSANDRA-19776)
|
* Obsolete expired SSTables before compaction starts (CASSANDRA-19776)
|
||||||
* Switch lz4-java to at.yawk.lz4 version due to CVE (CASSANDRA-21052)
|
* Switch lz4-java to at.yawk.lz4 version due to CVE (CASSANDRA-21052)
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,18 @@ import java.util.function.Supplier;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import com.google.common.base.Strings;
|
import com.google.common.base.Strings;
|
||||||
import com.google.common.collect.ImmutableSet;
|
import com.google.common.collect.ImmutableSet;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.mindrot.jbcrypt.BCrypt;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import org.apache.cassandra.concurrent.ScheduledExecutors;
|
import org.apache.cassandra.concurrent.ScheduledExecutors;
|
||||||
|
import org.apache.cassandra.config.CassandraRelevantProperties;
|
||||||
import org.apache.cassandra.config.DatabaseDescriptor;
|
import org.apache.cassandra.config.DatabaseDescriptor;
|
||||||
import org.apache.cassandra.schema.SchemaConstants;
|
import org.apache.cassandra.schema.SchemaConstants;
|
||||||
import org.apache.cassandra.cql3.*;
|
import org.apache.cassandra.cql3.*;
|
||||||
|
|
@ -49,7 +53,7 @@ import org.apache.cassandra.transport.Dispatcher;
|
||||||
import org.apache.cassandra.transport.messages.ResultMessage;
|
import org.apache.cassandra.transport.messages.ResultMessage;
|
||||||
import org.apache.cassandra.utils.ByteBufferUtil;
|
import org.apache.cassandra.utils.ByteBufferUtil;
|
||||||
import org.apache.cassandra.utils.NoSpamLogger;
|
import org.apache.cassandra.utils.NoSpamLogger;
|
||||||
import org.mindrot.jbcrypt.BCrypt;
|
import org.apache.cassandra.utils.Clock;
|
||||||
|
|
||||||
import static org.apache.cassandra.config.CassandraRelevantProperties.AUTH_BCRYPT_GENSALT_LOG2_ROUNDS;
|
import static org.apache.cassandra.config.CassandraRelevantProperties.AUTH_BCRYPT_GENSALT_LOG2_ROUNDS;
|
||||||
import static org.apache.cassandra.service.QueryState.forInternalCalls;
|
import static org.apache.cassandra.service.QueryState.forInternalCalls;
|
||||||
|
|
@ -118,6 +122,19 @@ public class CassandraRoleManager implements IRoleManager
|
||||||
|
|
||||||
private static final int GENSALT_LOG2_ROUNDS = getGensaltLogRounds();
|
private static final int GENSALT_LOG2_ROUNDS = getGensaltLogRounds();
|
||||||
|
|
||||||
|
private static int PASSWORD_UPDATE_MIN_INTERVAL_MS = CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt();
|
||||||
|
// in-memory protection against excessive loadRoleWithWritetimeStatement queries
|
||||||
|
private static Cache<String, Boolean> recentPasswordUpdates = Caffeine.newBuilder()
|
||||||
|
.expireAfterWrite(PASSWORD_UPDATE_MIN_INTERVAL_MS, TimeUnit.MILLISECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
public static synchronized void updatePasswordUpdateMinInterval(int newInterval)
|
||||||
|
{
|
||||||
|
recentPasswordUpdates = Caffeine.newBuilder().expireAfterWrite(newInterval, TimeUnit.MILLISECONDS).build();
|
||||||
|
PASSWORD_UPDATE_MIN_INTERVAL_MS = newInterval;
|
||||||
|
}
|
||||||
|
|
||||||
static int getGensaltLogRounds()
|
static int getGensaltLogRounds()
|
||||||
{
|
{
|
||||||
int rounds = AUTH_BCRYPT_GENSALT_LOG2_ROUNDS.getInt(10);
|
int rounds = AUTH_BCRYPT_GENSALT_LOG2_ROUNDS.getInt(10);
|
||||||
|
|
@ -129,6 +146,7 @@ public class CassandraRoleManager implements IRoleManager
|
||||||
|
|
||||||
private SelectStatement loadRoleStatement;
|
private SelectStatement loadRoleStatement;
|
||||||
private SelectStatement loadIdentityStatement;
|
private SelectStatement loadIdentityStatement;
|
||||||
|
private SelectStatement loadRoleWithWritetimeStatement;
|
||||||
|
|
||||||
private final Set<Option> supportedOptions;
|
private final Set<Option> supportedOptions;
|
||||||
private final Set<Option> alterableOptions;
|
private final Set<Option> alterableOptions;
|
||||||
|
|
@ -218,6 +236,10 @@ public class CassandraRoleManager implements IRoleManager
|
||||||
loadRoleStatement = (SelectStatement) prepare("SELECT * from %s.%s WHERE role = ?",
|
loadRoleStatement = (SelectStatement) prepare("SELECT * from %s.%s WHERE role = ?",
|
||||||
SchemaConstants.AUTH_KEYSPACE_NAME,
|
SchemaConstants.AUTH_KEYSPACE_NAME,
|
||||||
AuthKeyspace.ROLES);
|
AuthKeyspace.ROLES);
|
||||||
|
|
||||||
|
loadRoleWithWritetimeStatement = (SelectStatement) prepare("SELECT writetime(salted_hash) AS salted_hash_writetime from %s.%s WHERE role = ?",
|
||||||
|
SchemaConstants.AUTH_KEYSPACE_NAME,
|
||||||
|
AuthKeyspace.ROLES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -276,6 +298,9 @@ public class CassandraRoleManager implements IRoleManager
|
||||||
|
|
||||||
public void alterRole(AuthenticatedUser performer, RoleResource role, RoleOptions options)
|
public void alterRole(AuthenticatedUser performer, RoleResource role, RoleOptions options)
|
||||||
{
|
{
|
||||||
|
if (options.getPassword().isPresent())
|
||||||
|
enforcePasswordUpdateRateLimit(performer, role.getRoleName());
|
||||||
|
|
||||||
// Unlike most of the other data access methods here, this does not use a
|
// Unlike most of the other data access methods here, this does not use a
|
||||||
// prepared statement in order to allow the set of assignments to be variable.
|
// prepared statement in order to allow the set of assignments to be variable.
|
||||||
String assignments = optionsToAssignments(options.getOptions());
|
String assignments = optionsToAssignments(options.getOptions());
|
||||||
|
|
@ -627,6 +652,49 @@ public class CassandraRoleManager implements IRoleManager
|
||||||
.collect(Collectors.joining(","));
|
.collect(Collectors.joining(","));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit password updates on each role.
|
||||||
|
* @throws OverloadedException if the password was changed within ROLE_PASSWORD_UPDATE_INTERVAL
|
||||||
|
*/
|
||||||
|
private void enforcePasswordUpdateRateLimit(AuthenticatedUser performer, String roleName)
|
||||||
|
{
|
||||||
|
if (PASSWORD_UPDATE_MIN_INTERVAL_MS <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (Boolean.TRUE != recentPasswordUpdates.getIfPresent(roleName))
|
||||||
|
{
|
||||||
|
QueryOptions options = QueryOptions.forInternalCalls(consistencyForRole(roleName),
|
||||||
|
Collections.singletonList(ByteBufferUtil.bytes(roleName)));
|
||||||
|
|
||||||
|
ResultMessage.Rows rows = select(loadRoleWithWritetimeStatement, options);
|
||||||
|
boolean hasRecentPasswordUpdates = !rows.result.isEmpty();
|
||||||
|
if (hasRecentPasswordUpdates)
|
||||||
|
{
|
||||||
|
UntypedResultSet.Row row = UntypedResultSet.create(rows.result).one();
|
||||||
|
|
||||||
|
hasRecentPasswordUpdates = row.has("salted_hash_writetime")
|
||||||
|
&& PASSWORD_UPDATE_MIN_INTERVAL_MS >= (Clock.Global.currentTimeMillis() - TimeUnit.MICROSECONDS.toMillis(row.getLong("salted_hash_writetime")));
|
||||||
|
}
|
||||||
|
if (!hasRecentPasswordUpdates)
|
||||||
|
{
|
||||||
|
recentPasswordUpdates.put(roleName, Boolean.TRUE);
|
||||||
|
logger.info(String.format("Password changing for role %s by %s", roleName, performer.getName()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String failure = String.format("Password for role %s can only be changed every %sms.", roleName, PASSWORD_UPDATE_MIN_INTERVAL_MS);
|
||||||
|
logger.warn(String.format("%s [performer: %s]", failure, performer.getName()));
|
||||||
|
throw new OverloadedException(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static ConsistencyLevel consistencyForRole(String role)
|
||||||
|
{
|
||||||
|
if (role.equals(DEFAULT_SUPERUSER_NAME))
|
||||||
|
return ConsistencyLevel.QUORUM;
|
||||||
|
else
|
||||||
|
return ConsistencyLevel.LOCAL_ONE;
|
||||||
|
}
|
||||||
|
|
||||||
private static String hashpw(String password)
|
private static String hashpw(String password)
|
||||||
{
|
{
|
||||||
return BCrypt.hashpw(password, BCrypt.gensalt(GENSALT_LOG2_ROUNDS));
|
return BCrypt.hashpw(password, BCrypt.gensalt(GENSALT_LOG2_ROUNDS));
|
||||||
|
|
|
||||||
|
|
@ -424,6 +424,8 @@ public enum CassandraRelevantProperties
|
||||||
*/
|
*/
|
||||||
RESET_BOOTSTRAP_PROGRESS("cassandra.reset_bootstrap_progress"),
|
RESET_BOOTSTRAP_PROGRESS("cassandra.reset_bootstrap_progress"),
|
||||||
RING_DELAY("cassandra.ring_delay_ms"),
|
RING_DELAY("cassandra.ring_delay_ms"),
|
||||||
|
/** How often a role's password can be changed */
|
||||||
|
ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS("cassandra.role_password_update_min_interval_in_ms", "5000"),
|
||||||
|
|
||||||
// SAI specific properties
|
// SAI specific properties
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ public class AuditLoggerAuthTest
|
||||||
});
|
});
|
||||||
|
|
||||||
SUPERUSER_SETUP_DELAY_MS.setLong(0);
|
SUPERUSER_SETUP_DELAY_MS.setLong(0);
|
||||||
|
CassandraRoleManager.updatePasswordUpdateMinInterval(0);
|
||||||
embedded = ServerTestUtils.startEmbeddedCassandraService();
|
embedded = ServerTestUtils.startEmbeddedCassandraService();
|
||||||
|
|
||||||
executeWithCredentials(
|
executeWithCredentials(
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,10 @@ import org.junit.BeforeClass;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
import org.apache.cassandra.SchemaLoader;
|
import org.apache.cassandra.SchemaLoader;
|
||||||
|
import org.apache.cassandra.config.CassandraRelevantProperties;
|
||||||
import org.apache.cassandra.config.DatabaseDescriptor;
|
import org.apache.cassandra.config.DatabaseDescriptor;
|
||||||
import org.apache.cassandra.db.ColumnFamilyStore;
|
import org.apache.cassandra.db.ColumnFamilyStore;
|
||||||
|
import org.apache.cassandra.exceptions.OverloadedException;
|
||||||
import org.apache.cassandra.schema.KeyspaceParams;
|
import org.apache.cassandra.schema.KeyspaceParams;
|
||||||
import org.apache.cassandra.schema.SchemaConstants;
|
import org.apache.cassandra.schema.SchemaConstants;
|
||||||
import org.apache.cassandra.schema.TableMetadata;
|
import org.apache.cassandra.schema.TableMetadata;
|
||||||
|
|
@ -37,6 +39,7 @@ import org.apache.cassandra.service.StorageService;
|
||||||
import static org.apache.cassandra.auth.AuthTestUtils.*;
|
import static org.apache.cassandra.auth.AuthTestUtils.*;
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.junit.Assert.fail;
|
||||||
|
|
||||||
public class CassandraRoleManagerTest
|
public class CassandraRoleManagerTest
|
||||||
{
|
{
|
||||||
|
|
@ -151,6 +154,53 @@ public class CassandraRoleManagerTest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void testPasswordUpdateRateLimiting() throws Exception
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CassandraRoleManager.updatePasswordUpdateMinInterval(100);
|
||||||
|
|
||||||
|
IRoleManager roleManager = new LocalCassandraRoleManager();
|
||||||
|
roleManager.setup();
|
||||||
|
|
||||||
|
RoleResource testRole = RoleResource.role("test_password_role");
|
||||||
|
RoleOptions options = getLoginRoleOptions("initial_password");
|
||||||
|
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, testRole, options);
|
||||||
|
|
||||||
|
// Wait for the rate limit interval to pass
|
||||||
|
Thread.sleep(150);
|
||||||
|
|
||||||
|
// First password change should succeed
|
||||||
|
RoleOptions newOptions1 = getLoginRoleOptions("new_password_1");
|
||||||
|
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, newOptions1);
|
||||||
|
|
||||||
|
// Immediate second password change should fail with OverloadedException
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RoleOptions newOptions2 = getLoginRoleOptions("new_password_2");
|
||||||
|
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, newOptions2);
|
||||||
|
fail("Expected OverloadedException due to password update rate limiting");
|
||||||
|
}
|
||||||
|
catch (OverloadedException e)
|
||||||
|
{
|
||||||
|
assertEquals("Password for role test_password_role can only be changed every 100ms. ", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the rate limit interval to pass
|
||||||
|
Thread.sleep(150);
|
||||||
|
|
||||||
|
// After waiting, password change should succeed again
|
||||||
|
RoleOptions newOptions3 = getLoginRoleOptions("new_password_3");
|
||||||
|
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, newOptions3);
|
||||||
|
|
||||||
|
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, testRole);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CassandraRoleManager.updatePasswordUpdateMinInterval(CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void warmCacheWithEmptyTable()
|
public void warmCacheWithEmptyTable()
|
||||||
{
|
{
|
||||||
|
|
@ -167,4 +217,86 @@ public class CassandraRoleManagerTest
|
||||||
for (RoleResource expectedRole : expected)
|
for (RoleResource expectedRole : expected)
|
||||||
assertTrue(actual.stream().anyMatch(role -> role.resource.equals(expectedRole)));
|
assertTrue(actual.stream().anyMatch(role -> role.resource.equals(expectedRole)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void testPasswordUpdateRateLimitingDisabled() throws Exception
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CassandraRoleManager.updatePasswordUpdateMinInterval(0);
|
||||||
|
|
||||||
|
IRoleManager roleManager = new LocalCassandraRoleManager();
|
||||||
|
roleManager.setup();
|
||||||
|
|
||||||
|
RoleResource testRole = RoleResource.role("test_no_limit_role");
|
||||||
|
RoleOptions options = getLoginRoleOptions("initial_password");
|
||||||
|
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, testRole, options);
|
||||||
|
|
||||||
|
// Multiple rapid password changes should all succeed when rate limiting is disabled
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, getLoginRoleOptions("password_" + i));
|
||||||
|
|
||||||
|
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, testRole);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CassandraRoleManager.updatePasswordUpdateMinInterval(CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPasswordUpdateRateLimitingPerRole() throws Exception
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CassandraRoleManager.updatePasswordUpdateMinInterval(100);
|
||||||
|
|
||||||
|
IRoleManager roleManager = new LocalCassandraRoleManager();
|
||||||
|
roleManager.setup();
|
||||||
|
|
||||||
|
RoleResource role1 = RoleResource.role("test_role_1");
|
||||||
|
RoleResource role2 = RoleResource.role("test_role_2");
|
||||||
|
|
||||||
|
RoleOptions options1 = getLoginRoleOptions("password1");
|
||||||
|
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, role1, options1);
|
||||||
|
|
||||||
|
RoleOptions options2 = getLoginRoleOptions("password2");
|
||||||
|
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, role2, options2);
|
||||||
|
|
||||||
|
// Wait for the rate limit interval to pass
|
||||||
|
Thread.sleep(150);
|
||||||
|
|
||||||
|
RoleOptions newOptions1 = getLoginRoleOptions("new_password1");
|
||||||
|
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, role1, newOptions1);
|
||||||
|
|
||||||
|
RoleOptions newOptions2 = getLoginRoleOptions("new_password2");
|
||||||
|
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, role2, newOptions2);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RoleOptions newOptions1Again = getLoginRoleOptions("another_password1");
|
||||||
|
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, role1, newOptions1Again);
|
||||||
|
fail("Expected OverloadedException for test_role_1");
|
||||||
|
}
|
||||||
|
catch (OverloadedException e)
|
||||||
|
{
|
||||||
|
assertEquals("Password for role test_role_1 can only be changed every 100ms.", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, role1);
|
||||||
|
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, role2);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CassandraRoleManager.updatePasswordUpdateMinInterval(CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RoleOptions getLoginRoleOptions(String password)
|
||||||
|
{
|
||||||
|
RoleOptions roleOptions = new RoleOptions();
|
||||||
|
roleOptions.setOption(IRoleManager.Option.SUPERUSER, false);
|
||||||
|
roleOptions.setOption(IRoleManager.Option.LOGIN, true);
|
||||||
|
roleOptions.setOption(IRoleManager.Option.PASSWORD, password);
|
||||||
|
return roleOptions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ public class CreateAndAlterRoleTest extends CQLTester
|
||||||
@BeforeClass
|
@BeforeClass
|
||||||
public static void setUpClass()
|
public static void setUpClass()
|
||||||
{
|
{
|
||||||
|
CassandraRoleManager.updatePasswordUpdateMinInterval(0);
|
||||||
ServerTestUtils.daemonInitialization();
|
ServerTestUtils.daemonInitialization();
|
||||||
|
|
||||||
DatabaseDescriptor.setPermissionsValidity(0);
|
DatabaseDescriptor.setPermissionsValidity(0);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue