Similar to the ratelimit backend Signed-off-by: Joas Schilling <coding@schilljs.com>pull/39870/head
parent
4c2c53e271
commit
a95800c647
@ -0,0 +1,116 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/** |
||||
* @copyright Copyright (c) 2023 Joas Schilling <coding@schilljs.com> |
||||
* |
||||
* @author Joas Schilling <coding@schilljs.com> |
||||
* |
||||
* @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/>. |
||||
* |
||||
*/ |
||||
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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,82 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/** |
||||
* @copyright Copyright (c) 2023 Joas Schilling <coding@schilljs.com> |
||||
* |
||||
* @author Joas Schilling <coding@schilljs.com> |
||||
* |
||||
* @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/>. |
||||
* |
||||
*/ |
||||
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; |
||||
} |
||||
@ -0,0 +1,161 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/** |
||||
* @copyright Copyright (c) 2023 Joas Schilling <coding@schilljs.com> |
||||
* |
||||
* @author Joas Schilling <coding@schilljs.com> |
||||
* |
||||
* @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/>. |
||||
* |
||||
*/ |
||||
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(__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); |
||||
} |
||||
} |
||||
@ -0,0 +1,156 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/** |
||||
* @copyright Copyright (c) 2023 Joas Schilling <coding@schilljs.com> |
||||
* |
||||
* @author Joas Schilling <coding@schilljs.com> |
||||
* |
||||
* @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/>. |
||||
* |
||||
*/ |
||||
|
||||
namespace Test\Security\Bruteforce\Backend; |
||||
|
||||
use OC\Security\Bruteforce\Backend\IBackend; |
||||
use OC\Security\Bruteforce\Backend\MemoryCacheBackend; |
||||
use OCP\AppFramework\Utility\ITimeFactory; |
||||
use OCP\ICache; |
||||
use OCP\ICacheFactory; |
||||
use PHPUnit\Framework\MockObject\MockObject; |
||||
use Test\TestCase; |
||||
|
||||
class MemoryCacheBackendTest extends TestCase { |
||||
/** @var ICacheFactory|MockObject */ |
||||
private $cacheFactory; |
||||
/** @var ITimeFactory|MockObject */ |
||||
private $timeFactory; |
||||
/** @var ICache|MockObject */ |
||||
private $cache; |
||||
private IBackend $backend; |
||||
|
||||
protected function setUp(): void { |
||||
parent::setUp(); |
||||
|
||||
$this->cacheFactory = $this->createMock(ICacheFactory::class); |
||||
$this->timeFactory = $this->createMock(ITimeFactory::class); |
||||
$this->cache = $this->createMock(ICache::class); |
||||
|
||||
$this->cacheFactory |
||||
->expects($this->once()) |
||||
->method('createDistributed') |
||||
->with('OC\Security\Bruteforce\Backend\MemoryCacheBackend') |
||||
->willReturn($this->cache); |
||||
|
||||
$this->backend = new MemoryCacheBackend( |
||||
$this->cacheFactory, |
||||
$this->timeFactory |
||||
); |
||||
} |
||||
|
||||
public function testGetAttemptsWithNoAttemptsBefore(): void { |
||||
$this->cache |
||||
->expects($this->once()) |
||||
->method('get') |
||||
->with('8b9da631d1f7b022bb2c3c489e16092f82b42fd4') |
||||
->willReturn(null); |
||||
|
||||
$this->assertSame(0, $this->backend->getAttempts('10.10.10.10/32', 0)); |
||||
} |
||||
|
||||
public function dataGetAttempts(): array { |
||||
return [ |
||||
[0, null, null, 4], |
||||
[100, null, null, 2], |
||||
[0, 'action1', null, 2], |
||||
[100, 'action1', null, 1], |
||||
[0, 'action1', ['metadata2'], 1], |
||||
[100, 'action1', ['metadata2'], 1], |
||||
[100, 'action1', ['metadata1'], 0], |
||||
]; |
||||
} |
||||
|
||||
/** |
||||
* @dataProvider dataGetAttempts |
||||
*/ |
||||
public function testGetAttempts(int $maxAge, ?string $action, ?array $metadata, int $expected): void { |
||||
$this->cache |
||||
->expects($this->once()) |
||||
->method('get') |
||||
->with('8b9da631d1f7b022bb2c3c489e16092f82b42fd4') |
||||
->willReturn(json_encode([ |
||||
'1' . '#' . hash('sha1', 'action1') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
'300' . '#' . hash('sha1', 'action1') . '#' . hash('sha1', json_encode(['metadata2'])), |
||||
'1' . '#' . hash('sha1', 'action2') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
'300' . '#' . hash('sha1', 'action2') . '#' . hash('sha1', json_encode(['metadata2'])), |
||||
])); |
||||
|
||||
$this->assertSame($expected, $this->backend->getAttempts('10.10.10.10/32', $maxAge, $action, $metadata)); |
||||
} |
||||
|
||||
public function testRegisterAttemptWithNoAttemptsBefore(): void { |
||||
$this->cache |
||||
->expects($this->once()) |
||||
->method('get') |
||||
->with('8b9da631d1f7b022bb2c3c489e16092f82b42fd4') |
||||
->willReturn(null); |
||||
$this->cache |
||||
->expects($this->once()) |
||||
->method('set') |
||||
->with( |
||||
'8b9da631d1f7b022bb2c3c489e16092f82b42fd4', |
||||
json_encode(['223#' . hash('sha1', 'action1') . '#' . hash('sha1', json_encode(['metadata1']))]) |
||||
); |
||||
|
||||
$this->backend->registerAttempt('10.10.10.10', '10.10.10.10/32', 223, 'action1', ['metadata1']); |
||||
} |
||||
|
||||
public function testRegisterAttempt(): void { |
||||
$this->timeFactory |
||||
->expects($this->once()) |
||||
->method('getTime') |
||||
->willReturn(12 * 3600 + 86); |
||||
|
||||
$this->cache |
||||
->expects($this->once()) |
||||
->method('get') |
||||
->with('8b9da631d1f7b022bb2c3c489e16092f82b42fd4') |
||||
->willReturn(json_encode([ |
||||
'1#' . hash('sha1', 'action1') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
'2#' . hash('sha1', 'action2') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
'87#' . hash('sha1', 'action3') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
'123#' . hash('sha1', 'action4') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
'123#' . hash('sha1', 'action5') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
'124#' . hash('sha1', 'action6') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
])); |
||||
$this->cache |
||||
->expects($this->once()) |
||||
->method('set') |
||||
->with( |
||||
'8b9da631d1f7b022bb2c3c489e16092f82b42fd4', |
||||
json_encode([ |
||||
'87#' . hash('sha1', 'action3') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
'123#' . hash('sha1', 'action4') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
'123#' . hash('sha1', 'action5') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
'124#' . hash('sha1', 'action6') . '#' . hash('sha1', json_encode(['metadata1'])), |
||||
'186#' . hash('sha1', 'action7') . '#' . hash('sha1', json_encode(['metadata2'])), |
||||
]) |
||||
); |
||||
|
||||
$this->backend->registerAttempt('10.10.10.10', '10.10.10.10/32', 186, 'action7', ['metadata2']); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue