diff --git a/README.md b/README.md index b1e777d..b06d4e4 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ $abuse = new Abuse($adapter); // Use vars to resolve adapter key -if(!$abuse->check()) { +if($abuse->isSafe() === false) { throw new Exception('Service was abused!'); // throw error and return X-Rate limit headers here } ``` @@ -77,18 +77,18 @@ To use this adapter you need to create an API key from the Google ReCaptcha serv require_once __DIR__ . '/../../vendor/autoload.php'; use Utopia\Abuse\Abuse; -use Utopia\Abuse\Adapters\ReCaptcha; +use Utopia\Abuse\Adapters\ReCaptchaLike\ReCaptcha; // Limit login attempts to 10 time in 5 minutes time frame $adapter = new ReCaptcha('secret-api-key', $_POST['g-recaptcha-response'], $_SERVER['REMOTE_ADDR']); $abuse = new Abuse($adapter); -if(!$abuse->check()) { +if($abuse->isSafe() === false) { throw new Exception('Service was abused!'); // throw error and return X-Rate limit headers here } ``` -*Notice: The code above is for example purpose only. It is always recommended to validate user input before using it in your code. If you are using a load balancer or any proxy server you might need to get user IP from the HTTP_X_FORWARDE‌​D_FOR header.* +*Notice: The code above is for example purpose only. It is always recommended to validate user input before using it in your code. If you are using a load balancer or any proxy server you might need to get user IP from the HTTP_X_FORWARDED_FOR header.* ## System Requirements diff --git a/src/Abuse/Abuse.php b/src/Abuse/Abuse.php index 7386c8a..6e3aaca 100644 --- a/src/Abuse/Abuse.php +++ b/src/Abuse/Abuse.php @@ -23,12 +23,23 @@ public function __construct(Adapter $adapter) * Checks if request is considered abuse or not * * @return bool + * @deprecated Check is ambiguous, use isSafe instead */ public function check(): bool { return $this->adapter->check(); } + /** + * Main method for threat detection + * + * @return bool Returns true if is safe to continue + */ + public function isSafe(): bool + { + return $this->adapter->isSafe(); + } + /** * Get abuse logs * diff --git a/src/Abuse/Adapter.php b/src/Abuse/Adapter.php index 0cf1d28..7fcf70c 100644 --- a/src/Abuse/Adapter.php +++ b/src/Abuse/Adapter.php @@ -10,9 +10,17 @@ interface Adapter * Checks if number of counts is bigger or smaller than current limit * * @return bool + * @deprecated Check is ambiguous, use isSafe instead */ public function check(): bool; + /** + * Main method for threat detection + * + * @return bool Returns true if is safe to continue + */ + public function isSafe(): bool; + /** * Get abuse logs * diff --git a/src/Abuse/Adapters/ReCaptchaLike/CfTurnstile.php b/src/Abuse/Adapters/ReCaptchaLike/CfTurnstile.php new file mode 100644 index 0000000..88a69d4 --- /dev/null +++ b/src/Abuse/Adapters/ReCaptchaLike/CfTurnstile.php @@ -0,0 +1,25 @@ +threshold; + } + + /** + * @inheritDoc + */ + protected function getSiteVerifyUrl(): string + { + return 'https://api.hcaptcha.com/siteverify'; + } +} diff --git a/src/Abuse/Adapters/ReCaptchaLike/ReCaptcha.php b/src/Abuse/Adapters/ReCaptchaLike/ReCaptcha.php new file mode 100644 index 0000000..aa2fced --- /dev/null +++ b/src/Abuse/Adapters/ReCaptchaLike/ReCaptcha.php @@ -0,0 +1,25 @@ + $this->threshold; + } + + /** + * @inheritDoc + */ + protected function getSiteVerifyUrl(): string + { + return 'https://www.google.com/recaptcha/api/siteverify'; + } +} diff --git a/src/Abuse/Adapters/ReCaptcha.php b/src/Abuse/Adapters/ReCaptchaLike/ReCaptchaLike.php similarity index 63% rename from src/Abuse/Adapters/ReCaptcha.php rename to src/Abuse/Adapters/ReCaptchaLike/ReCaptchaLike.php index 6f8a179..996f891 100644 --- a/src/Abuse/Adapters/ReCaptcha.php +++ b/src/Abuse/Adapters/ReCaptchaLike/ReCaptchaLike.php @@ -1,11 +1,11 @@ secret = $secret; $this->response = $response; $this->remoteIP = $remoteIP; + $this->threshold = $threshold; } /** - * Check - * - * Check if user is human or not, compared to score - * - * @param float $score - * @return bool + * @inheritDoc */ public function check(float $score = 0.5): bool { - $url = 'https://www.google.com/recaptcha/api/siteverify'; + $this->threshold = $score; + + return $this->isSafe() === false; + } + + /** + * @inheritDoc + */ + public function isSafe(): bool + { $fields = [ 'secret' => \urlencode($this->secret), 'response' => \urlencode($this->response), @@ -71,24 +82,40 @@ public function check(float $score = 0.5): bool $ch = \curl_init(); //set the url, number of POST vars, POST data - \curl_setopt($ch, CURLOPT_URL, $url); + \curl_setopt($ch, CURLOPT_URL, $this->getSiteVerifyUrl()); \curl_setopt($ch, CURLOPT_POST, \count($fields)); \curl_setopt($ch, CURLOPT_POSTFIELDS, \http_build_query($fields)); \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); //execute post /** @var array $result */ - $result = \json_decode((string) \curl_exec($ch), true); - - //close connection - \curl_close($ch); - if ($result['success'] && $result['score'] >= $score) { - return true; - } else { + try { + $result = \json_decode((string)\curl_exec($ch), true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $_) { return false; + } finally { + //close connection + \curl_close($ch); } + + return $this->decideByResult($result); } + /** + * Implementation how score is interpreted. + * + * @param array $result Returned by reCAPTCHA service + * @return bool True if is safe + */ + abstract protected function decideByResult(array $result): bool; + + /** + * Implementation how to get site-verify url. + * + * @return string Url of the site-verify endpoint. + */ + abstract protected function getSiteVerifyUrl(): string; + /** * Delete logs older than $datetime * diff --git a/src/Abuse/Adapters/TimeLimit.php b/src/Abuse/Adapters/TimeLimit.php index e280b84..43284e5 100644 --- a/src/Abuse/Adapters/TimeLimit.php +++ b/src/Abuse/Adapters/TimeLimit.php @@ -51,7 +51,7 @@ class TimeLimit implements Adapter /** * @param string $key * @param int $seconds - * @param int $limit + * @param int $limit Number of requests in given time window. 0 means unlimited. * @param Database $db */ public function __construct(string $key, int $limit, int $seconds, Database $db) @@ -314,28 +314,36 @@ public function cleanup(string $datetime): bool } /** - * Check - * - * Checks if number of counts is bigger or smaller than current limit. limit 0 is equal to unlimited - * - * @return bool - * - * @throws \Exception|Throwable + * @inheritDoc + * @throws AuthorizationException + * @throws Throwable + * @throws Structure */ public function check(): bool { - if (0 == $this->limit) { - return false; + return $this->isSafe() === false; + } + + /** + * @inheritDoc + * @throws AuthorizationException + * @throws Throwable + * @throws Structure + */ + public function isSafe(): bool + { + if (0 === $this->limit) { + return true; } $key = $this->parseKey(); - if ($this->limit > $this->count($key, $this->time)) { - $this->hit($key, $this->time); - + if ($this->limit <= $this->count($key, $this->time)) { return false; } + $this->hit($key, $this->time); + return true; } diff --git a/tests/Abuse/AbuseTest.php b/tests/Abuse/AbuseTest.php index dac103d..9f0333d 100755 --- a/tests/Abuse/AbuseTest.php +++ b/tests/Abuse/AbuseTest.php @@ -53,7 +53,7 @@ public function setUp(): void public function tearDown(): void { - unset($this->abuse); + unset($this->abuse, $this->abuseIp); } public function testImitate2Requests(): void @@ -64,8 +64,9 @@ public function testImitate2Requests(): void $adapter = new TimeLimit($key, 1, 1, $this->db); $adapter->setParam($key, $value); $this->abuseIp = new Abuse($adapter); - $this->assertEquals($this->abuseIp->check(), false); - $this->assertEquals($this->abuseIp->check(), true); + + $this->assertEquals($this->abuseIp->isSafe(), true); + $this->assertEquals($this->abuseIp->isSafe(), false); sleep(1); @@ -73,17 +74,31 @@ public function testImitate2Requests(): void $adapter->setParam($key, $value); $this->abuseIp = new Abuse($adapter); - $this->assertEquals($this->abuseIp->check(), false); - $this->assertEquals($this->abuseIp->check(), true); + $this->assertEquals($this->abuseIp->isSafe(), true); + $this->assertEquals($this->abuseIp->isSafe(), false); + } + + public function testUnlimitedRequests(): void + { + $key = '{{ip}}'; + $value = '0.0.0.10'; + + $adapter = new TimeLimit($key, 0, 1, $this->db); + $adapter->setParam($key, $value); + $this->abuseIp = new Abuse($adapter); + + for ($i = 0; $i < 100; $i++) { + $this->assertEquals($this->abuseIp->isSafe(), true); + } } public function testIsValid(): void { // Use vars to resolve adapter key - $this->assertEquals($this->abuse->check(), false); - $this->assertEquals($this->abuse->check(), false); - $this->assertEquals($this->abuse->check(), false); - $this->assertEquals($this->abuse->check(), true); + $this->assertEquals($this->abuse->isSafe(), true); + $this->assertEquals($this->abuse->isSafe(), true); + $this->assertEquals($this->abuse->isSafe(), true); + $this->assertEquals($this->abuse->isSafe(), false); } public function testCleanup(): void