From b88d2cc2f6f1d413125bf7a9af4d99b0163d04ce Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 2 Oct 2026 13:39:02 +0200 Subject: [PATCH] Implemented multiple captchas support. Implemented Yandex Smart Captcha support --- .env.dist | 5 + api/components/Captcha/CaptchaRegistry.php | 37 +++++++ api/components/Captcha/CaptchaTrait.php | 22 ++++ api/components/Captcha/CaptchaValidator.php | 90 +++++++++++++++ .../Captcha/Providers/ProviderInterface.php | 22 ++++ .../Captcha/Providers/ReCaptchaEnterprise.php | 61 +++++++++++ .../Captcha/Providers/YandexSmartCaptcha.php | 53 +++++++++ api/components/ReCaptcha/Component.php | 22 ---- api/components/ReCaptcha/Validator.php | 89 --------------- api/config/config-dev.php | 4 - api/config/config-test.php | 13 +-- api/config/config.php | 35 +++++- api/controllers/OptionsController.php | 11 +- .../authentication/ForgotPasswordForm.php | 11 +- .../authentication/RegistrationForm.php | 10 +- .../RepeatAccountActivationForm.php | 11 +- api/tests/_pages/AuthenticationRoute.php | 5 + api/tests/_pages/SignupRoute.php | 7 +- .../_support/Captcha/FakeCaptchaProvider.php | 27 +++++ api/tests/functional/OptionsCest.php | 12 +- .../Captcha/CaptchaRegistryTest.php | 51 +++++++++ .../Captcha/CaptchaValidatorTest.php | 103 ++++++++++++++++++ .../Providers/ReCaptchaEnterpriseTest.php | 71 ++++++++++++ .../Providers/YandexSmartCaptchaTest.php | 62 +++++++++++ .../components/ReCaptcha/ValidatorTest.php | 72 ------------ .../authentication/ForgotPasswordFormTest.php | 18 +-- .../authentication/RegistrationFormTest.php | 13 +-- .../RepeatAccountActivationFormTest.php | 16 +-- autocompletion.php | 1 - docker/nginx/account.ely.by.conf.template | 2 +- phpstan-baseline.neon | 39 +------ 31 files changed, 696 insertions(+), 299 deletions(-) create mode 100644 api/components/Captcha/CaptchaRegistry.php create mode 100644 api/components/Captcha/CaptchaTrait.php create mode 100644 api/components/Captcha/CaptchaValidator.php create mode 100644 api/components/Captcha/Providers/ProviderInterface.php create mode 100644 api/components/Captcha/Providers/ReCaptchaEnterprise.php create mode 100644 api/components/Captcha/Providers/YandexSmartCaptcha.php delete mode 100644 api/components/ReCaptcha/Component.php delete mode 100644 api/components/ReCaptcha/Validator.php create mode 100644 api/tests/_support/Captcha/FakeCaptchaProvider.php create mode 100644 api/tests/unit/components/Captcha/CaptchaRegistryTest.php create mode 100644 api/tests/unit/components/Captcha/CaptchaValidatorTest.php create mode 100644 api/tests/unit/components/Captcha/Providers/ReCaptchaEnterpriseTest.php create mode 100644 api/tests/unit/components/Captcha/Providers/YandexSmartCaptchaTest.php delete mode 100644 api/tests/unit/components/ReCaptcha/ValidatorTest.php diff --git a/.env.dist b/.env.dist index 86da162..9124a61 100644 --- a/.env.dist +++ b/.env.dist @@ -13,8 +13,13 @@ JWT_PRIVATE_KEY_PASS= ## External services CHRLY_HOST=skinsystem.ely.by +# Google reCAPTCHA (Google Cloud edition). Checkbox site key, API key and Google Cloud project id RECAPTCHA_PUBLIC= RECAPTCHA_SECRET= +RECAPTCHA_PROJECT_ID= +# Yandex SmartCaptcha. Leave empty to disable +YANDEX_CAPTCHA_CLIENT_KEY= +YANDEX_CAPTCHA_SERVER_KEY= SENTRY_DSN= ## SMTP params diff --git a/api/components/Captcha/CaptchaRegistry.php b/api/components/Captcha/CaptchaRegistry.php new file mode 100644 index 0000000..bddd607 --- /dev/null +++ b/api/components/Captcha/CaptchaRegistry.php @@ -0,0 +1,37 @@ + $providers provider name => provider + */ + public function __construct( + private array $providers, + ) { + } + + public function hasProvider(string $name): bool { + return isset($this->providers[$name]); + } + + /** + * @throws InvalidArgumentException if the provider isn't registered + */ + public function getProvider(string $name): ProviderInterface { + return $this->providers[$name] ?? throw new InvalidArgumentException(sprintf('The "%s" captcha provider isn\'t registered', $name)); + } + + /** + * @return array> provider name => its public params + */ + public function getPublicParams(): array { + return array_map(fn(ProviderInterface $provider): array => $provider->getPublicParams(), $this->providers); + } + +} diff --git a/api/components/Captcha/CaptchaTrait.php b/api/components/Captcha/CaptchaTrait.php new file mode 100644 index 0000000..2a166e0 --- /dev/null +++ b/api/components/Captcha/CaptchaTrait.php @@ -0,0 +1,22 @@ +> + */ + protected static function getCaptchaValidationRules(): array { + return [ + ['captcha', CaptchaValidator::class], + ['captchaType', 'safe'], + ]; + } + +} diff --git a/api/components/Captcha/CaptchaValidator.php b/api/components/Captcha/CaptchaValidator.php new file mode 100644 index 0000000..97be834 --- /dev/null +++ b/api/components/Captcha/CaptchaValidator.php @@ -0,0 +1,90 @@ +{$this->typeAttribute}; + $result = $this->validateCaptcha($model->$attribute, $type); + if ($result !== null) { + $this->addError($model, $attribute, $result[0], $result[1]); + } + } + + /** + * @return array{string, array}|null + */ + private function validateCaptcha(mixed $value, mixed $type): ?array { + if (empty($value) || !is_string($value)) { + return [$this->requiredMessage, []]; + } + + if (empty($type)) { + $type = $this->defaultType; + } + + if (!is_string($type) || !$this->captchaRegistry->hasProvider($type)) { + return [$this->typeMessage, []]; + } + + $provider = $this->captchaRegistry->getProvider($type); + if (!$this->verifyWithRetries($provider, $value, Yii::$app->getRequest()->getUserIP())) { + return [(string)$this->message, []]; + } + + return null; + } + + private function verifyWithRetries(ProviderInterface $provider, string $token, ?string $remoteIp): bool { + $repeats = 0; + while (true) { + try { + return $provider->verify($token, $remoteIp); + } catch (ConnectException|ServerException $e) { + if (++$repeats >= self::REPEAT_LIMIT) { + throw $e; + } + + sleep(self::REPEAT_TIMEOUT); + } + } + } + +} diff --git a/api/components/Captcha/Providers/ProviderInterface.php b/api/components/Captcha/Providers/ProviderInterface.php new file mode 100644 index 0000000..4d34907 --- /dev/null +++ b/api/components/Captcha/Providers/ProviderInterface.php @@ -0,0 +1,22 @@ + + */ + public function getPublicParams(): array; + + /** + * @throws \GuzzleHttp\Exception\GuzzleException network errors must be thrown as is, so that the caller can retry the request + * @throws \yii\base\Exception when the provider returns a malformed response + */ + public function verify(string $token, ?string $remoteIp): bool; + +} diff --git a/api/components/Captcha/Providers/ReCaptchaEnterprise.php b/api/components/Captcha/Providers/ReCaptchaEnterprise.php new file mode 100644 index 0000000..6640cd1 --- /dev/null +++ b/api/components/Captcha/Providers/ReCaptchaEnterprise.php @@ -0,0 +1,61 @@ + $this->siteKey, + ]; + } + + public function verify(string $token, ?string $remoteIp): bool { + $event = [ + 'token' => $token, + 'siteKey' => $this->siteKey, + ]; + if ($remoteIp !== null) { + $event['userIpAddress'] = $remoteIp; + } + + $response = $this->client->request('POST', sprintf('%s/projects/%s/assessments', self::BASE_URL, rawurlencode($this->projectId)), [ + RequestOptions::QUERY => [ + 'key' => $this->apiKey, + ], + RequestOptions::JSON => [ + 'event' => $event, + ], + ]); + + $data = json_decode((string)$response->getBody(), true, flags: JSON_THROW_ON_ERROR); + if (!is_array($data) || !isset($data['tokenProperties']) || !is_array($data['tokenProperties'])) { + throw new Exception('Invalid reCAPTCHA Enterprise assessment response.'); + } + + return ($data['tokenProperties']['valid'] ?? false) === true; + } + +} diff --git a/api/components/Captcha/Providers/YandexSmartCaptcha.php b/api/components/Captcha/Providers/YandexSmartCaptcha.php new file mode 100644 index 0000000..83a0f91 --- /dev/null +++ b/api/components/Captcha/Providers/YandexSmartCaptcha.php @@ -0,0 +1,53 @@ + $this->clientKey, + ]; + } + + public function verify(string $token, ?string $remoteIp): bool { + $params = [ + 'secret' => $this->serverKey, + 'token' => $token, + ]; + if ($remoteIp !== null) { + $params['ip'] = $remoteIp; + } + + $response = $this->client->request('POST', self::VALIDATE_URL, [ + RequestOptions::FORM_PARAMS => $params, + ]); + + $data = json_decode((string)$response->getBody(), true, flags: JSON_THROW_ON_ERROR); + if (!is_array($data) || !isset($data['status'])) { + throw new Exception('Invalid Yandex SmartCaptcha validation response.'); + } + + return $data['status'] === 'ok'; + } + +} diff --git a/api/components/ReCaptcha/Component.php b/api/components/ReCaptcha/Component.php deleted file mode 100644 index 2848c4f..0000000 --- a/api/components/ReCaptcha/Component.php +++ /dev/null @@ -1,22 +0,0 @@ -public === null) { - throw new InvalidConfigException('Public is required'); - } - - if ($this->secret === null) { - throw new InvalidConfigException('Secret is required'); - } - } - -} diff --git a/api/components/ReCaptcha/Validator.php b/api/components/ReCaptcha/Validator.php deleted file mode 100644 index c6826b0..0000000 --- a/api/components/ReCaptcha/Validator.php +++ /dev/null @@ -1,89 +0,0 @@ -component = Instance::ensure($this->component, Component::class); - } - - /** - * @inheritdoc - */ - protected function validateValue($value): ?array { - if (empty($value)) { - return [$this->requiredMessage, []]; - } - - $repeats = 0; - do { - $isSuccess = true; - try { - $response = $this->performRequest($value); - } catch (ConnectException|ServerException $e) { - if (++$repeats >= self::REPEAT_LIMIT) { - throw $e; - } - - $isSuccess = false; - sleep(self::REPEAT_TIMEOUT); - } - } while (!$isSuccess); - - /** @noinspection PhpUndefinedVariableInspection */ - $data = json_decode($response->getBody(), true); - if (!isset($data['success'])) { - throw new Exception('Invalid recaptcha verify response.'); - } - - if (!$data['success']) { - return [$this->message, []]; - } - - return null; - } - - /** - * @throws \GuzzleHttp\Exception\GuzzleException - */ - protected function performRequest(string $value): ResponseInterface { - return $this->client->request('POST', self::SITE_VERIFY_URL, [ - 'form_params' => [ - 'secret' => $this->component->secret, - 'response' => $value, - 'remoteip' => Yii::$app->getRequest()->getUserIP(), - ], - ]); - } - -} diff --git a/api/config/config-dev.php b/api/config/config-dev.php index 7b9ef41..17f133b 100644 --- a/api/config/config-dev.php +++ b/api/config/config-dev.php @@ -1,9 +1,5 @@ [ - 'reCaptcha' => [ - 'public' => '6LfwqCYTAAAAAJlaJpqCdzESCjAXUC81Ca6jBHR7', - 'secret' => '6LfwqCYTAAAAAFPjHmgwmnjSMFeAOJh7Lk5xxDMC', - ], ], ]; diff --git a/api/config/config-test.php b/api/config/config-test.php index e9f8adc..ad39356 100644 --- a/api/config/config-test.php +++ b/api/config/config-test.php @@ -6,21 +6,16 @@ return [ 'privateKeyPass' => null, 'encryptionKey' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ], - 'reCaptcha' => [ - 'public' => 'public-key', - 'secret' => 'private-key', - ], ], 'params' => [ 'authserverHost' => 'localhost', ], 'container' => [ 'singletons' => [ - api\components\ReCaptcha\Validator::class => fn(): api\components\ReCaptcha\Validator => new class(new GuzzleHttp\Client()) extends api\components\ReCaptcha\Validator { - protected function validateValue($value): ?array { - return null; - } - }, + api\components\Captcha\CaptchaRegistry::class => fn(): api\components\Captcha\CaptchaRegistry => new api\components\Captcha\CaptchaRegistry([ + api\components\Captcha\Providers\ReCaptchaEnterprise::NAME => new api\tests\_support\Captcha\FakeCaptchaProvider('public-key'), + api\components\Captcha\Providers\YandexSmartCaptcha::NAME => new api\tests\_support\Captcha\FakeCaptchaProvider('yandex-public-key'), + ]), common\components\SkinsSystemApi::class => fn(): common\components\SkinsSystemApi => new class('http://chrly.ely.by') extends common\components\SkinsSystemApi { public function textures(string $username): ?array { return [ diff --git a/api/config/config.php b/api/config/config.php index e1dbe1c..4f715bc 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -22,6 +22,36 @@ return [ 'accounts' => api\modules\accounts\Module::class, 'oauth' => api\modules\oauth\Module::class, ], + 'container' => [ + 'singletons' => [ + api\components\Captcha\CaptchaRegistry::class => function(): api\components\Captcha\CaptchaRegistry { + $providers = []; + if (getenv('RECAPTCHA_SECRET') && getenv('RECAPTCHA_PUBLIC') && getenv('RECAPTCHA_PROJECT_ID')) { + $providers[api\components\Captcha\Providers\ReCaptchaEnterprise::NAME] = Yii::createObject([ + '__class' => api\components\Captcha\Providers\ReCaptchaEnterprise::class, + '__construct()' => [ + 'apiKey' => getenv('RECAPTCHA_SECRET'), + 'siteKey' => getenv('RECAPTCHA_PUBLIC'), + 'projectId' => getenv('RECAPTCHA_PROJECT_ID'), + ], + ]); + } + + if (getenv('YANDEX_CAPTCHA_SERVER_KEY') && getenv('YANDEX_CAPTCHA_CLIENT_KEY')) { + $providers[api\components\Captcha\Providers\YandexSmartCaptcha::NAME] = Yii::createObject([ + '__class' => api\components\Captcha\Providers\YandexSmartCaptcha::class, + '__construct()' => [ + 'serverKey' => getenv('YANDEX_CAPTCHA_SERVER_KEY'), + 'clientKey' => getenv('YANDEX_CAPTCHA_CLIENT_KEY'), + ], + ]); + } + + // Not Yii::createObject(): this closure is the container definition of CaptchaRegistry itself + return new api\components\Captcha\CaptchaRegistry($providers); + }, + ], + ], 'components' => [ 'user' => [ 'class' => api\components\User\Component::class, @@ -90,11 +120,6 @@ return [ 'showScriptName' => false, 'rules' => require __DIR__ . '/routes.php', ], - 'reCaptcha' => [ - 'class' => api\components\ReCaptcha\Component::class, - 'public' => getenv('RECAPTCHA_PUBLIC'), - 'secret' => getenv('RECAPTCHA_SECRET'), - ], 'response' => [ 'format' => yii\web\Response::FORMAT_JSON, ], diff --git a/api/controllers/OptionsController.php b/api/controllers/OptionsController.php index f5904c7..a9363e1 100644 --- a/api/controllers/OptionsController.php +++ b/api/controllers/OptionsController.php @@ -1,8 +1,9 @@ Yii::$app->reCaptcha->public, + // Kept for backward compatibility, use captcha instead + 'reCaptchaPublicKey' => $captchaRegistry->getProvider(CaptchaValidator::DEFAULT_TYPE)->getPublicParams()['publicKey'], + 'captcha' => $captchaRegistry->getPublicParams(), ]; } diff --git a/api/models/authentication/ForgotPasswordForm.php b/api/models/authentication/ForgotPasswordForm.php index 0c17c37..4cabe8f 100644 --- a/api/models/authentication/ForgotPasswordForm.php +++ b/api/models/authentication/ForgotPasswordForm.php @@ -3,7 +3,7 @@ declare(strict_types=1); namespace api\models\authentication; -use api\components\ReCaptcha\Validator as ReCaptchaValidator; +use api\components\Captcha\CaptchaTrait; use api\models\base\ApiForm; use common\components\UserFriendlyRandomKey; use common\helpers\Error as E; @@ -13,21 +13,20 @@ use common\models\EmailActivation; use common\tasks\SendPasswordRecoveryEmail; use Yii; use yii\base\ErrorException; +use yii\helpers\ArrayHelper; class ForgotPasswordForm extends ApiForm { - - public mixed $captcha = null; + use CaptchaTrait; public mixed $login = null; public function rules(): array { - return [ - ['captcha', ReCaptchaValidator::class], + return ArrayHelper::merge(self::getCaptchaValidationRules(), [ ['login', 'required', 'message' => E::LOGIN_REQUIRED], ['login', 'validateLogin'], ['login', 'validateActivity'], ['login', 'validateFrequency'], - ]; + ]); } public function validateLogin(string $attribute): void { diff --git a/api/models/authentication/RegistrationForm.php b/api/models/authentication/RegistrationForm.php index e739ab9..b5cd6e2 100644 --- a/api/models/authentication/RegistrationForm.php +++ b/api/models/authentication/RegistrationForm.php @@ -1,7 +1,7 @@ E::RULES_AGREEMENT_REQUIRED], ['username', UsernameValidator::class], @@ -51,7 +49,7 @@ class RegistrationForm extends ApiForm { ['lang', LanguageValidator::class], ['lang', 'default', 'value' => 'en'], - ]; + ]); } public function validatePasswordAndRePasswordMatch($attribute): void { diff --git a/api/models/authentication/RepeatAccountActivationForm.php b/api/models/authentication/RepeatAccountActivationForm.php index df98fb4..5b948ef 100644 --- a/api/models/authentication/RepeatAccountActivationForm.php +++ b/api/models/authentication/RepeatAccountActivationForm.php @@ -3,7 +3,7 @@ declare(strict_types=1); namespace api\models\authentication; -use api\components\ReCaptcha\Validator as ReCaptchaValidator; +use api\components\Captcha\CaptchaTrait; use api\models\base\ApiForm; use common\components\UserFriendlyRandomKey; use common\helpers\Error as E; @@ -13,21 +13,20 @@ use common\models\EmailActivation; use common\tasks\SendRegistrationEmail; use Webmozart\Assert\Assert; use Yii; +use yii\helpers\ArrayHelper; class RepeatAccountActivationForm extends ApiForm { - - public mixed $captcha = null; + use CaptchaTrait; public mixed $email = null; public function rules(): array { - return [ - ['captcha', ReCaptchaValidator::class], + return ArrayHelper::merge(self::getCaptchaValidationRules(), [ ['email', 'filter', 'filter' => 'trim'], ['email', 'required', 'message' => E::EMAIL_REQUIRED], ['email', 'validateEmailForAccount'], ['email', 'validateExistsActivation'], - ]; + ]); } public function validateEmailForAccount(string $attribute): void { diff --git a/api/tests/_pages/AuthenticationRoute.php b/api/tests/_pages/AuthenticationRoute.php index 9d8dba2..34f591d 100644 --- a/api/tests/_pages/AuthenticationRoute.php +++ b/api/tests/_pages/AuthenticationRoute.php @@ -3,6 +3,9 @@ declare(strict_types=1); namespace api\tests\_pages; +use api\components\Captcha\Providers\ReCaptchaEnterprise; +use api\tests\_support\Captcha\FakeCaptchaProvider; + final class AuthenticationRoute extends BasePage { public function login(string $login = '', string $password = '', bool|string|null $rememberMeOrToken = null, bool $rememberMe = false): void { @@ -24,6 +27,8 @@ final class AuthenticationRoute extends BasePage { $this->getActor()->sendPOST('/api/authentication/forgot-password', [ 'login' => $login, 'totp' => $token, + 'captcha' => FakeCaptchaProvider::VALID, + 'captchaType' => ReCaptchaEnterprise::NAME, ]); } diff --git a/api/tests/_pages/SignupRoute.php b/api/tests/_pages/SignupRoute.php index de78991..4f48470 100644 --- a/api/tests/_pages/SignupRoute.php +++ b/api/tests/_pages/SignupRoute.php @@ -1,14 +1,17 @@ getActor()->sendPOST('/api/signup', $registrationData); + $this->getActor()->sendPOST('/api/signup', $registrationData + ['captcha' => FakeCaptchaProvider::VALID, 'captchaType' => ReCaptchaEnterprise::NAME]); } public function sendRepeatMessage($email = ''): void { - $this->getActor()->sendPOST('/api/signup/repeat-message', ['email' => $email]); + $this->getActor()->sendPOST('/api/signup/repeat-message', ['email' => $email, 'captcha' => FakeCaptchaProvider::VALID, 'captchaType' => ReCaptchaEnterprise::NAME]); } public function confirm($key = ''): void { diff --git a/api/tests/_support/Captcha/FakeCaptchaProvider.php b/api/tests/_support/Captcha/FakeCaptchaProvider.php new file mode 100644 index 0000000..ea1e7a8 --- /dev/null +++ b/api/tests/_support/Captcha/FakeCaptchaProvider.php @@ -0,0 +1,27 @@ + $this->publicKey, + ]; + } + + public function verify(string $token, ?string $remoteIp): bool { + return $token === self::VALID; + } + +} diff --git a/api/tests/functional/OptionsCest.php b/api/tests/functional/OptionsCest.php index 2921901..f5798ea 100644 --- a/api/tests/functional/OptionsCest.php +++ b/api/tests/functional/OptionsCest.php @@ -12,14 +12,22 @@ class OptionsCest { $this->route = new OptionsRoute($I); } - public function testRecaptchaPublicKey(FunctionalTester $I): void { - $I->wantTo('Get recaptcha public key'); + public function testCaptchaPublicParams(FunctionalTester $I): void { + $I->wantTo('Get captcha public params'); $this->route->get(); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ 'reCaptchaPublicKey' => 'public-key', + 'captcha' => [ + 'recaptcha' => [ + 'publicKey' => 'public-key', + ], + 'yandex' => [ + 'publicKey' => 'yandex-public-key', + ], + ], ]); } diff --git a/api/tests/unit/components/Captcha/CaptchaRegistryTest.php b/api/tests/unit/components/Captcha/CaptchaRegistryTest.php new file mode 100644 index 0000000..8b80633 --- /dev/null +++ b/api/tests/unit/components/Captcha/CaptchaRegistryTest.php @@ -0,0 +1,51 @@ +createProvider('recaptcha-public'); + $yandex = $this->createProvider('yandex-public'); + $registry = new CaptchaRegistry([ + 'recaptcha' => $recaptcha, + 'yandex' => $yandex, + ]); + + $this->assertTrue($registry->hasProvider('recaptcha')); + $this->assertTrue($registry->hasProvider('yandex')); + $this->assertFalse($registry->hasProvider('unknown')); + $this->assertSame($recaptcha, $registry->getProvider('recaptcha')); + $this->assertSame($yandex, $registry->getProvider('yandex')); + $this->assertSame([ + 'recaptcha' => ['publicKey' => 'recaptcha-public'], + 'yandex' => ['publicKey' => 'yandex-public'], + ], $registry->getPublicParams()); + } + + public function testEmptyRegistry(): void { + $registry = new CaptchaRegistry([]); + $this->assertFalse($registry->hasProvider('recaptcha')); + $this->assertSame([], $registry->getPublicParams()); + } + + public function testGetUnregisteredProvider(): void { + $registry = new CaptchaRegistry([]); + $this->expectException(InvalidArgumentException::class); + $registry->getProvider('recaptcha'); + } + + private function createProvider(string $publicKey): ProviderInterface { + $provider = $this->createMock(ProviderInterface::class); + $provider->method('getPublicParams')->willReturn(['publicKey' => $publicKey]); + + return $provider; + } + +} diff --git a/api/tests/unit/components/Captcha/CaptchaValidatorTest.php b/api/tests/unit/components/Captcha/CaptchaValidatorTest.php new file mode 100644 index 0000000..9fd1f04 --- /dev/null +++ b/api/tests/unit/components/Captcha/CaptchaValidatorTest.php @@ -0,0 +1,103 @@ +createValidator([ + 'recaptcha' => new FakeCaptchaProvider('recaptcha-public'), + 'yandex' => new FakeCaptchaProvider('yandex-public'), + ]); + $model = $this->createModel($captcha, $captchaType); + $validator->validateAttribute($model, 'captcha'); + + $this->assertSame($expectedError === null ? [] : [$expectedError], $model->getErrors('captcha')); + } + + /** + * @return iterable + */ + public static function getValidationCases(): iterable { + yield 'empty token' => ['', null, 'error.captcha_required']; + yield 'null token' => [null, 'yandex', 'error.captcha_required']; + yield 'non-string token' => [[FakeCaptchaProvider::VALID], null, 'error.captcha_required']; + yield 'default provider when type is null' => [FakeCaptchaProvider::VALID, null, null]; + yield 'default provider when type is empty' => [FakeCaptchaProvider::VALID, '', null]; + yield 'explicit recaptcha' => [FakeCaptchaProvider::VALID, 'recaptcha', null]; + yield 'explicit yandex' => [FakeCaptchaProvider::VALID, 'yandex', null]; + yield 'invalid recaptcha token' => ['invalid', 'recaptcha', 'error.captcha_invalid']; + yield 'invalid yandex token' => ['invalid', 'yandex', 'error.captcha_invalid']; + yield 'unknown type' => [FakeCaptchaProvider::VALID, 'unknown', 'error.captcha_invalid']; + yield 'non-string type' => [FakeCaptchaProvider::VALID, ['recaptcha'], 'error.captcha_invalid']; + } + + public function testValidateUsesProviderSelectedByType(): void { + $validator = $this->createValidator([ + 'yandex' => new FakeCaptchaProvider('yandex-public'), + ]); + + $model = $this->createModel(FakeCaptchaProvider::VALID, 'yandex'); + $validator->validateAttribute($model, 'captcha'); + $this->assertFalse($model->hasErrors()); + + // The default (recaptcha) provider isn't registered, so the yandex one must not be used implicitly + $model = $this->createModel(FakeCaptchaProvider::VALID, null); + $validator->validateAttribute($model, 'captcha'); + $this->assertSame(['error.captcha_invalid'], $model->getErrors('captcha')); + } + + public function testValidateWithNetworkTroubles(): void { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->exactly(2))->method('verify')->willReturnOnConsecutiveCalls( + $this->throwException($this->createMock(ConnectException::class)), + true, + ); + $this->getFunctionMock(CaptchaValidator::class, 'sleep')->expects($this->once()); + + $model = $this->createModel('token', null); + $this->createValidator(['recaptcha' => $provider])->validateAttribute($model, 'captcha'); + $this->assertFalse($model->hasErrors()); + } + + public function testValidateWithHugeNetworkTroubles(): void { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->exactly(3))->method('verify')->willThrowException($this->createMock(ServerException::class)); + $this->getFunctionMock(CaptchaValidator::class, 'sleep')->expects($this->exactly(2)); + + $this->expectException(ServerException::class); + $this->createValidator(['recaptcha' => $provider])->validateAttribute($this->createModel('token', null), 'captcha'); + } + + /** + * @param array $providers + */ + private function createValidator(array $providers): CaptchaValidator { + return new CaptchaValidator(new CaptchaRegistry($providers)); + } + + private function createModel(mixed $captcha, mixed $captchaType): Model { + return new class($captcha, $captchaType) extends Model { + public function __construct( + public mixed $captcha, + public mixed $captchaType, + ) { + parent::__construct(); + } + }; + } + +} diff --git a/api/tests/unit/components/Captcha/Providers/ReCaptchaEnterpriseTest.php b/api/tests/unit/components/Captcha/Providers/ReCaptchaEnterpriseTest.php new file mode 100644 index 0000000..745d5fe --- /dev/null +++ b/api/tests/unit/components/Captcha/Providers/ReCaptchaEnterpriseTest.php @@ -0,0 +1,71 @@ +createProvider($this->createMock(GuzzleClientInterface::class)); + $this->assertSame(['publicKey' => 'site-key'], $provider->getPublicParams()); + } + + public function testVerifyValidToken(): void { + $client = $this->createMock(GuzzleClientInterface::class); + $client->expects($this->once())->method('request')->with( + 'POST', + 'https://recaptchaenterprise.googleapis.com/v1/projects/my-project/assessments', + [ + 'query' => ['key' => 'api-key'], + 'json' => [ + 'event' => [ + 'token' => 'token', + 'siteKey' => 'site-key', + 'userIpAddress' => '127.0.0.1', + ], + ], + ], + )->willReturn(new Response(200, [], (string)json_encode([ + 'name' => 'projects/123/assessments/456', + 'tokenProperties' => [ + 'valid' => true, + 'invalidReason' => 'INVALID_REASON_UNSPECIFIED', + 'hostname' => 'account.ely.by', + 'action' => '', + ], + ]))); + + $this->assertTrue($this->createProvider($client)->verify('token', '127.0.0.1')); + } + + public function testVerifyInvalidToken(): void { + $client = $this->createMock(GuzzleClientInterface::class); + $client->method('request')->willReturn(new Response(200, [], (string)json_encode([ + 'tokenProperties' => [ + 'valid' => false, + 'invalidReason' => 'MALFORMED', + ], + ]))); + + $this->assertFalse($this->createProvider($client)->verify('token', null)); + } + + public function testVerifyMalformedResponse(): void { + $client = $this->createMock(GuzzleClientInterface::class); + $client->method('request')->willReturn(new Response(200, [], '{}')); + + $this->expectException(Exception::class); + $this->createProvider($client)->verify('token', null); + } + + private function createProvider(GuzzleClientInterface $client): ReCaptchaEnterprise { + return new ReCaptchaEnterprise('api-key', 'site-key', 'my-project', $client); + } + +} diff --git a/api/tests/unit/components/Captcha/Providers/YandexSmartCaptchaTest.php b/api/tests/unit/components/Captcha/Providers/YandexSmartCaptchaTest.php new file mode 100644 index 0000000..3974d97 --- /dev/null +++ b/api/tests/unit/components/Captcha/Providers/YandexSmartCaptchaTest.php @@ -0,0 +1,62 @@ +createProvider($this->createMock(ClientInterface::class)); + $this->assertSame(['publicKey' => 'client-key'], $provider->getPublicParams()); + } + + public function testVerifyValidToken(): void { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once())->method('request')->with( + 'POST', + 'https://smartcaptcha.cloud.yandex.ru/validate', + [ + 'form_params' => [ + 'secret' => 'server-key', + 'token' => 'token', + 'ip' => '127.0.0.1', + ], + ], + )->willReturn(new Response(200, [], (string)json_encode([ + 'status' => 'ok', + 'message' => '', + 'host' => 'account.ely.by', + ]))); + + $this->assertTrue($this->createProvider($client)->verify('token', '127.0.0.1')); + } + + public function testVerifyInvalidToken(): void { + $client = $this->createMock(ClientInterface::class); + $client->method('request')->willReturn(new Response(200, [], (string)json_encode([ + 'status' => 'failed', + 'message' => 'Invalid or expired Token.', + ]))); + + $this->assertFalse($this->createProvider($client)->verify('token', null)); + } + + public function testVerifyMalformedResponse(): void { + $client = $this->createMock(ClientInterface::class); + $client->method('request')->willReturn(new Response(200, [], '{"message":""}')); + + $this->expectException(Exception::class); + $this->createProvider($client)->verify('token', null); + } + + private function createProvider(ClientInterface $client): YandexSmartCaptcha { + return new YandexSmartCaptcha('server-key', 'client-key', $client); + } + +} diff --git a/api/tests/unit/components/ReCaptcha/ValidatorTest.php b/api/tests/unit/components/ReCaptcha/ValidatorTest.php deleted file mode 100644 index 4c75d5e..0000000 --- a/api/tests/unit/components/ReCaptcha/ValidatorTest.php +++ /dev/null @@ -1,72 +0,0 @@ -createMock(ClientInterface::class)); - $this->assertFalse($validator->validate('', $error)); - $this->assertSame('error.captcha_required', $error, 'Get error.captcha_required, if passed empty value'); - } - - public function testValidateInvalidValue(): void { - $mockClient = $this->createMock(ClientInterface::class); - $mockClient->method('request')->willReturn(new Response(200, [], json_encode([ - 'success' => false, - 'error-codes' => [ - 'invalid-input-response', // The response parameter is invalid or malformed. - ], - ]))); - - $validator = new Validator($mockClient); - $this->assertFalse($validator->validate('12341234', $error)); - $this->assertSame('error.captcha_invalid', $error, 'Get error.captcha_invalid, if passed wrong value'); - } - - public function testValidateWithNetworkTroubles(): void { - $mockClient = $this->createMock(ClientInterface::class); - $mockClient->expects($this->exactly(2))->method('request')->willReturnOnConsecutiveCalls( - $this->throwException($this->createMock(ConnectException::class)), - $this->returnValue(new Response(200, [], json_encode([ - 'success' => true, - 'error-codes' => [ - 'invalid-input-response', // The response parameter is invalid or malformed. - ], - ]))), - ); - $this->getFunctionMock(Validator::class, 'sleep')->expects($this->once()); - - $validator = new Validator($mockClient); - $this->assertTrue($validator->validate('12341234', $error)); - $this->assertNull($error); - } - - public function testValidateWithHugeNetworkTroubles(): void { - $mockClient = $this->createMock(ClientInterface::class); - $mockClient->expects($this->exactly(3))->method('request')->willThrowException($this->createMock(ConnectException::class)); - $this->getFunctionMock(Validator::class, 'sleep')->expects($this->exactly(2)); - - $validator = new Validator($mockClient); - $this->expectException(ConnectException::class); - $validator->validate('12341234', $error); - } - - public function testValidateValidValue(): void { - $mockClient = $this->createMock(ClientInterface::class); - $mockClient->method('request')->willReturn(new Response(200, [], json_encode([ - 'success' => true, - ]))); - $validator = new Validator($mockClient); - $this->assertTrue($validator->validate('12341234', $error)); - $this->assertNull($error); - } - -} diff --git a/api/tests/unit/models/authentication/ForgotPasswordFormTest.php b/api/tests/unit/models/authentication/ForgotPasswordFormTest.php index 1c775d6..1b52266 100644 --- a/api/tests/unit/models/authentication/ForgotPasswordFormTest.php +++ b/api/tests/unit/models/authentication/ForgotPasswordFormTest.php @@ -3,29 +3,19 @@ declare(strict_types=1); namespace api\tests\unit\models\authentication; -use api\components\ReCaptcha\Validator as ReCaptchaValidator; +use api\components\Captcha\Providers\ReCaptchaEnterprise; use api\models\authentication\ForgotPasswordForm; +use api\tests\_support\Captcha\FakeCaptchaProvider; use api\tests\unit\TestCase; use common\models\Account; use common\models\EmailActivation; use common\tasks\SendPasswordRecoveryEmail; use common\tests\fixtures\AccountFixture; use common\tests\fixtures\EmailActivationFixture; -use GuzzleHttp\ClientInterface; use PHPUnit\Framework\MockObject\MockObject; -use Yii; class ForgotPasswordFormTest extends TestCase { - protected function setUp(): void { - parent::setUp(); - Yii::$container->set(ReCaptchaValidator::class, new class($this->createMock(ClientInterface::class)) extends ReCaptchaValidator { - public function validateValue($value): ?array { - return null; - } - }); - } - public function _fixtures(): array { return [ 'accounts' => AccountFixture::class, @@ -81,7 +71,7 @@ class ForgotPasswordFormTest extends TestCase { public function testForgotPassword(): void { /** @var Account $account */ $account = $this->tester->grabFixture('accounts', 'admin'); - $model = new ForgotPasswordForm(['login' => $account->username]); + $model = new ForgotPasswordForm(['login' => $account->username, 'captcha' => FakeCaptchaProvider::VALID, 'captchaType' => ReCaptchaEnterprise::NAME]); $this->assertTrue($model->forgotPassword(), 'form should be successfully processed'); $activation = $model->getEmailActivation(); $this->assertInstanceOf(EmailActivation::class, $activation, 'getEmailActivation should return valid object instance'); @@ -92,7 +82,7 @@ class ForgotPasswordFormTest extends TestCase { public function testForgotPasswordResend(): void { /** @var Account $account */ $account = $this->tester->grabFixture('accounts', 'account-with-expired-forgot-password-message'); - $model = new ForgotPasswordForm(['login' => $account->username]); + $model = new ForgotPasswordForm(['login' => $account->username, 'captcha' => FakeCaptchaProvider::VALID, 'captchaType' => ReCaptchaEnterprise::NAME]); $callTime = time(); $this->assertTrue($model->forgotPassword(), 'form should be successfully processed'); $emailActivation = $model->getEmailActivation(); diff --git a/api/tests/unit/models/authentication/RegistrationFormTest.php b/api/tests/unit/models/authentication/RegistrationFormTest.php index 4a5e5a9..a6d088d 100644 --- a/api/tests/unit/models/authentication/RegistrationFormTest.php +++ b/api/tests/unit/models/authentication/RegistrationFormTest.php @@ -3,8 +3,9 @@ declare(strict_types=1); namespace api\tests\unit\models\authentication; -use api\components\ReCaptcha\Validator as ReCaptchaValidator; +use api\components\Captcha\Providers\ReCaptchaEnterprise; use api\models\authentication\RegistrationForm; +use api\tests\_support\Captcha\FakeCaptchaProvider; use api\tests\unit\TestCase; use common\models\Account; use common\models\EmailActivation; @@ -13,7 +14,6 @@ use common\tasks\SendRegistrationEmail; use common\tests\fixtures\AccountFixture; use common\tests\fixtures\EmailActivationFixture; use common\tests\fixtures\UsernameHistoryFixture; -use GuzzleHttp\ClientInterface; use Yii; use yii\validators\EmailValidator; use yii\web\Request; @@ -24,11 +24,6 @@ class RegistrationFormTest extends TestCase { protected function setUp(): void { parent::setUp(); $this->mockRequest(); - Yii::$container->set(ReCaptchaValidator::class, new class($this->createMock(ClientInterface::class)) extends ReCaptchaValidator { - public function validateValue($value): ?array { - return null; - } - }); } public function _fixtures(): array { @@ -64,6 +59,8 @@ class RegistrationFormTest extends TestCase { 'password' => 'some_password', 'rePassword' => 'some_password', 'rulesAgreement' => true, + 'captcha' => FakeCaptchaProvider::VALID, + 'captchaType' => ReCaptchaEnterprise::NAME, 'lang' => 'ru', ]); @@ -82,6 +79,8 @@ class RegistrationFormTest extends TestCase { 'password' => 'some_password', 'rePassword' => 'some_password', 'rulesAgreement' => true, + 'captcha' => FakeCaptchaProvider::VALID, + 'captchaType' => ReCaptchaEnterprise::NAME, ]); $account = $model->signup(); diff --git a/api/tests/unit/models/authentication/RepeatAccountActivationFormTest.php b/api/tests/unit/models/authentication/RepeatAccountActivationFormTest.php index 91fb1d9..cd170bc 100644 --- a/api/tests/unit/models/authentication/RepeatAccountActivationFormTest.php +++ b/api/tests/unit/models/authentication/RepeatAccountActivationFormTest.php @@ -3,28 +3,18 @@ declare(strict_types=1); namespace api\tests\unit\models\authentication; -use api\components\ReCaptcha\Validator as ReCaptchaValidator; +use api\components\Captcha\Providers\ReCaptchaEnterprise; use api\models\authentication\RepeatAccountActivationForm; +use api\tests\_support\Captcha\FakeCaptchaProvider; use api\tests\unit\TestCase; use common\models\Account; use common\models\confirmations\RegistrationConfirmation; use common\tasks\SendRegistrationEmail; use common\tests\fixtures\AccountFixture; use common\tests\fixtures\EmailActivationFixture; -use GuzzleHttp\ClientInterface; -use Yii; class RepeatAccountActivationFormTest extends TestCase { - protected function setUp(): void { - parent::setUp(); - Yii::$container->set(ReCaptchaValidator::class, new class($this->createMock(ClientInterface::class)) extends ReCaptchaValidator { - public function validateValue($value): ?array { - return null; - } - }); - } - public function _fixtures(): array { return [ 'accounts' => AccountFixture::class, @@ -71,7 +61,7 @@ class RepeatAccountActivationFormTest extends TestCase { /** @var \common\models\Account $account */ $account = $this->tester->grabFixture('accounts', 'not-activated-account-with-expired-message'); - $model = new RepeatAccountActivationForm(['email' => $account->email]); + $model = new RepeatAccountActivationForm(['email' => $account->email, 'captcha' => FakeCaptchaProvider::VALID, 'captchaType' => ReCaptchaEnterprise::NAME]); $this->assertTrue($model->sendRepeatMessage()); $activation = $model->getActivation(); $this->assertNotNull($activation); diff --git a/autocompletion.php b/autocompletion.php index 257612f..5d67262 100644 --- a/autocompletion.php +++ b/autocompletion.php @@ -33,7 +33,6 @@ abstract class BaseApplication extends yii\base\Application { * Include only Web application related components here * * @property \api\components\User\Component $user - * @property \api\components\ReCaptcha\Component $reCaptcha * @property \api\components\Tokens\Component $tokens * @property \api\components\Tokens\TokensFactory $tokensFactory * diff --git a/docker/nginx/account.ely.by.conf.template b/docker/nginx/account.ely.by.conf.template index 6b5e841..f13c5a6 100644 --- a/docker/nginx/account.ely.by.conf.template +++ b/docker/nginx/account.ely.by.conf.template @@ -10,7 +10,7 @@ server { add_header X-Frame-Options "sameorigin" always; add_header X-XSS-Protection "1; mode=block" always; add_header X-Content-Type-Options "nosniff" always; - add_header Content-Security-Policy "default-src 'none';style-src 'self' 'unsafe-inline';script-src 'self' 'unsafe-inline' https://www.google-analytics.com https://recaptcha.net/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.gstatic.cn/recaptcha/;img-src 'self' data: www.google-analytics.com;font-src 'self' data:;connect-src 'self' https://sentry.io https://sentry.ely.by;frame-src https://www.google.com/recaptcha/ https://recaptcha.net/recaptcha/"; + add_header Content-Security-Policy "default-src 'none';style-src 'self' 'unsafe-inline';script-src 'self' 'unsafe-inline' https://www.google-analytics.com https://recaptcha.net/recaptcha/ https://www.gstatic.com/recaptcha/ https://smartcaptcha.cloud.yandex.ru/;img-src 'self' data: www.google-analytics.com;font-src 'self' data:;connect-src 'self' https://sentry.io https://sentry.ely.by;frame-src https://recaptcha.net/recaptcha/ https://smartcaptcha.cloud.yandex.ru/"; # You can uncomment the next lines to enable debug mode # rewrite_log on; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f8c843d..bb39796 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,31 +5,6 @@ parameters: count: 1 path: api/components/ErrorHandler.php - - - message: "#^Property api\\\\components\\\\ReCaptcha\\\\Component\\:\\:\\$public has no type specified\\.$#" - count: 1 - path: api/components/ReCaptcha/Component.php - - - - message: "#^Property api\\\\components\\\\ReCaptcha\\\\Component\\:\\:\\$secret has no type specified\\.$#" - count: 1 - path: api/components/ReCaptcha/Component.php - - - - message: "#^Cannot access property \\$secret on api\\\\components\\\\ReCaptcha\\\\Component\\|string\\.$#" - count: 1 - path: api/components/ReCaptcha/Validator.php - - - - message: "#^Method api\\\\components\\\\ReCaptcha\\\\Validator\\:\\:validateValue\\(\\) should return array\\{string, array\\\\|null\\}\\|null but returns array\\{string\\|null, array\\{\\}\\}\\.$#" - count: 1 - path: api/components/ReCaptcha/Validator.php - - - - message: "#^Property api\\\\components\\\\ReCaptcha\\\\Validator\\:\\:\\$component \\(api\\\\components\\\\ReCaptcha\\\\Component\\|string\\) does not accept object\\.$#" - count: 1 - path: api/components/ReCaptcha/Validator.php - - message: "#^Cannot access offset 'key' on array\\|false\\.$#" count: 1 @@ -86,12 +61,12 @@ parameters: path: api/components/User/LegacyOAuth2Identity.php - - message: "#^Method class@anonymous/api/config/config\\-test\\.php\\:24\\:\\:profile\\(\\) return type has no value type specified in iterable type array\\.$#" + message: "#^Method class@anonymous/api/config/config\\-test\\.php\\:19\\:\\:profile\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: api/config/config-test.php - - message: "#^Method class@anonymous/api/config/config\\-test\\.php\\:24\\:\\:textures\\(\\) return type has no value type specified in iterable type array\\.$#" + message: "#^Method class@anonymous/api/config/config\\-test\\.php\\:19\\:\\:textures\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: api/config/config-test.php @@ -185,11 +160,6 @@ parameters: count: 1 path: api/models/authentication/RegistrationForm.php - - - message: "#^Property api\\\\models\\\\authentication\\\\RegistrationForm\\:\\:\\$captcha has no type specified\\.$#" - count: 1 - path: api/models/authentication/RegistrationForm.php - - message: "#^Property api\\\\models\\\\authentication\\\\RegistrationForm\\:\\:\\$email has no type specified\\.$#" count: 1 @@ -770,11 +740,6 @@ parameters: count: 1 path: api/tests/unit/TestCase.php - - - message: "#^Parameter \\#3 \\$body of class GuzzleHttp\\\\Psr7\\\\Response constructor expects Psr\\\\Http\\\\Message\\\\StreamInterface\\|resource\\|string\\|null, string\\|false given\\.$#" - count: 3 - path: api/tests/unit/components/ReCaptcha/ValidatorTest.php - - message: "#^Method api\\\\tests\\\\unit\\\\components\\\\Tokens\\\\TokenReaderTest\\:\\:getAccountIdTestCases\\(\\) return type has no value type specified in iterable type iterable\\.$#" count: 1