mirror of https://github.com/apache/cassandra
Rate limit password changes
Introduces property cassandra.role_password_update_interval_in_ms, with default value of 5000. Logging added when passwords on roles are changed or are rate-limited. patch by Mick Semb Wever; reviewed by Doug Rohrer, Jeremiah Jordan for CASSANDRA-21202
This commit is contained in:
parent
3ad4794aa0
commit
17c573f800
|
|
@ -1,4 +1,5 @@
|
|||
4.0.20
|
||||
* Rate limit password changes (CASSANDRA-21202)
|
||||
* Node does not send multiple inflight echos (CASSANDRA-18866)
|
||||
* Obsolete expired SSTables before compaction starts (CASSANDRA-19776)
|
||||
* No need to evict already prepared statements, as it creates a race condition between multiple threads (CASSANDRA-17401)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import java.util.function.Predicate;
|
|||
import java.util.stream.Collectors;
|
||||
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.base.Strings;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
|
|
@ -33,6 +35,7 @@ import org.slf4j.Logger;
|
|||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import org.apache.cassandra.concurrent.ScheduledExecutors;
|
||||
import org.apache.cassandra.config.CassandraRelevantProperties;
|
||||
import org.apache.cassandra.config.Config;
|
||||
import org.apache.cassandra.config.DatabaseDescriptor;
|
||||
import org.apache.cassandra.schema.SchemaConstants;
|
||||
|
|
@ -107,6 +110,19 @@ public class CassandraRoleManager implements IRoleManager
|
|||
public static final String GENSALT_LOG2_ROUNDS_PROPERTY = Config.PROPERTY_PREFIX + "auth_bcrypt_gensalt_log2_rounds";
|
||||
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()
|
||||
{
|
||||
int rounds = Integer.getInteger(GENSALT_LOG2_ROUNDS_PROPERTY, 10);
|
||||
|
|
@ -118,6 +134,7 @@ public class CassandraRoleManager implements IRoleManager
|
|||
}
|
||||
|
||||
private SelectStatement loadRoleStatement;
|
||||
private SelectStatement loadRoleWithWritetimeStatement;
|
||||
|
||||
private final Set<Option> supportedOptions;
|
||||
private final Set<Option> alterableOptions;
|
||||
|
|
@ -140,6 +157,11 @@ public class CassandraRoleManager implements IRoleManager
|
|||
loadRoleStatement = (SelectStatement) prepare("SELECT * from %s.%s WHERE role = ?",
|
||||
SchemaConstants.AUTH_KEYSPACE_NAME,
|
||||
AuthKeyspace.ROLES);
|
||||
|
||||
loadRoleWithWritetimeStatement = (SelectStatement) prepare("SELECT writetime(salted_hash) AS salted_hash_writetime from %s.%s WHERE role = ?",
|
||||
SchemaConstants.AUTH_KEYSPACE_NAME,
|
||||
AuthKeyspace.ROLES);
|
||||
|
||||
scheduleSetupTask(() -> {
|
||||
setupDefaultRole();
|
||||
return null;
|
||||
|
|
@ -188,6 +210,9 @@ public class CassandraRoleManager implements IRoleManager
|
|||
|
||||
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
|
||||
// prepared statement in order to allow the set of assignments to be variable.
|
||||
String assignments = optionsToAssignments(options.getOptions());
|
||||
|
|
@ -492,6 +517,41 @@ public class CassandraRoleManager implements IRoleManager
|
|||
.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 >= (System.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))
|
||||
|
|
|
|||
|
|
@ -265,7 +265,10 @@ public enum CassandraRelevantProperties
|
|||
* Do not try to calculate optimal streaming candidates. This can take a lot of time in some configs specially
|
||||
* with vnodes.
|
||||
*/
|
||||
SKIP_OPTIMAL_STREAMING_CANDIDATES_CALCULATION("cassandra.skip_optimal_streaming_candidates_calculation", "false");
|
||||
SKIP_OPTIMAL_STREAMING_CANDIDATES_CALCULATION("cassandra.skip_optimal_streaming_candidates_calculation", "false"),
|
||||
|
||||
/** How often a role's password can be changed */
|
||||
ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS("cassandra.role_password_update_min_interval_in_ms", "5000");
|
||||
|
||||
CassandraRelevantProperties(String key, String defaultVal)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import com.datastax.driver.core.exceptions.AuthenticationException;
|
|||
import com.datastax.driver.core.exceptions.SyntaxError;
|
||||
import com.datastax.driver.core.exceptions.UnauthorizedException;
|
||||
|
||||
import org.apache.cassandra.auth.CassandraRoleManager;
|
||||
import org.apache.cassandra.config.DatabaseDescriptor;
|
||||
import org.apache.cassandra.config.OverrideConfigurationLoader;
|
||||
import org.apache.cassandra.config.ParameterizedClass;
|
||||
|
|
@ -77,6 +78,7 @@ public class AuditLoggerAuthTest
|
|||
CQLTester.prepareServer();
|
||||
|
||||
System.setProperty("cassandra.superuser_setup_delay_ms", "0");
|
||||
CassandraRoleManager.updatePasswordUpdateMinInterval(0);
|
||||
embedded = new EmbeddedCassandraService();
|
||||
embedded.start();
|
||||
|
||||
|
|
|
|||
|
|
@ -25,12 +25,15 @@ import org.junit.BeforeClass;
|
|||
import org.junit.Test;
|
||||
|
||||
import org.apache.cassandra.SchemaLoader;
|
||||
import org.apache.cassandra.config.CassandraRelevantProperties;
|
||||
import org.apache.cassandra.exceptions.OverloadedException;
|
||||
import org.apache.cassandra.schema.KeyspaceParams;
|
||||
import org.apache.cassandra.schema.SchemaConstants;
|
||||
import org.apache.cassandra.schema.TableMetadata;
|
||||
|
||||
import static org.apache.cassandra.auth.RoleTestUtils.*;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class CassandraRoleManagerTest
|
||||
{
|
||||
|
|
@ -85,4 +88,135 @@ public class CassandraRoleManagerTest
|
|||
long after = getReadCount();
|
||||
assertEquals(granted.size(), after - before);
|
||||
}
|
||||
|
||||
@Test
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue