diff options
Diffstat (limited to 'lib/private/Security/Bruteforce')
-rw-r--r-- | lib/private/Security/Bruteforce/Backend/DatabaseBackend.php | 99 | ||||
-rw-r--r-- | lib/private/Security/Bruteforce/Backend/IBackend.php | 65 | ||||
-rw-r--r-- | lib/private/Security/Bruteforce/Backend/MemoryCacheBackend.php | 144 | ||||
-rw-r--r-- | lib/private/Security/Bruteforce/Capabilities.php | 56 | ||||
-rw-r--r-- | lib/private/Security/Bruteforce/CleanupJob.php | 40 | ||||
-rw-r--r-- | lib/private/Security/Bruteforce/Throttler.php | 349 |
6 files changed, 440 insertions, 313 deletions
diff --git a/lib/private/Security/Bruteforce/Backend/DatabaseBackend.php b/lib/private/Security/Bruteforce/Backend/DatabaseBackend.php new file mode 100644 index 00000000000..33c2a3aae62 --- /dev/null +++ b/lib/private/Security/Bruteforce/Backend/DatabaseBackend.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\Bruteforce\Backend; + +use OCP\IDBConnection; + +class DatabaseBackend implements IBackend { + private const TABLE_NAME = 'bruteforce_attempts'; + + public function __construct( + private IDBConnection $db, + ) { + } + + /** + * {@inheritDoc} + */ + public function getAttempts( + string $ipSubnet, + int $maxAgeTimestamp, + ?string $action = null, + ?array $metadata = null, + ): int { + $query = $this->db->getQueryBuilder(); + $query->select($query->func()->count('*', 'attempts')) + ->from(self::TABLE_NAME) + ->where($query->expr()->gt('occurred', $query->createNamedParameter($maxAgeTimestamp))) + ->andWhere($query->expr()->eq('subnet', $query->createNamedParameter($ipSubnet))); + + if ($action !== null) { + $query->andWhere($query->expr()->eq('action', $query->createNamedParameter($action))); + + if ($metadata !== null) { + $query->andWhere($query->expr()->eq('metadata', $query->createNamedParameter(json_encode($metadata)))); + } + } + + $result = $query->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + return (int)$row['attempts']; + } + + /** + * {@inheritDoc} + */ + public function resetAttempts( + string $ipSubnet, + ?string $action = null, + ?array $metadata = null, + ): void { + $query = $this->db->getQueryBuilder(); + $query->delete(self::TABLE_NAME) + ->where($query->expr()->eq('subnet', $query->createNamedParameter($ipSubnet))); + + if ($action !== null) { + $query->andWhere($query->expr()->eq('action', $query->createNamedParameter($action))); + + if ($metadata !== null) { + $query->andWhere($query->expr()->eq('metadata', $query->createNamedParameter(json_encode($metadata)))); + } + } + + $query->executeStatement(); + } + + /** + * {@inheritDoc} + */ + public function registerAttempt( + string $ip, + string $ipSubnet, + int $timestamp, + string $action, + array $metadata = [], + ): void { + $values = [ + 'ip' => $ip, + 'subnet' => $ipSubnet, + 'action' => $action, + 'metadata' => json_encode($metadata), + 'occurred' => $timestamp, + ]; + + $qb = $this->db->getQueryBuilder(); + $qb->insert(self::TABLE_NAME); + foreach ($values as $column => $value) { + $qb->setValue($column, $qb->createNamedParameter($value)); + } + $qb->executeStatement(); + } +} diff --git a/lib/private/Security/Bruteforce/Backend/IBackend.php b/lib/private/Security/Bruteforce/Backend/IBackend.php new file mode 100644 index 00000000000..7118123cbb5 --- /dev/null +++ b/lib/private/Security/Bruteforce/Backend/IBackend.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\Bruteforce\Backend; + +/** + * Interface IBackend defines a storage backend for the bruteforce data. It + * should be noted that writing and reading brute force data is an expensive + * operation and one should thus make sure to only use sufficient fast backends. + */ +interface IBackend { + /** + * Gets the number of attempts for the specified subnet (and further filters) + * + * @param string $ipSubnet + * @param int $maxAgeTimestamp + * @param ?string $action Optional action to further limit attempts + * @param ?array $metadata Optional metadata stored to further limit attempts (Only considered when $action is set) + * @return int + * @since 28.0.0 + */ + public function getAttempts( + string $ipSubnet, + int $maxAgeTimestamp, + ?string $action = null, + ?array $metadata = null, + ): int; + + /** + * Reset the attempts for the specified subnet (and further filters) + * + * @param string $ipSubnet + * @param ?string $action Optional action to further limit attempts + * @param ?array $metadata Optional metadata stored to further limit attempts(Only considered when $action is set) + * @since 28.0.0 + */ + public function resetAttempts( + string $ipSubnet, + ?string $action = null, + ?array $metadata = null, + ): void; + + /** + * Register a failed attempt to bruteforce a security control + * + * @param string $ip + * @param string $ipSubnet + * @param int $timestamp + * @param string $action + * @param array $metadata Optional metadata stored to further limit attempts when getting + * @since 28.0.0 + */ + public function registerAttempt( + string $ip, + string $ipSubnet, + int $timestamp, + string $action, + array $metadata = [], + ): void; +} diff --git a/lib/private/Security/Bruteforce/Backend/MemoryCacheBackend.php b/lib/private/Security/Bruteforce/Backend/MemoryCacheBackend.php new file mode 100644 index 00000000000..9a0723db47e --- /dev/null +++ b/lib/private/Security/Bruteforce/Backend/MemoryCacheBackend.php @@ -0,0 +1,144 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Security\Bruteforce\Backend; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\ICache; +use OCP\ICacheFactory; + +class MemoryCacheBackend implements IBackend { + private ICache $cache; + + public function __construct( + ICacheFactory $cacheFactory, + private ITimeFactory $timeFactory, + ) { + $this->cache = $cacheFactory->createDistributed(self::class); + } + + private function hash( + null|string|array $data, + ): ?string { + if ($data === null) { + return null; + } + if (!is_string($data)) { + $data = json_encode($data); + } + return hash('sha1', $data); + } + + private function getExistingAttempts(string $identifier): array { + $cachedAttempts = $this->cache->get($identifier); + if ($cachedAttempts === null) { + return []; + } + + $cachedAttempts = json_decode($cachedAttempts, true); + if (\is_array($cachedAttempts)) { + return $cachedAttempts; + } + + return []; + } + + /** + * {@inheritDoc} + */ + public function getAttempts( + string $ipSubnet, + int $maxAgeTimestamp, + ?string $action = null, + ?array $metadata = null, + ): int { + $identifier = $this->hash($ipSubnet); + $actionHash = $this->hash($action); + $metadataHash = $this->hash($metadata); + $existingAttempts = $this->getExistingAttempts($identifier); + + $count = 0; + foreach ($existingAttempts as $info) { + [$occurredTime, $attemptAction, $attemptMetadata] = explode('#', $info, 3); + if ($action === null || $attemptAction === $actionHash) { + if ($metadata === null || $attemptMetadata === $metadataHash) { + if ($occurredTime > $maxAgeTimestamp) { + $count++; + } + } + } + } + + return $count; + } + + /** + * {@inheritDoc} + */ + public function resetAttempts( + string $ipSubnet, + ?string $action = null, + ?array $metadata = null, + ): void { + $identifier = $this->hash($ipSubnet); + if ($action === null) { + $this->cache->remove($identifier); + } else { + $actionHash = $this->hash($action); + $metadataHash = $this->hash($metadata); + $existingAttempts = $this->getExistingAttempts($identifier); + $maxAgeTimestamp = $this->timeFactory->getTime() - 12 * 3600; + + foreach ($existingAttempts as $key => $info) { + [$occurredTime, $attemptAction, $attemptMetadata] = explode('#', $info, 3); + if ($attemptAction === $actionHash) { + if ($metadata === null || $attemptMetadata === $metadataHash) { + unset($existingAttempts[$key]); + } elseif ($occurredTime < $maxAgeTimestamp) { + unset($existingAttempts[$key]); + } + } + } + + if (!empty($existingAttempts)) { + $this->cache->set($identifier, json_encode($existingAttempts), 12 * 3600); + } else { + $this->cache->remove($identifier); + } + } + } + + /** + * {@inheritDoc} + */ + public function registerAttempt( + string $ip, + string $ipSubnet, + int $timestamp, + string $action, + array $metadata = [], + ): void { + $identifier = $this->hash($ipSubnet); + $existingAttempts = $this->getExistingAttempts($identifier); + $maxAgeTimestamp = $this->timeFactory->getTime() - 12 * 3600; + + // Unset all attempts that are already expired + foreach ($existingAttempts as $key => $info) { + [$occurredTime,] = explode('#', $info, 3); + if ($occurredTime < $maxAgeTimestamp) { + unset($existingAttempts[$key]); + } + } + $existingAttempts = array_values($existingAttempts); + + // Store the new attempt + $existingAttempts[] = $timestamp . '#' . $this->hash($action) . '#' . $this->hash($metadata); + + $this->cache->set($identifier, json_encode($existingAttempts), 12 * 3600); + } +} diff --git a/lib/private/Security/Bruteforce/Capabilities.php b/lib/private/Security/Bruteforce/Capabilities.php index 5de4f35f24e..581b4480a27 100644 --- a/lib/private/Security/Bruteforce/Capabilities.php +++ b/lib/private/Security/Bruteforce/Capabilities.php @@ -3,62 +3,32 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2017 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author J0WI <J0WI@users.noreply.github.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Security\Bruteforce; -use OCP\Capabilities\IPublicCapability; use OCP\Capabilities\IInitialStateExcludedCapability; +use OCP\Capabilities\IPublicCapability; use OCP\IRequest; +use OCP\Security\Bruteforce\IThrottler; class Capabilities implements IPublicCapability, IInitialStateExcludedCapability { - /** @var IRequest */ - private $request; - - /** @var Throttler */ - private $throttler; + public function __construct( + private IRequest $request, + private IThrottler $throttler, + ) { + } /** - * Capabilities constructor. - * - * @param IRequest $request - * @param Throttler $throttler + * @return array{bruteforce: array{delay: int, allow-listed: bool}} */ - public function __construct(IRequest $request, - Throttler $throttler) { - $this->request = $request; - $this->throttler = $throttler; - } - public function getCapabilities(): array { - if (version_compare(\OC::$server->getConfig()->getSystemValue('version', '0.0.0.0'), '12.0.0.0', '<')) { - return []; - } - return [ 'bruteforce' => [ - 'delay' => $this->throttler->getDelay($this->request->getRemoteAddress()) - ] + 'delay' => $this->throttler->getDelay($this->request->getRemoteAddress()), + 'allow-listed' => $this->throttler->isBypassListed($this->request->getRemoteAddress()), + ], ]; } } diff --git a/lib/private/Security/Bruteforce/CleanupJob.php b/lib/private/Security/Bruteforce/CleanupJob.php index 6faf853760a..f07e4dbacbd 100644 --- a/lib/private/Security/Bruteforce/CleanupJob.php +++ b/lib/private/Security/Bruteforce/CleanupJob.php @@ -3,55 +3,35 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Security\Bruteforce; use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\IJob; use OCP\BackgroundJob\TimedJob; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; class CleanupJob extends TimedJob { - - /** @var IDBConnection */ - private $connection; - - public function __construct(ITimeFactory $time, IDBConnection $connection) { + public function __construct( + ITimeFactory $time, + private IDBConnection $connection, + ) { parent::__construct($time); - $this->connection = $connection; // Run once a day - $this->setInterval(3600 * 24); - $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + $this->setInterval(60 * 60 * 24); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); } - protected function run($argument) { + protected function run($argument): void { // Delete all entries more than 48 hours old $time = $this->time->getTime() - (48 * 3600); $qb = $this->connection->getQueryBuilder(); $qb->delete('bruteforce_attempts') ->where($qb->expr()->lt('occurred', $qb->createNamedParameter($time), IQueryBuilder::PARAM_INT)); - $qb->execute(); + $qb->executeStatement(); } } diff --git a/lib/private/Security/Bruteforce/Throttler.php b/lib/private/Security/Bruteforce/Throttler.php index c47d102b881..574f6c80c3f 100644 --- a/lib/private/Security/Bruteforce/Throttler.php +++ b/lib/private/Security/Bruteforce/Throttler.php @@ -3,39 +3,17 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch> - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Johannes Riedel <joeried@users.noreply.github.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Security\Bruteforce; +use OC\Security\Bruteforce\Backend\IBackend; +use OC\Security\Ip\BruteforceAllowList; use OC\Security\Normalizer\IpAddress; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; -use OCP\IDBConnection; +use OCP\Security\Bruteforce\IThrottler; use OCP\Security\Bruteforce\MaxDelayReached; use Psr\Log\LoggerInterface; @@ -52,80 +30,34 @@ use Psr\Log\LoggerInterface; * * @package OC\Security\Bruteforce */ -class Throttler { - public const LOGIN_ACTION = 'login'; - public const MAX_DELAY = 25; - public const MAX_DELAY_MS = 25000; // in milliseconds - public const MAX_ATTEMPTS = 10; - - /** @var IDBConnection */ - private $db; - /** @var ITimeFactory */ - private $timeFactory; - private LoggerInterface $logger; - /** @var IConfig */ - private $config; - /** @var bool */ - private $hasAttemptsDeleted = false; - - public function __construct(IDBConnection $db, - ITimeFactory $timeFactory, - LoggerInterface $logger, - IConfig $config) { - $this->db = $db; - $this->timeFactory = $timeFactory; - $this->logger = $logger; - $this->config = $config; - } - - /** - * Convert a number of seconds into the appropriate DateInterval - * - * @param int $expire - * @return \DateInterval - */ - private function getCutoff(int $expire): \DateInterval { - $d1 = new \DateTime(); - $d2 = clone $d1; - $d2->sub(new \DateInterval('PT' . $expire . 'S')); - return $d2->diff($d1); - } - - /** - * Calculate the cut off timestamp - * - * @param float $maxAgeHours - * @return int - */ - private function getCutoffTimestamp(float $maxAgeHours = 12.0): int { - return (new \DateTime()) - ->sub($this->getCutoff((int) ($maxAgeHours * 3600))) - ->getTimestamp(); +class Throttler implements IThrottler { + /** @var bool[] */ + private array $hasAttemptsDeleted = []; + + public function __construct( + private ITimeFactory $timeFactory, + private LoggerInterface $logger, + private IConfig $config, + private IBackend $backend, + private BruteforceAllowList $allowList, + ) { } /** - * Register a failed attempt to bruteforce a security control - * - * @param string $action - * @param string $ip - * @param array $metadata Optional metadata logged to the database + * {@inheritDoc} */ public function registerAttempt(string $action, - string $ip, - array $metadata = []): void { + string $ip, + array $metadata = []): void { // No need to log if the bruteforce protection is disabled - if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) { + if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) { return; } $ipAddress = new IpAddress($ip); - $values = [ - 'action' => $action, - 'occurred' => $this->timeFactory->getTime(), - 'ip' => (string)$ipAddress, - 'subnet' => $ipAddress->getSubnet(), - 'metadata' => json_encode($metadata), - ]; + if ($this->isBypassListed((string)$ipAddress)) { + return; + } $this->logger->notice( sprintf( @@ -138,86 +70,33 @@ class Throttler { ] ); - $qb = $this->db->getQueryBuilder(); - $qb->insert('bruteforce_attempts'); - foreach ($values as $column => $value) { - $qb->setValue($column, $qb->createNamedParameter($value)); - } - $qb->execute(); + $this->backend->registerAttempt( + (string)$ipAddress, + $ipAddress->getSubnet(), + $this->timeFactory->getTime(), + $action, + $metadata + ); } /** * Check if the IP is whitelisted - * - * @param string $ip - * @return bool */ - private function isIPWhitelisted(string $ip): bool { - if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) { - return true; - } - - $keys = $this->config->getAppKeys('bruteForce'); - $keys = array_filter($keys, function ($key) { - return 0 === strpos($key, 'whitelist_'); - }); - - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $type = 4; - } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - $type = 6; - } else { - return false; - } - - $ip = inet_pton($ip); - - foreach ($keys as $key) { - $cidr = $this->config->getAppValue('bruteForce', $key, null); - - $cx = explode('/', $cidr); - $addr = $cx[0]; - $mask = (int)$cx[1]; - - // Do not compare ipv4 to ipv6 - if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) || - ($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) { - continue; - } - - $addr = inet_pton($addr); - - $valid = true; - for ($i = 0; $i < $mask; $i++) { - $part = ord($addr[(int)($i / 8)]); - $orig = ord($ip[(int)($i / 8)]); - - $bitmask = 1 << (7 - ($i % 8)); - - $part = $part & $bitmask; - $orig = $orig & $bitmask; - - if ($part !== $orig) { - $valid = false; - break; - } - } - - if ($valid === true) { - return true; - } - } + public function isBypassListed(string $ip): bool { + return $this->allowList->isBypassListed($ip); + } - return false; + /** + * {@inheritDoc} + */ + public function showBruteforceWarning(string $ip, string $action = ''): bool { + $attempts = $this->getAttempts($ip, $action); + // 4 failed attempts is the last delay below 5 seconds + return $attempts >= 4; } /** - * Get the throttling delay (in milliseconds) - * - * @param string $ip - * @param string $action optionally filter by action - * @param float $maxAgeHours - * @return int + * {@inheritDoc} */ public function getAttempts(string $ip, string $action = '', float $maxAgeHours = 12): int { if ($maxAgeHours > 48) { @@ -225,49 +104,42 @@ class Throttler { $maxAgeHours = 48; } - if ($ip === '' || $this->hasAttemptsDeleted) { + if ($ip === '' || isset($this->hasAttemptsDeleted[$action])) { return 0; } $ipAddress = new IpAddress($ip); - if ($this->isIPWhitelisted((string)$ipAddress)) { + if ($this->isBypassListed((string)$ipAddress)) { return 0; } - $cutoffTime = $this->getCutoffTimestamp($maxAgeHours); + $maxAgeTimestamp = (int)($this->timeFactory->getTime() - 3600 * $maxAgeHours); - $qb = $this->db->getQueryBuilder(); - $qb->select($qb->func()->count('*', 'attempts')) - ->from('bruteforce_attempts') - ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime))) - ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet()))); - - if ($action !== '') { - $qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action))); - } - - $result = $qb->execute(); - $row = $result->fetch(); - $result->closeCursor(); - - return (int) $row['attempts']; + return $this->backend->getAttempts( + $ipAddress->getSubnet(), + $maxAgeTimestamp, + $action !== '' ? $action : null, + ); } /** - * Get the throttling delay (in milliseconds) - * - * @param string $ip - * @param string $action optionally filter by action - * @return int + * {@inheritDoc} */ public function getDelay(string $ip, string $action = ''): int { $attempts = $this->getAttempts($ip, $action); + return $this->calculateDelay($attempts); + } + + /** + * {@inheritDoc} + */ + public function calculateDelay(int $attempts): int { if ($attempts === 0) { return 0; } $firstDelay = 0.1; - if ($attempts > self::MAX_ATTEMPTS) { + if ($attempts > $this->config->getSystemValueInt('auth.bruteforce.max-attempts', self::MAX_ATTEMPTS)) { // Don't ever overflow. Just assume the maxDelay time:s return self::MAX_DELAY_MS; } @@ -276,92 +148,89 @@ class Throttler { if ($delay > self::MAX_DELAY) { return self::MAX_DELAY_MS; } - return (int) \ceil($delay * 1000); + return (int)\ceil($delay * 1000); } /** - * Reset the throttling delay for an IP address, action and metadata - * - * @param string $ip - * @param string $action - * @param array $metadata + * {@inheritDoc} */ public function resetDelay(string $ip, string $action, array $metadata): void { - $ipAddress = new IpAddress($ip); - if ($this->isIPWhitelisted((string)$ipAddress)) { + // No need to log if the bruteforce protection is disabled + if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) { return; } - $cutoffTime = $this->getCutoffTimestamp(); - - $qb = $this->db->getQueryBuilder(); - $qb->delete('bruteforce_attempts') - ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime))) - ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet()))) - ->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action))) - ->andWhere($qb->expr()->eq('metadata', $qb->createNamedParameter(json_encode($metadata)))); + $ipAddress = new IpAddress($ip); + if ($this->isBypassListed((string)$ipAddress)) { + return; + } - $qb->executeStatement(); + $this->backend->resetAttempts( + $ipAddress->getSubnet(), + $action, + $metadata, + ); - $this->hasAttemptsDeleted = true; + $this->hasAttemptsDeleted[$action] = true; } /** - * Reset the throttling delay for an IP address - * - * @param string $ip + * {@inheritDoc} */ - public function resetDelayForIP($ip) { - $cutoffTime = $this->getCutoffTimestamp(); + public function resetDelayForIP(string $ip): void { + // No need to log if the bruteforce protection is disabled + if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) { + return; + } - $qb = $this->db->getQueryBuilder(); - $qb->delete('bruteforce_attempts') - ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime))) - ->andWhere($qb->expr()->eq('ip', $qb->createNamedParameter($ip))); + $ipAddress = new IpAddress($ip); + if ($this->isBypassListed((string)$ipAddress)) { + return; + } - $qb->execute(); + $this->backend->resetAttempts($ipAddress->getSubnet()); } /** - * Will sleep for the defined amount of time - * - * @param string $ip - * @param string $action optionally filter by action - * @return int the time spent sleeping + * {@inheritDoc} */ public function sleepDelay(string $ip, string $action = ''): int { $delay = $this->getDelay($ip, $action); - usleep($delay * 1000); + if (!$this->config->getSystemValueBool('auth.bruteforce.protection.testing')) { + usleep($delay * 1000); + } return $delay; } /** - * Will sleep for the defined amount of time unless maximum was reached in the last 30 minutes - * In this case a "429 Too Many Request" exception is thrown - * - * @param string $ip - * @param string $action optionally filter by action - * @return int the time spent sleeping - * @throws MaxDelayReached when reached the maximum + * {@inheritDoc} */ public function sleepDelayOrThrowOnMax(string $ip, string $action = ''): int { - $delay = $this->getDelay($ip, $action); - if (($delay === self::MAX_DELAY_MS) && $this->getAttempts($ip, $action, 0.5) > self::MAX_ATTEMPTS) { - $this->logger->info('IP address blocked because it reached the maximum failed attempts in the last 30 minutes [action: {action}, ip: {ip}]', [ + $maxAttempts = $this->config->getSystemValueInt('auth.bruteforce.max-attempts', self::MAX_ATTEMPTS); + $attempts = $this->getAttempts($ip, $action); + if ($attempts > $maxAttempts) { + $attempts30mins = $this->getAttempts($ip, $action, 0.5); + if ($attempts30mins > $maxAttempts) { + $this->logger->info('IP address blocked because it reached the maximum failed attempts in the last 30 minutes [action: {action}, attempts: {attempts}, ip: {ip}]', [ + 'action' => $action, + 'ip' => $ip, + 'attempts' => $attempts30mins, + ]); + // If the ip made too many attempts within the last 30 mins we don't execute anymore + throw new MaxDelayReached('Reached maximum delay'); + } + + $this->logger->info('IP address throttled because it reached the attempts limit in the last 12 hours [action: {action}, attempts: {attempts}, ip: {ip}]', [ 'action' => $action, 'ip' => $ip, + 'attempts' => $attempts, ]); - // If the ip made too many attempts within the last 30 mins we don't execute anymore - throw new MaxDelayReached('Reached maximum delay'); } - if ($delay > 100) { - $this->logger->info('IP address throttled because it reached the attempts limit in the last 30 minutes [action: {action}, delay: {delay}, ip: {ip}]', [ - 'action' => $action, - 'ip' => $ip, - 'delay' => $delay, - ]); + + if ($attempts > 0) { + return $this->calculateDelay($attempts); } - usleep($delay * 1000); - return $delay; + + return 0; } } |