Commit 793974d9 authored by catch's avatar catch

Issue #1795186 by paranojik, marcingy, cweagans, Lars Toomre: Make flood storage pluggable.

parent a8f9ad3c
......@@ -1185,84 +1185,6 @@ function valid_number_step($value, $step, $offset = 0.0) {
* @} End of "defgroup validation".
*/
/**
* Registers an event for the current visitor to the flood control mechanism.
*
* @param $name
* The name of an event.
* @param $window
* Optional number of seconds before this event expires. Defaults to 3600 (1
* hour). Typically uses the same value as the flood_is_allowed() $window
* parameter. Expired events are purged on cron run to prevent the flood table
* from growing indefinitely.
* @param $identifier
* Optional identifier (defaults to the current user's IP address).
*/
function flood_register_event($name, $window = 3600, $identifier = NULL) {
if (!isset($identifier)) {
$identifier = ip_address();
}
db_insert('flood')
->fields(array(
'event' => $name,
'identifier' => $identifier,
'timestamp' => REQUEST_TIME,
'expiration' => REQUEST_TIME + $window,
))
->execute();
}
/**
* Makes the flood control mechanism forget an event for the current visitor.
*
* @param $name
* The name of an event.
* @param $identifier
* Optional identifier (defaults to the current user's IP address).
*/
function flood_clear_event($name, $identifier = NULL) {
if (!isset($identifier)) {
$identifier = ip_address();
}
db_delete('flood')
->condition('event', $name)
->condition('identifier', $identifier)
->execute();
}
/**
* Checks whether a user is allowed to proceed with the specified event.
*
* Events can have thresholds saying that each user can only do that event
* a certain number of times in a time window. This function verifies that the
* current user has not exceeded this threshold.
*
* @param $name
* The unique name of the event.
* @param $threshold
* The maximum number of times each user can do this event per time window.
* @param $window
* Number of seconds in the time window for this event (default is 3600
* seconds, or 1 hour).
* @param $identifier
* Unique identifier of the current user. Defaults to their IP address.
*
* @return
* TRUE if the user is allowed to proceed. FALSE if they have exceeded the
* threshold and should not be allowed to proceed.
*/
function flood_is_allowed($name, $threshold, $window = 3600, $identifier = NULL) {
if (!isset($identifier)) {
$identifier = ip_address();
}
$number = db_query("SELECT COUNT(*) FROM {flood} WHERE event = :event AND identifier = :identifier AND timestamp > :timestamp", array(
':event' => $name,
':identifier' => $identifier,
':timestamp' => REQUEST_TIME - $window))
->fetchField();
return ($number < $threshold);
}
/**
* @defgroup sanitization Sanitization functions
* @{
......
......@@ -202,6 +202,9 @@ public function build(ContainerBuilder $container) {
->addArgument(array())
->addArgument(array());
$container->register('flood', 'Drupal\Core\Flood\DatabaseBackend')
->addArgument(new Reference('database'));
$container->addCompilerPass(new RegisterMatchersPass());
$container->addCompilerPass(new RegisterNestedMatchersPass());
// Add a compiler pass for registering event subscribers.
......
<?php
/**
* @file
* Definition of Drupal\Core\Flood\DatabaseBackend.
*/
namespace Drupal\Core\Flood;
use Drupal\Core\Database\Connection;
/**
* Defines the database flood backend. This is the default Drupal backend.
*/
class DatabaseBackend implements FloodInterface {
/**
* The database connection used to store flood event information.
*
* @var \Drupal\Core\Database\Connection
*/
protected $connection;
/**
* Construct the DatabaseBackend.
*
* @param \Drupal\Core\Database\Connection $connection
* The database connection which will be used to store the flood event
* information.
*/
public function __construct(Connection $connection) {
$this->connection = $connection;
}
/**
* Implements Drupal\Core\Flood\FloodInterface::register().
*/
public function register($name, $window = 3600, $identifier = NULL) {
if (!isset($identifier)) {
$identifier = ip_address();
}
$this->connection->insert('flood')
->fields(array(
'event' => $name,
'identifier' => $identifier,
'timestamp' => REQUEST_TIME,
'expiration' => REQUEST_TIME + $window,
))
->execute();
}
/**
* Implements Drupal\Core\Flood\FloodInterface::clear().
*/
public function clear($name, $identifier = NULL) {
if (!isset($identifier)) {
$identifier = ip_address();
}
$this->connection->delete('flood')
->condition('event', $name)
->condition('identifier', $identifier)
->execute();
}
/**
* Implements Drupal\Core\Flood\FloodInterface::isAllowed().
*/
public function isAllowed($name, $threshold, $window = 3600, $identifier = NULL) {
if (!isset($identifier)) {
$identifier = ip_address();
}
$number = $this->connection->select('flood', 'f')
->condition('event', $name)
->condition('identifier', $identifier)
->condition('timestamp', REQUEST_TIME - $window, '>')
->countQuery()
->execute()
->fetchField();
return ($number < $threshold);
}
/**
* Implements Drupal\Core\Flood\FloodInterface::garbageCollection().
*/
public function garbageCollection() {
return $this->connection->delete('flood')
->condition('expiration', REQUEST_TIME, '<')
->execute();
}
}
<?php
/**
* @file
* Definition of Drupal\Core\Flood\FloodInterface.
*/
namespace Drupal\Core\Flood;
/**
* Defines an interface for flood controllers.
*/
interface FloodInterface {
/**
* Registers an event for the current visitor to the flood control mechanism.
*
* @param string $name
* The name of an event. To prevent unintended name clashes, it is recommended
* to use the module name first in the event name, optionally followed by
* a dot and the actual event name (e.g. "mymodule.my_event").
* @param int $window
* (optional) Number of seconds before this event expires. Defaults to 3600
* (1 hour). Typically uses the same value as the isAllowed() $window
* parameter. Expired events are purged on cron run to prevent the flood
* table from growing indefinitely.
* @param string $identifier
* (optional) Unique identifier of the current user. Defaults to the current
* user's IP address).
*/
public function register($name, $window = 3600, $identifier = NULL);
/**
* Makes the flood control mechanism forget an event for the current visitor.
*
* @param string $name
* The name of an event.
* @param string $identifier
* (optional) Unique identifier of the current user. Defaults to the current
* user's IP address).
*/
public function clear($name, $identifier = NULL);
/**
* Checks whether a user is allowed to proceed with the specified event.
*
* Events can have thresholds saying that each user can only do that event
* a certain number of times in a time window. This function verifies that
* the current user has not exceeded this threshold.
*
* @param string $name
* The name of an event.
* @param int $threshold
* The maximum number of times each user can do this event per time window.
* @param int $window
* (optional) Number of seconds in the time window for this event (default is 3600
* seconds, or 1 hour).
* @param string $identifier
* (optional) Unique identifier of the current user. Defaults to the current
* user's IP address).
*
* @return
* TRUE if the user is allowed to proceed. FALSE if they have exceeded the
* threshold and should not be allowed to proceed.
*/
public function isAllowed($name, $threshold, $window = 3600, $identifier = NULL);
/**
* Cleans up expired flood events. This method is called automatically on
* cron run.
*
* @see system_cron()
*/
public function garbageCollection();
}
<?php
/**
* @file
* Definition of Drupal\Core\Flood\MemoryBackend.
*/
namespace Drupal\Core\Flood;
/**
* Defines the memory flood backend. This is used for testing.
*/
class MemoryBackend implements FloodInterface {
/**
* An array holding flood events, keyed by event name and identifier.
*/
protected $events = array();
/**
* Implements Drupal\Core\Flood\FloodInterface::register().
*/
public function register($name, $window = 3600, $identifier = NULL) {
if (!isset($identifier)) {
$identifier = ip_address();
}
// We can't use REQUEST_TIME here, because that would not guarantee
// uniqueness.
$time = microtime(true);
$this->events[$name][$identifier][$time + $window] = $time;
}
/**
* Implements Drupal\Core\Flood\FloodInterface::clear().
*/
public function clear($name, $identifier = NULL) {
if (!isset($identifier)) {
$identifier = ip_address();
}
unset($this->events[$name][$identifier]);
}
/**
* Implements Drupal\Core\Flood\FloodInterface::isAllowed().
*/
public function isAllowed($name, $threshold, $window = 3600, $identifier = NULL) {
if (!isset($identifier)) {
$identifier = ip_address();
}
$limit = microtime(true) - $window;
$number = count(array_filter($this->events[$name][$identifier], function ($timestamp) use ($limit) {
return $timestamp > $limit;
}));
return ($number < $threshold);
}
/**
* Implements Drupal\Core\Flood\FloodInterface::garbageCollection().
*/
public function garbageCollection() {
foreach ($this->events as $name => $identifiers) {
foreach ($this->events[$name] as $identifier => $timestamps) {
// Filter by key (expiration) but preserve key => value associations.
$this->events[$name][$identifier] = array_filter($timestamps, function () use (&$timestamps) {
$expiration = key($timestamps);
next($timestamps);
return $expiration > microtime(true);
});
}
}
}
}
......@@ -25,7 +25,7 @@ function contact_site_form($form, &$form_state) {
$config = config('contact.settings');
$limit = $config->get('flood.limit');
$interval = $config->get('flood.interval');
if (!flood_is_allowed('contact', $limit, $interval) && !user_access('administer contact forms')) {
if (!drupal_container()->get('flood')->isAllowed('contact', $limit, $interval) && !user_access('administer contact forms')) {
drupal_set_message(t("You cannot send more than %limit messages in @interval. Try again later.", array('%limit' => $limit, '@interval' => format_interval($interval))), 'error');
throw new AccessDeniedHttpException();
}
......@@ -166,7 +166,7 @@ function contact_site_form_submit($form, &$form_state) {
drupal_mail('contact', 'page_autoreply', $from, $language_interface->langcode, $values, $to);
}
flood_register_event('contact', config('contact.settings')->get('flood.interval'));
drupal_container()->get('flood')->register('contact', config('contact.settings')->get('flood.interval'));
watchdog('mail', '%sender-name (@sender-from) sent an e-mail regarding %category.', array('%sender-name' => $values['name'], '@sender-from' => $from, '%category' => $values['category']->label()));
// Jump to home page rather than back to contact page to avoid
......@@ -195,7 +195,7 @@ function contact_personal_form($form, &$form_state, $recipient) {
$config = config('contact.settings');
$limit = $config->get('flood.limit');
$interval = $config->get('flood.interval');
if (!flood_is_allowed('contact', $limit, $interval) && !user_access('administer contact forms') && !user_access('administer users')) {
if (!drupal_container()->get('flood')->isAllowed('contact', $limit, $interval) && !user_access('administer contact forms') && !user_access('administer users')) {
drupal_set_message(t("You cannot send more than %limit messages in @interval. Try again later.", array('%limit' => $limit, '@interval' => format_interval($interval))), 'error');
throw new AccessDeniedHttpException();
}
......@@ -304,7 +304,7 @@ function contact_personal_form_submit($form, &$form_state) {
drupal_mail('contact', 'user_copy', $from, $language_interface->langcode, $values, $from);
}
flood_register_event('contact', config('contact.settings')->get('flood.interval'));
drupal_container()->get('flood')->register('contact', config('contact.settings')->get('flood.interval'));
watchdog('mail', '%sender-name (@sender-from) sent %recipient-name an e-mail.', array('%sender-name' => $values['name'], '@sender-from' => $from, '%recipient-name' => $values['recipient']->name));
// Jump to the contacted user's profile page.
......
......@@ -30,19 +30,45 @@ function testCleanUp() {
$name = 'flood_test_cleanup';
// Register expired event.
flood_register_event($name, $window_expired);
drupal_container()->get('flood')->register($name, $window_expired);
// Verify event is not allowed.
$this->assertFalse(flood_is_allowed($name, $threshold));
$this->assertFalse(drupal_container()->get('flood')->isAllowed($name, $threshold));
// Run cron and verify event is now allowed.
$this->cronRun();
$this->assertTrue(flood_is_allowed($name, $threshold));
$this->assertTrue(drupal_container()->get('flood')->isAllowed($name, $threshold));
// Register unexpired event.
flood_register_event($name);
drupal_container()->get('flood')->register($name);
// Verify event is not allowed.
$this->assertFalse(flood_is_allowed($name, $threshold));
$this->assertFalse(drupal_container()->get('flood')->isAllowed($name, $threshold));
// Run cron and verify event is still not allowed.
$this->cronRun();
$this->assertFalse(flood_is_allowed($name, $threshold));
$this->assertFalse(drupal_container()->get('flood')->isAllowed($name, $threshold));
}
/**
* Test flood control memory backend.
*/
function testMemoryBackend() {
$threshold = 1;
$window_expired = -1;
$name = 'flood_test_cleanup';
$flood = new \Drupal\Core\Flood\MemoryBackend;
// Register expired event.
$flood->register($name, $window_expired);
// Verify event is not allowed.
$this->assertFalse($flood->isAllowed($name, $threshold));
// Run cron and verify event is now allowed.
$flood->garbageCollection();
$this->assertTrue($flood->isAllowed($name, $threshold));
// Register unexpired event.
$flood->register($name);
// Verify event is not allowed.
$this->assertFalse($flood->isAllowed($name, $threshold));
// Run cron and verify event is still not allowed.
$flood->garbageCollection();
$this->assertFalse($flood->isAllowed($name, $threshold));
}
}
......@@ -3486,9 +3486,7 @@ function system_get_module_admin_tasks($module, $info) {
*/
function system_cron() {
// Cleanup the flood.
db_delete('flood')
->condition('expiration', REQUEST_TIME, '<')
->execute();
drupal_container()->get('flood')->garbageCollection();
$cache_bins = array_merge(module_invoke_all('cache_flush'), array('form', 'menu'));
foreach ($cache_bins as $bin) {
......
......@@ -1621,7 +1621,7 @@ function user_login_authenticate_validate($form, &$form_state) {
// independent of the per-user limit to catch attempts from one IP to log
// in to many different user accounts. We have a reasonably high limit
// since there may be only one apparent IP for all users at an institution.
if (!flood_is_allowed('failed_login_attempt_ip', $flood_config->get('ip_limit'), $flood_config->get('ip_window'))) {
if (!drupal_container()->get('flood')->isAllowed('user.failed_login_ip', $flood_config->get('ip_limit'), $flood_config->get('ip_window'))) {
$form_state['flood_control_triggered'] = 'ip';
return;
}
......@@ -1642,7 +1642,7 @@ function user_login_authenticate_validate($form, &$form_state) {
// Don't allow login if the limit for this user has been reached.
// Default is to allow 5 failed attempts every 6 hours.
if (!flood_is_allowed('failed_login_attempt_user', $flood_config->get('user_limit'), $flood_config->get('user_window'), $identifier)) {
if (!drupal_container()->get('flood')->isAllowed('user.failed_login_user', $flood_config->get('user_limit'), $flood_config->get('user_window'), $identifier)) {
$form_state['flood_control_triggered'] = 'user';
return;
}
......@@ -1664,10 +1664,10 @@ function user_login_final_validate($form, &$form_state) {
$flood_config = config('user.flood');
if (empty($form_state['uid'])) {
// Always register an IP-based failed login event.
flood_register_event('failed_login_attempt_ip', $flood_config->get('ip_window'));
drupal_container()->get('flood')->register('user.failed_login_ip', $flood_config->get('ip_window'));
// Register a per-user failed login event.
if (isset($form_state['flood_control_user_identifier'])) {
flood_register_event('failed_login_attempt_user', $flood_config->get('user_window'), $form_state['flood_control_user_identifier']);
drupal_container()->get('flood')->register('user.failed_login_user', $flood_config->get('user_window'), $form_state['flood_control_user_identifier']);
}
if (isset($form_state['flood_control_triggered'])) {
......@@ -1687,7 +1687,7 @@ function user_login_final_validate($form, &$form_state) {
elseif (isset($form_state['flood_control_user_identifier'])) {
// Clear past failures for this user so as not to block a user who might
// log in and out more than once in an hour.
flood_clear_event('failed_login_attempt_user', $form_state['flood_control_user_identifier']);
drupal_container()->get('flood')->clear('user.failed_login_user', $form_state['flood_control_user_identifier']);
}
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment