Implemented multiple captchas support. Implemented Yandex Smart Captcha support

This commit is contained in:
ErickSkrauch committed 2026-10-02 13:39:02 +02:00
1 parent a09c7a7035
commit b88d2cc2f6
31 files changed
+696 -299

No files matched your search

+5
View File
@@ -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
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace api\components\Captcha;
use api\components\Captcha\Providers\ProviderInterface;
use InvalidArgumentException;
final readonly class CaptchaRegistry {
/**
* @param array<string, ProviderInterface> $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<string, array<string, mixed>> provider name => its public params
*/
public function getPublicParams(): array {
return array_map(fn(ProviderInterface $provider): array => $provider->getPublicParams(), $this->providers);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace api\components\Captcha;
trait CaptchaTrait {
public mixed $captcha = null;
public mixed $captchaType = null;
/**
* @return list<array<int|string, mixed>>
*/
protected static function getCaptchaValidationRules(): array {
return [
['captcha', CaptchaValidator::class],
['captchaType', 'safe'],
];
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace api\components\Captcha;
use api\components\Captcha\Providers\ProviderInterface;
use api\components\Captcha\Providers\ReCaptchaEnterprise;
use common\helpers\Error as E;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
use Yii;
use yii\validators\Validator;
final class CaptchaValidator extends Validator {
public const string DEFAULT_TYPE = ReCaptchaEnterprise::NAME;
private const int REPEAT_LIMIT = 3;
private const int REPEAT_TIMEOUT = 1;
public $skipOnEmpty = false;
public $message = E::CAPTCHA_INVALID;
public string $requiredMessage = E::CAPTCHA_REQUIRED;
public string $typeMessage = E::CAPTCHA_INVALID;
/**
* The name of the model's attribute that contains the captcha provider name
*/
public string $typeAttribute = 'captchaType';
public string $defaultType = self::DEFAULT_TYPE;
public function __construct(
private readonly CaptchaRegistry $captchaRegistry,
$config = [],
) {
parent::__construct($config);
}
public function validateAttribute($model, $attribute): void {
$type = $model->{$this->typeAttribute};
$result = $this->validateCaptcha($model->$attribute, $type);
if ($result !== null) {
$this->addError($model, $attribute, $result[0], $result[1]);
}
}
/**
* @return array{string, array<string, string>}|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);
}
}
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace api\components\Captcha\Providers;
interface ProviderInterface {
/**
* Parameters the client side needs to render the captcha widget.
* Must contain at least the "publicKey".
*
* @return array{publicKey: string}&array<string, mixed>
*/
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;
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace api\components\Captcha\Providers;
use GuzzleHttp\ClientInterface as GuzzleClientInterface;
use GuzzleHttp\RequestOptions;
use yii\base\Exception;
/**
* Google reCAPTCHA (Google Cloud edition) with a checkbox key.
*
* @see https://docs.cloud.google.com/recaptcha/docs/create-assessment-website
*/
final readonly class ReCaptchaEnterprise implements ProviderInterface {
public const string NAME = 'recaptcha';
private const string BASE_URL = 'https://recaptchaenterprise.googleapis.com/v1';
public function __construct(
private string $apiKey,
private string $siteKey,
private string $projectId,
private GuzzleClientInterface $client,
) {
}
public function getPublicParams(): array {
return [
'publicKey' => $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;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace api\components\Captcha\Providers;
use GuzzleHttp\ClientInterface as GuzzleClientInterface;
use GuzzleHttp\RequestOptions;
use yii\base\Exception;
/**
* @see https://yandex.cloud/en/docs/smartcaptcha/concepts/validation
*/
final readonly class YandexSmartCaptcha implements ProviderInterface {
public const string NAME = 'yandex';
private const string VALIDATE_URL = 'https://smartcaptcha.cloud.yandex.ru/validate';
public function __construct(
private string $serverKey,
private string $clientKey,
private GuzzleClientInterface $client,
) {
}
public function getPublicParams(): array {
return [
'publicKey' => $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';
}
}
-22
View File
@@ -1,22 +0,0 @@
<?php
namespace api\components\ReCaptcha;
use yii\base\InvalidConfigException;
class Component extends \yii\base\Component {
public $public;
public $secret;
public function init(): void {
if ($this->public === null) {
throw new InvalidConfigException('Public is required');
}
if ($this->secret === null) {
throw new InvalidConfigException('Secret is required');
}
}
}
-89
View File
@@ -1,89 +0,0 @@
<?php
namespace api\components\ReCaptcha;
use common\helpers\Error as E;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
use Psr\Http\Message\ResponseInterface;
use Yii;
use yii\base\Exception;
use yii\di\Instance;
class Validator extends \yii\validators\Validator {
private const string SITE_VERIFY_URL = 'https://recaptcha.net/recaptcha/api/siteverify';
private const int REPEAT_LIMIT = 3;
private const int REPEAT_TIMEOUT = 1;
public $skipOnEmpty = false;
public $message = E::CAPTCHA_INVALID;
public string $requiredMessage = E::CAPTCHA_REQUIRED;
public Component|string $component = 'reCaptcha';
public function __construct(
private readonly ClientInterface $client,
array $config = [],
) {
parent::__construct($config);
}
public function init(): void {
parent::init();
$this->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(),
],
]);
}
}
-4
View File
@@ -1,9 +1,5 @@
<?php
return [
'components' => [
'reCaptcha' => [
'public' => '6LfwqCYTAAAAAJlaJpqCdzESCjAXUC81Ca6jBHR7',
'secret' => '6LfwqCYTAAAAAFPjHmgwmnjSMFeAOJh7Lk5xxDMC',
],
],
];
+4 -9
View File
@@ -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 [
+30 -5
View File
@@ -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,
],
+8 -3
View File
@@ -1,8 +1,9 @@
<?php
namespace api\controllers;
use api\components\Captcha\CaptchaRegistry;
use api\components\Captcha\CaptchaValidator;
use api\filters\NginxCache;
use Yii;
use yii\helpers\ArrayHelper;
class OptionsController extends Controller {
@@ -27,9 +28,13 @@ class OptionsController extends Controller {
];
}
public function actionIndex(): array {
public function actionIndex(
CaptchaRegistry $captchaRegistry,
): array {
return [
'reCaptchaPublicKey' => Yii::$app->reCaptcha->public,
// Kept for backward compatibility, use captcha instead
'reCaptchaPublicKey' => $captchaRegistry->getProvider(CaptchaValidator::DEFAULT_TYPE)->getPublicParams()['publicKey'],
'captcha' => $captchaRegistry->getPublicParams(),
];
}
@@ -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 {
@@ -1,7 +1,7 @@
<?php
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;
@@ -21,8 +21,7 @@ use yii\helpers\ArrayHelper;
use const common\LATEST_RULES_VERSION;
class RegistrationForm extends ApiForm {
public $captcha;
use CaptchaTrait;
public $username;
@@ -37,8 +36,7 @@ class RegistrationForm extends ApiForm {
public $lang;
public function rules() {
return [
['captcha', ReCaptchaValidator::class],
return ArrayHelper::merge(self::getCaptchaValidationRules(), [
['rulesAgreement', 'required', 'message' => 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 {
@@ -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 {
+5
View File
@@ -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,
]);
}
+5 -2
View File
@@ -1,14 +1,17 @@
<?php
namespace api\tests\_pages;
use api\components\Captcha\Providers\ReCaptchaEnterprise;
use api\tests\_support\Captcha\FakeCaptchaProvider;
class SignupRoute extends BasePage {
public function register(array $registrationData): void {
$this->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 {
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace api\tests\_support\Captcha;
use api\components\Captcha\Providers\ProviderInterface;
final readonly class FakeCaptchaProvider implements ProviderInterface {
public const string VALID = 'validCaptchaToken';
public function __construct(
private string $publicKey,
) {
}
public function getPublicParams(): array {
return [
'publicKey' => $this->publicKey,
];
}
public function verify(string $token, ?string $remoteIp): bool {
return $token === self::VALID;
}
}
+10 -2
View File
@@ -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',
],
],
]);
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace api\tests\unit\components\Captcha;
use api\components\Captcha\CaptchaRegistry;
use api\components\Captcha\Providers\ProviderInterface;
use api\tests\unit\TestCase;
use InvalidArgumentException;
final class CaptchaRegistryTest extends TestCase {
public function testProviders(): void {
$recaptcha = $this->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;
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace api\tests\unit\components\Captcha;
use api\components\Captcha\CaptchaRegistry;
use api\components\Captcha\CaptchaValidator;
use api\components\Captcha\Providers\ProviderInterface;
use api\tests\_support\Captcha\FakeCaptchaProvider;
use api\tests\unit\TestCase;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
use yii\base\Model;
final class CaptchaValidatorTest extends TestCase {
/**
* @dataProvider getValidationCases
*/
public function testValidateAttribute(mixed $captcha, mixed $captchaType, ?string $expectedError): void {
$validator = $this->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<string, array{mixed, mixed, string|null}>
*/
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<string, ProviderInterface> $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();
}
};
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace api\tests\unit\components\Captcha\Providers;
use api\components\Captcha\Providers\ReCaptchaEnterprise;
use api\tests\unit\TestCase;
use GuzzleHttp\ClientInterface as GuzzleClientInterface;
use GuzzleHttp\Psr7\Response;
use yii\base\Exception;
final class ReCaptchaEnterpriseTest extends TestCase {
public function testGetPublicParams(): void {
$provider = $this->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);
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace api\tests\unit\components\Captcha\Providers;
use api\components\Captcha\Providers\YandexSmartCaptcha;
use api\tests\unit\TestCase;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Psr7\Response;
use yii\base\Exception;
final class YandexSmartCaptchaTest extends TestCase {
public function testGetPublicParams(): void {
$provider = $this->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);
}
}
@@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace api\tests\unit\components\ReCaptcha;
use api\components\ReCaptcha\Validator;
use api\tests\unit\TestCase;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Response;
class ValidatorTest extends TestCase {
public function testValidateEmptyValue(): void {
$validator = new Validator($this->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);
}
}
@@ -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();
@@ -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();
@@ -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);
-1
View File
@@ -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
*
+1 -1
View File
@@ -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;
+2 -37
View File
@@ -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\\<string, string\\>\\|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