mirror of
https://github.com/elyby/accounts.git
synced 2026-10-20 12:48:07 +05:30
Implemented Altcha support
This commit is contained in:
26 files changed
+1046
-8
No files matched your search
@@ -20,6 +20,29 @@ RECAPTCHA_PROJECT_ID=
|
||||
# Yandex SmartCaptcha. Leave empty to disable
|
||||
YANDEX_CAPTCHA_CLIENT_KEY=
|
||||
YANDEX_CAPTCHA_SERVER_KEY=
|
||||
# ALTCHA (self-hosted proof-of-work captcha). Leave the secrets empty to disable.
|
||||
# Both secrets are required, must be random strings at least 32 bytes long and differ from each other
|
||||
ALTCHA_HMAC_SECRET=
|
||||
ALTCHA_HMAC_KEY_SECRET=
|
||||
# Difficulty: the server calculates the KDF once with ALTCHA_COST per challenge,
|
||||
# while the client's work is ALTCHA_COST × counter, where counter is random in [MIN, MAX]
|
||||
#
|
||||
# Tuning under attack: raise the counter range, not the cost. The cost affects both the server and
|
||||
# the client, while the counter affects only the client. Keep ALTCHA_COST within 1000-5000
|
||||
# (~0.1-0.6 ms of server CPU per challenge). Client time estimates (average counter × cost iterations,
|
||||
# single thread; a weak Android device is assumed to be ~8x slower than a desktop):
|
||||
# normal: COST=1000, COUNTER 500-1500 ~1M iterations, ~0.1 s desktop, ~1 s weak phone
|
||||
# elevated: COST=1000, COUNTER 2000-6000 ~4M iterations, ~0.5 s desktop, ~4 s weak phone
|
||||
# siege: COST=1000, COUNTER 5000-15000 ~10M iterations, ~1.2 s desktop, ~10 s weak phone
|
||||
# Going beyond "siege" hurts real mobile users more than bots: at that point switch the users to the paid
|
||||
# captchas instead (e.g. disable ALTCHA by emptying ALTCHA_HMAC_SECRET, the frontend falls back to the next
|
||||
# provider). PoW only makes each attempt more expensive, a botnet with enough CPU still gets through.
|
||||
# New values apply to new challenges only, already issued ones remain valid until ALTCHA_TTL expires.
|
||||
ALTCHA_COST=1000
|
||||
ALTCHA_COUNTER_MIN=500
|
||||
ALTCHA_COUNTER_MAX=1500
|
||||
# Challenge lifetime in seconds
|
||||
ALTCHA_TTL=600
|
||||
SENTRY_DSN=
|
||||
|
||||
## SMTP params
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\components\Captcha\Providers;
|
||||
|
||||
use AltchaOrg\Altcha\Algorithm\DeriveKeyInterface;
|
||||
use AltchaOrg\Altcha\Altcha as AltchaLib;
|
||||
use AltchaOrg\Altcha\CreateChallengeOptions;
|
||||
use AltchaOrg\Altcha\Payload as AltchaPayload;
|
||||
use AltchaOrg\Altcha\VerifySolutionOptions;
|
||||
use api\components\Captcha\Providers\Altcha\UsedChallengesStorage;
|
||||
use Carbon\Carbon;
|
||||
use InvalidArgumentException;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
/**
|
||||
* @see https://altcha.org
|
||||
*/
|
||||
final readonly class Altcha implements ProviderInterface {
|
||||
|
||||
public const string NAME = 'altcha';
|
||||
|
||||
/**
|
||||
* The counter is packed as uint32 (counterMode: 'uint32' on the client side)
|
||||
*/
|
||||
private const int MAX_COUNTER = 0xFFFFFFFF;
|
||||
|
||||
/**
|
||||
* @param positive-int $cost the KDF cost. The server calculates the KDF once per issued challenge,
|
||||
* while the client's work is cost × counter
|
||||
* @param non-negative-int $counterMin the client finds the counter value by brute force, starting from 0
|
||||
* @param non-negative-int $counterMax
|
||||
* @param positive-int $ttl challenge lifetime in seconds
|
||||
* @param non-empty-string $challengeUrl
|
||||
*/
|
||||
public function __construct(
|
||||
private AltchaLib $altcha,
|
||||
private DeriveKeyInterface $algorithm,
|
||||
private UsedChallengesStorage $usedChallenges,
|
||||
private int $cost,
|
||||
private int $counterMin,
|
||||
private int $counterMax,
|
||||
private int $ttl,
|
||||
private string $challengeUrl = '/api/captcha/altcha/challenge',
|
||||
) {
|
||||
Assert::greaterThanEq($counterMin, 0);
|
||||
Assert::greaterThanEq($counterMax, $counterMin);
|
||||
Assert::lessThanEq($counterMax, self::MAX_COUNTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* challengeUrl: non-empty-string,
|
||||
* }
|
||||
*/
|
||||
public function getPublicParams(): array {
|
||||
return [
|
||||
'challengeUrl' => $this->challengeUrl,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Each call produces a new unique challenge
|
||||
*
|
||||
* @return array<string, mixed> the challenge, which is passed to the client as is
|
||||
*/
|
||||
public function createChallenge(): array {
|
||||
return $this->altcha->createChallenge(new CreateChallengeOptions(
|
||||
algorithm: $this->algorithm,
|
||||
cost: $this->cost,
|
||||
counter: random_int($this->counterMin, $this->counterMax), // @phpstan-ignore argument.type (counterMin <= counterMax is asserted in the constructor)
|
||||
expiresAt: Carbon::now()->getTimestamp() + $this->ttl,
|
||||
))->toArray();
|
||||
}
|
||||
|
||||
public function verify(string $token, ?string $remoteIp): bool {
|
||||
try {
|
||||
$payload = AltchaPayload::fromBase64($token);
|
||||
} catch (InvalidArgumentException) {
|
||||
return false; // malformed
|
||||
}
|
||||
|
||||
$result = $this->altcha->verifySolution(new VerifySolutionOptions($payload, $this->algorithm));
|
||||
if (!$result->verified) {
|
||||
return false; // expired, invalid signature or invalid solution
|
||||
}
|
||||
|
||||
// The library treats a missing or zero expiresAt as "never expires".
|
||||
// The replay protection relies on the TTL, so such challenges are never accepted.
|
||||
$expiresAt = $payload->challenge->parameters->expiresAt;
|
||||
if (!$expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The signature can be trusted only after the successful verification.
|
||||
// Not the solution: on the fast verification path the counter isn't checked.
|
||||
/** @var string $signature verified signature always exists */
|
||||
$signature = $payload->challenge->signature;
|
||||
|
||||
return $this->usedChallenges->markUsed($signature, $expiresAt);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\components\Captcha\Providers\Altcha;
|
||||
|
||||
use AltchaOrg\Altcha\Algorithm\DeriveKeyInterface;
|
||||
use AltchaOrg\Altcha\Algorithm\DeriveKeyResult;
|
||||
use AltchaOrg\Altcha\ChallengeParameters;
|
||||
use AltchaOrg\Altcha\HmacAlgorithm;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A drop-in replacement for the library's {@see \AltchaOrg\Altcha\Algorithm\Pbkdf2}:
|
||||
* the same algorithm and the same result, but computed by OpenSSL, which is ~18x faster
|
||||
* than hash_pbkdf2(). It matters because the challenge issuing endpoint is public and
|
||||
* calculates the KDF once per request.
|
||||
*/
|
||||
final readonly class OpensslPbkdf2 implements DeriveKeyInterface {
|
||||
|
||||
public function __construct(
|
||||
private HmacAlgorithm $hmacAlgorithm = HmacAlgorithm::SHA256,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Must match the library's implementation: the name is passed in the challenge
|
||||
* and the client side chooses the KDF by it.
|
||||
*/
|
||||
public function getAlgorithmName(): string {
|
||||
return 'PBKDF2/' . $this->hmacAlgorithm->value;
|
||||
}
|
||||
|
||||
public function deriveKey(ChallengeParameters $parameters, string $salt, string $password): DeriveKeyResult {
|
||||
$derivedKey = openssl_pbkdf2(
|
||||
password: $password,
|
||||
salt: $salt,
|
||||
key_length: max(1, $parameters->keyLength),
|
||||
iterations: max(1, $parameters->cost),
|
||||
digest_algo: $this->hmacAlgorithm->hashAlgo(),
|
||||
);
|
||||
if ($derivedKey === false) {
|
||||
throw new RuntimeException('Unable to derive the key with OpenSSL: ' . openssl_error_string());
|
||||
}
|
||||
|
||||
return new DeriveKeyResult($derivedKey);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\components\Captcha\Providers\Altcha;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use yii\redis\Connection as RedisConnection;
|
||||
|
||||
/**
|
||||
* ALTCHA library checks only the signature, the expiration and the solution itself,
|
||||
* so without this storage the same solution could be submitted any number of times until it expires.
|
||||
*/
|
||||
final readonly class UsedChallengesStorage {
|
||||
|
||||
private const string KEY_PREFIX = 'captcha:altcha:used:';
|
||||
|
||||
/**
|
||||
* A small reserve for the clock drift between the app servers
|
||||
*/
|
||||
private const int TTL_RESERVE = 5;
|
||||
|
||||
public function __construct(
|
||||
private RedisConnection $redis,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically marks the challenge as used.
|
||||
*
|
||||
* The challenge signature must be passed only after a successful verification:
|
||||
* until then it's an untrusted user input. The solution must not be used as a key,
|
||||
* because on the fast verification path the counter isn't checked and can be changed freely.
|
||||
*
|
||||
* Redis errors aren't handled on purpose (fail closed).
|
||||
*
|
||||
* @param float $expiresAt unix timestamp of the challenge expiration, may be fractional
|
||||
*
|
||||
* @return bool true if the challenge is used for the first time
|
||||
*/
|
||||
public function markUsed(string $challengeSignature, float $expiresAt): bool {
|
||||
$ttl = max(1, (int)ceil($expiresAt) - Carbon::now()->getTimestamp() + self::TTL_RESERVE);
|
||||
|
||||
return $this->redis->set(self::KEY_PREFIX . $challengeSignature, '1', 'EX', $ttl, 'NX') === true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,10 +6,16 @@ namespace api\components\Captcha\Providers;
|
||||
interface ProviderInterface {
|
||||
|
||||
/**
|
||||
* Parameters the client side needs to render the captcha widget.
|
||||
* Must contain at least the "publicKey".
|
||||
* A well known param for traditional captcha providers.
|
||||
*/
|
||||
public const string PUBLIC_KEY_PARAM = 'publicKey';
|
||||
|
||||
/**
|
||||
* Provider-specific parameters the client side needs to render the captcha widget.
|
||||
* The response is shared between all clients (/api/options is cached by nginx),
|
||||
* so the params must not depend on the current request.
|
||||
*
|
||||
* @return array{publicKey: string}&array<string, mixed>
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getPublicParams(): array;
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ final readonly class ReCaptchaEnterprise implements ProviderInterface {
|
||||
|
||||
private const string BASE_URL = 'https://recaptchaenterprise.googleapis.com/v1';
|
||||
|
||||
/**
|
||||
* @param non-empty-string $siteKey
|
||||
*/
|
||||
public function __construct(
|
||||
private string $apiKey,
|
||||
private string $siteKey,
|
||||
@@ -26,9 +29,12 @@ final readonly class ReCaptchaEnterprise implements ProviderInterface {
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{publicKey: non-empty-string}
|
||||
*/
|
||||
public function getPublicParams(): array {
|
||||
return [
|
||||
'publicKey' => $this->siteKey,
|
||||
self::PUBLIC_KEY_PARAM => $this->siteKey,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ final readonly class YandexSmartCaptcha implements ProviderInterface {
|
||||
|
||||
private const string VALIDATE_URL = 'https://smartcaptcha.cloud.yandex.ru/validate';
|
||||
|
||||
/**
|
||||
* @param non-empty-string $clientKey
|
||||
*/
|
||||
public function __construct(
|
||||
private string $serverKey,
|
||||
private string $clientKey,
|
||||
@@ -23,9 +26,12 @@ final readonly class YandexSmartCaptcha implements ProviderInterface {
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{publicKey: non-empty-string}
|
||||
*/
|
||||
public function getPublicParams(): array {
|
||||
return [
|
||||
'publicKey' => $this->clientKey,
|
||||
self::PUBLIC_KEY_PARAM => $this->clientKey,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ return [
|
||||
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'),
|
||||
// The real provider with the minimal difficulty, so the tests can solve its challenges
|
||||
api\components\Captcha\Providers\Altcha::NAME => new api\components\Captcha\Providers\Altcha(
|
||||
altcha: new AltchaOrg\Altcha\Altcha('test-signature-secret-0123456789abcdef', 'test-key-signature-secret-0123456789ab'),
|
||||
algorithm: new api\components\Captcha\Providers\Altcha\OpensslPbkdf2(),
|
||||
usedChallenges: new api\components\Captcha\Providers\Altcha\UsedChallengesStorage(Yii::$app->redis),
|
||||
cost: 1,
|
||||
counterMin: 1,
|
||||
counterMax: 10,
|
||||
ttl: 600,
|
||||
),
|
||||
]),
|
||||
common\components\SkinsSystemApi::class => fn(): common\components\SkinsSystemApi => new class('http://chrly.ely.by') extends common\components\SkinsSystemApi {
|
||||
public function textures(string $username): ?array {
|
||||
|
||||
@@ -47,6 +47,36 @@ return [
|
||||
]);
|
||||
}
|
||||
|
||||
if (getenv('ALTCHA_HMAC_SECRET')) {
|
||||
$signatureSecret = getenv('ALTCHA_HMAC_SECRET');
|
||||
// The library treats an empty key secret as a valid HMAC key, so it must be checked explicitly
|
||||
$keySignatureSecret = (string)getenv('ALTCHA_HMAC_KEY_SECRET');
|
||||
Webmozart\Assert\Assert::minLength($signatureSecret, 32, 'ALTCHA_HMAC_SECRET must be at least 32 bytes long');
|
||||
Webmozart\Assert\Assert::minLength($keySignatureSecret, 32, 'ALTCHA_HMAC_KEY_SECRET must be at least 32 bytes long');
|
||||
Webmozart\Assert\Assert::notSame($signatureSecret, $keySignatureSecret, 'ALTCHA_HMAC_SECRET and ALTCHA_HMAC_KEY_SECRET must be different');
|
||||
$cost = (int)(getenv('ALTCHA_COST') ?: 1000);
|
||||
$counterMin = (int)(getenv('ALTCHA_COUNTER_MIN') ?: 500);
|
||||
$counterMax = (int)(getenv('ALTCHA_COUNTER_MAX') ?: 1500);
|
||||
$ttl = (int)(getenv('ALTCHA_TTL') ?: 600);
|
||||
Webmozart\Assert\Assert::positiveInteger($cost, 'ALTCHA_COST must be a positive integer');
|
||||
Webmozart\Assert\Assert::natural($counterMin, 'ALTCHA_COUNTER_MIN must be a non-negative integer');
|
||||
Webmozart\Assert\Assert::natural($counterMax, 'ALTCHA_COUNTER_MAX must be a non-negative integer');
|
||||
Webmozart\Assert\Assert::positiveInteger($ttl, 'ALTCHA_TTL must be a positive integer');
|
||||
|
||||
$providers[api\components\Captcha\Providers\Altcha::NAME] = Yii::createObject([
|
||||
'__class' => api\components\Captcha\Providers\Altcha::class,
|
||||
'__construct()' => [
|
||||
'altcha' => new AltchaOrg\Altcha\Altcha($signatureSecret, $keySignatureSecret),
|
||||
'algorithm' => new api\components\Captcha\Providers\Altcha\OpensslPbkdf2(),
|
||||
'usedChallenges' => new api\components\Captcha\Providers\Altcha\UsedChallengesStorage(Yii::$app->redis),
|
||||
'cost' => $cost,
|
||||
'counterMin' => $counterMin,
|
||||
'counterMax' => $counterMax,
|
||||
'ttl' => $ttl,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// Not Yii::createObject(): this closure is the container definition of CaptchaRegistry itself
|
||||
return new api\components\Captcha\CaptchaRegistry($providers);
|
||||
},
|
||||
|
||||
@@ -35,6 +35,9 @@ return [
|
||||
'DELETE /two-factor-auth' => 'accounts/default/disable-two-factor-auth',
|
||||
'POST /accounts/change-lang' => 'accounts/default/language',
|
||||
|
||||
// Captcha routes
|
||||
'GET /captcha/altcha/challenge' => 'captcha/altcha-challenge',
|
||||
|
||||
// Session server module routes
|
||||
'/minecraft/session/join' => 'session/session/join',
|
||||
'/minecraft/session/legacy/join' => 'session/session/join-legacy',
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\controllers;
|
||||
|
||||
use api\components\Captcha\CaptchaRegistry;
|
||||
use api\components\Captcha\Providers\Altcha;
|
||||
use api\filters\IpRateLimiter;
|
||||
use Yii;
|
||||
use yii\helpers\ArrayHelper;
|
||||
use yii\helpers\UnsetArrayValue;
|
||||
use yii\web\NotFoundHttpException;
|
||||
|
||||
final class CaptchaController extends Controller {
|
||||
|
||||
public function behaviors(): array {
|
||||
return ArrayHelper::merge(parent::behaviors(), [
|
||||
'authenticator' => new UnsetArrayValue(),
|
||||
// Issuing a challenge calculates the KDF once, so the public endpoint must be limited
|
||||
'rateLimiter' => [
|
||||
'class' => IpRateLimiter::class,
|
||||
'only' => ['altcha-challenge'],
|
||||
'keyPrefix' => 'captcha:altcha:challenge:ratelimit',
|
||||
'limits' => [
|
||||
60 => 30,
|
||||
3600 => 300,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string[]>
|
||||
*/
|
||||
public function verbs(): array {
|
||||
return [
|
||||
'altcha-challenge' => ['GET'],
|
||||
];
|
||||
}
|
||||
|
||||
public function actionAltchaChallenge(CaptchaRegistry $captchaRegistry): array {
|
||||
try {
|
||||
/** @var \api\components\Captcha\Providers\Altcha $altcha */
|
||||
$altcha = $captchaRegistry->getProvider(Altcha::NAME);
|
||||
} catch (\Throwable $e) {
|
||||
throw new NotFoundHttpException(previous: $e);
|
||||
}
|
||||
|
||||
// Every response is unique, so neither nginx nor the browser must cache it
|
||||
Yii::$app->response->headers->set('Cache-Control', 'no-store');
|
||||
|
||||
return $altcha->createChallenge();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@ namespace api\controllers;
|
||||
|
||||
use api\components\Captcha\CaptchaRegistry;
|
||||
use api\components\Captcha\CaptchaValidator;
|
||||
use api\components\Captcha\Providers\ProviderInterface;
|
||||
use api\filters\NginxCache;
|
||||
use yii\helpers\ArrayHelper;
|
||||
|
||||
@@ -33,7 +34,7 @@ class OptionsController extends Controller {
|
||||
): array {
|
||||
return [
|
||||
// Kept for backward compatibility, use captcha instead
|
||||
'reCaptchaPublicKey' => $captchaRegistry->getProvider(CaptchaValidator::DEFAULT_TYPE)->getPublicParams()['publicKey'],
|
||||
'reCaptchaPublicKey' => $captchaRegistry->getProvider(CaptchaValidator::DEFAULT_TYPE)->getPublicParams()[ProviderInterface::PUBLIC_KEY_PARAM],
|
||||
'captcha' => $captchaRegistry->getPublicParams(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\controllers;
|
||||
|
||||
final class TestController extends \yii\web\Controller {
|
||||
|
||||
public function actionIndex(): array {
|
||||
return [
|
||||
'isSecure' => $this->request->getIsSecureConnection(),
|
||||
'host' => $this->request->getHostInfo(),
|
||||
'headers' => $this->request->headers->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\filters;
|
||||
|
||||
use Webmozart\Assert\Assert;
|
||||
use Yii;
|
||||
use yii\base\ActionFilter;
|
||||
use yii\web\TooManyRequestsHttpException;
|
||||
|
||||
/**
|
||||
* Limits the number of requests from a single IP address with fixed windows stored in Redis.
|
||||
* IPv6 addresses are limited per /64 subnet, since a single client usually owns the whole subnet.
|
||||
*/
|
||||
final class IpRateLimiter extends ActionFilter {
|
||||
|
||||
/**
|
||||
* Increments the counter and sets its TTL in a single atomic operation,
|
||||
* so a counter can't be left without expiration (which would block the IP forever)
|
||||
*/
|
||||
private const string INCR_SCRIPT = <<<'LUA'
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
return count
|
||||
LUA;
|
||||
|
||||
/**
|
||||
* @var array<int, int> window duration in seconds => max number of requests within the window
|
||||
*/
|
||||
public array $limits = [];
|
||||
|
||||
public string $keyPrefix = '';
|
||||
|
||||
public function init(): void {
|
||||
parent::init();
|
||||
Assert::notEmpty($this->limits, 'At least one limit must be configured');
|
||||
Assert::stringNotEmpty($this->keyPrefix, 'keyPrefix must be configured');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TooManyRequestsHttpException
|
||||
*/
|
||||
public function beforeAction($action): bool {
|
||||
$ip = self::normalizeIp(Yii::$app->getRequest()->getUserIP());
|
||||
$isExceeded = false;
|
||||
// Every window is incremented even if one of them is already exceeded,
|
||||
// so that the requests made while being blocked count in the longer windows too
|
||||
foreach ($this->limits as $window => $limit) {
|
||||
$count = (int)Yii::$app->redis->executeCommand('EVAL', [
|
||||
self::INCR_SCRIPT,
|
||||
1,
|
||||
"{$this->keyPrefix}:{$window}:{$ip}",
|
||||
$window,
|
||||
]);
|
||||
if ($count > $limit) {
|
||||
$isExceeded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isExceeded) {
|
||||
throw new TooManyRequestsHttpException();
|
||||
}
|
||||
|
||||
return parent::beforeAction($action);
|
||||
}
|
||||
|
||||
private static function normalizeIp(?string $ip): string {
|
||||
if ($ip === null) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
||||
/** @var string $binary validated above */
|
||||
$binary = inet_pton($ip);
|
||||
|
||||
return inet_ntop(substr($binary, 0, 8) . str_repeat("\0", 8)) . '/64';
|
||||
}
|
||||
|
||||
return $ip;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace api\tests;
|
||||
|
||||
use AltchaOrg\Altcha\Algorithm\Pbkdf2;
|
||||
use AltchaOrg\Altcha\Altcha as AltchaLib;
|
||||
use AltchaOrg\Altcha\Challenge;
|
||||
use AltchaOrg\Altcha\Payload;
|
||||
use AltchaOrg\Altcha\SolveChallengeOptions;
|
||||
use api\tests\_generated\FunctionalTesterActions;
|
||||
use Codeception\Actor;
|
||||
use common\models\Account;
|
||||
@@ -25,6 +30,24 @@ class FunctionalTester extends Actor {
|
||||
return $account->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a new ALTCHA challenge and solves it like the client side does
|
||||
*
|
||||
* @return string the payload to be passed as the captcha value
|
||||
*/
|
||||
public function solveAltchaChallenge(): string {
|
||||
$this->sendGET('/api/captcha/altcha/challenge');
|
||||
$this->seeResponseCodeIs(200);
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = json_decode($this->grabResponse(), true, flags: JSON_THROW_ON_ERROR);
|
||||
$challenge = Challenge::fromArray($data);
|
||||
// Solving doesn't need the secrets
|
||||
$solution = (new AltchaLib())->solveChallenge(new SolveChallengeOptions($challenge, new Pbkdf2()));
|
||||
$this->assertNotNull($solution, 'Unable to solve the ALTCHA challenge');
|
||||
|
||||
return (new Payload($challenge, $solution))->toBase64();
|
||||
}
|
||||
|
||||
public function notLoggedIn(): void {
|
||||
$this->haveHttpHeader('Authorization', '');
|
||||
Yii::$app->user->logout();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\tests\functional;
|
||||
|
||||
use api\tests\FunctionalTester;
|
||||
use Yii;
|
||||
|
||||
final class CaptchaCest {
|
||||
|
||||
public function _before(FunctionalTester $I): void {
|
||||
$this->resetRateLimit();
|
||||
}
|
||||
|
||||
/**
|
||||
* The rate limit test exhausts the limit, which would break the next tests requesting challenges
|
||||
*/
|
||||
public function _after(FunctionalTester $I): void {
|
||||
$this->resetRateLimit();
|
||||
}
|
||||
|
||||
public function testAltchaChallenge(FunctionalTester $I): void {
|
||||
$I->wantTo('get a new altcha challenge without authorization');
|
||||
$I->sendGET('/api/captcha/altcha/challenge');
|
||||
$I->canSeeResponseCodeIs(200);
|
||||
$I->canSeeResponseIsJson();
|
||||
$I->canSeeHttpHeader('Cache-Control', 'no-store');
|
||||
$I->canSeeResponseContainsJson([
|
||||
'parameters' => [
|
||||
'algorithm' => 'PBKDF2/SHA-256',
|
||||
],
|
||||
]);
|
||||
$I->canSeeResponseJsonMatchesJsonPath('$.signature');
|
||||
$I->canSeeResponseJsonMatchesJsonPath('$.parameters.keySignature');
|
||||
$I->canSeeResponseJsonMatchesJsonPath('$.parameters.expiresAt');
|
||||
$firstSignature = $I->grabDataFromResponseByJsonPath('$.signature')[0];
|
||||
|
||||
$I->wantTo('get a different challenge on the next request');
|
||||
$I->sendGET('/api/captcha/altcha/challenge');
|
||||
$I->canSeeResponseCodeIs(200);
|
||||
$I->assertNotSame($firstSignature, $I->grabDataFromResponseByJsonPath('$.signature')[0]);
|
||||
}
|
||||
|
||||
public function testRateLimit(FunctionalTester $I): void {
|
||||
$I->wantTo('get 429 after exceeding the per minute limit');
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$I->sendGET('/api/captcha/altcha/challenge');
|
||||
$I->canSeeResponseCodeIs(200);
|
||||
}
|
||||
|
||||
$I->sendGET('/api/captcha/altcha/challenge');
|
||||
$I->canSeeResponseCodeIs(429);
|
||||
}
|
||||
|
||||
private function resetRateLimit(): void {
|
||||
// Rate limiter counters live in Redis and would leak between the tests otherwise.
|
||||
// KEYS is fine for the test Redis, but must never be used in the app code
|
||||
$keys = Yii::$app->redis->keys('captcha:altcha:challenge:ratelimit:*');
|
||||
if ($keys !== []) {
|
||||
Yii::$app->redis->del(...$keys);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -44,6 +44,16 @@ class ForgotPasswordCest {
|
||||
$this->assertSuccessResponse($I, true);
|
||||
}
|
||||
|
||||
public function testForgotPasswordWithAltcha(FunctionalTester $I): void {
|
||||
$I->wantTo('create new password recover request with the altcha captcha');
|
||||
$I->sendPOST('/api/authentication/forgot-password', [
|
||||
'login' => 'Admin',
|
||||
'captcha' => $I->solveAltchaChallenge(),
|
||||
'captchaType' => 'altcha',
|
||||
]);
|
||||
$this->assertSuccessResponse($I, true);
|
||||
}
|
||||
|
||||
public function testDataForFrequencyError(FunctionalTester $I): void {
|
||||
$I->wantTo('get info about time to repeat recover password request');
|
||||
$this->route->forgotPassword('Notch');
|
||||
|
||||
@@ -27,6 +27,9 @@ class OptionsCest {
|
||||
'yandex' => [
|
||||
'publicKey' => 'yandex-public-key',
|
||||
],
|
||||
'altcha' => [
|
||||
'challengeUrl' => '/api/captcha/altcha/challenge',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,43 @@ class RegisterCest {
|
||||
$I->cantSeeResponseJsonMatchesJsonPath('$.errors');
|
||||
}
|
||||
|
||||
public function testRegistrationWithAltcha(FunctionalTester $I): void {
|
||||
$I->wantTo('register with the altcha captcha');
|
||||
$captcha = $I->solveAltchaChallenge();
|
||||
$I->sendPOST('/api/signup', [
|
||||
'username' => 'altcha_user',
|
||||
'email' => 'altcha_user@gmail.com',
|
||||
'password' => 'some_password',
|
||||
'rePassword' => 'some_password',
|
||||
'rulesAgreement' => true,
|
||||
'lang' => 'ru',
|
||||
'captcha' => $captcha,
|
||||
'captchaType' => 'altcha',
|
||||
]);
|
||||
$I->canSeeResponseCodeIs(200);
|
||||
$I->canSeeResponseContainsJson(['success' => true]);
|
||||
$I->cantSeeResponseJsonMatchesJsonPath('$.errors');
|
||||
|
||||
$I->wantTo('not be able to reuse the same altcha solution');
|
||||
$I->sendPOST('/api/signup', [
|
||||
'username' => 'altcha_user2',
|
||||
'email' => 'altcha_user2@gmail.com',
|
||||
'password' => 'some_password',
|
||||
'rePassword' => 'some_password',
|
||||
'rulesAgreement' => true,
|
||||
'lang' => 'ru',
|
||||
'captcha' => $captcha,
|
||||
'captchaType' => 'altcha',
|
||||
]);
|
||||
$I->canSeeResponseCodeIs(200);
|
||||
$I->canSeeResponseContainsJson([
|
||||
'success' => false,
|
||||
'errors' => [
|
||||
'captcha' => 'error.captcha_invalid',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getInvalidInputExamples
|
||||
*/
|
||||
|
||||
@@ -66,4 +66,17 @@ class RepeatAccountActivationCest {
|
||||
$I->cantSeeResponseJsonMatchesJsonPath('$.errors');
|
||||
}
|
||||
|
||||
public function testSuccessWithAltcha(FunctionalTester $I): void {
|
||||
$I->wantTo('successfully resend account activation message with the altcha captcha');
|
||||
$I->sendPOST('/api/signup/repeat-message', [
|
||||
'email' => 'jon@ely.by',
|
||||
'captcha' => $I->solveAltchaChallenge(),
|
||||
'captchaType' => 'altcha',
|
||||
]);
|
||||
$I->canSeeResponseCodeIs(200);
|
||||
$I->canSeeResponseIsJson();
|
||||
$I->canSeeResponseContainsJson(['success' => true]);
|
||||
$I->cantSeeResponseJsonMatchesJsonPath('$.errors');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\tests\unit\components\Captcha\Providers\Altcha;
|
||||
|
||||
use AltchaOrg\Altcha\Algorithm\Pbkdf2;
|
||||
use AltchaOrg\Altcha\ChallengeParameters;
|
||||
use AltchaOrg\Altcha\HmacAlgorithm;
|
||||
use api\components\Captcha\Providers\Altcha\OpensslPbkdf2;
|
||||
use api\tests\unit\TestCase;
|
||||
|
||||
final class OpensslPbkdf2Test extends TestCase {
|
||||
|
||||
/**
|
||||
* @dataProvider hmacAlgorithmsProvider
|
||||
*/
|
||||
public function testSameAsLibraryImplementation(HmacAlgorithm $hmacAlgorithm): void {
|
||||
$library = new Pbkdf2($hmacAlgorithm);
|
||||
$openssl = new OpensslPbkdf2($hmacAlgorithm);
|
||||
|
||||
$this->assertSame($library->getAlgorithmName(), $openssl->getAlgorithmName());
|
||||
|
||||
$salt = random_bytes(16);
|
||||
$password = random_bytes(16) . pack('N', 12345);
|
||||
foreach ([[1, 32], [1000, 32], [1000, 16], [1000, 64]] as [$cost, $keyLength]) {
|
||||
$parameters = new ChallengeParameters(algorithm: $library->getAlgorithmName(), nonce: '', salt: '', cost: $cost, keyLength: $keyLength);
|
||||
$this->assertSame(
|
||||
bin2hex($library->deriveKey($parameters, $salt, $password)->derivedKey),
|
||||
bin2hex($openssl->deriveKey($parameters, $salt, $password)->derivedKey),
|
||||
"cost={$cost}, keyLength={$keyLength}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{HmacAlgorithm}>
|
||||
*/
|
||||
public static function hmacAlgorithmsProvider(): iterable {
|
||||
foreach (HmacAlgorithm::cases() as $case) {
|
||||
yield $case->value => [$case];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\tests\unit\components\Captcha\Providers\Altcha;
|
||||
|
||||
use api\components\Captcha\Providers\Altcha\UsedChallengesStorage;
|
||||
use api\tests\unit\TestCase;
|
||||
use Carbon\Carbon;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use yii\redis\Connection;
|
||||
use yii\redis\SocketException;
|
||||
|
||||
final class UsedChallengesStorageTest extends TestCase {
|
||||
|
||||
private const int NOW = 1792436000;
|
||||
|
||||
private Connection&MockObject $redis;
|
||||
|
||||
private UsedChallengesStorage $storage;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
Carbon::setTestNow(Carbon::createFromTimestamp(self::NOW));
|
||||
$this->redis = $this->getMockBuilder(Connection::class)
|
||||
->onlyMethods(['executeCommand'])
|
||||
->getMock();
|
||||
$this->storage = new UsedChallengesStorage($this->redis);
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
Carbon::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testFirstUsage(): void {
|
||||
$this->redis->expects($this->once())
|
||||
->method('executeCommand')
|
||||
->with('SET', ['captcha:altcha:used:signature', '1', 'EX', 605, 'NX'])
|
||||
->willReturn(true);
|
||||
|
||||
$this->assertTrue($this->storage->markUsed('signature', self::NOW + 600));
|
||||
}
|
||||
|
||||
public function testRepeatedUsage(): void {
|
||||
// SET NX replies with nil when the key already exists
|
||||
$this->redis->method('executeCommand')->willReturn(null);
|
||||
|
||||
$this->assertFalse($this->storage->markUsed('signature', self::NOW + 600));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider ttlProvider
|
||||
*/
|
||||
public function testTtl(int|float $expiresAt, int $expectedTtl): void {
|
||||
$this->redis->expects($this->once())
|
||||
->method('executeCommand')
|
||||
->with('SET', $this->callback(fn(array $params): bool => $params[3] === $expectedTtl))
|
||||
->willReturn(true);
|
||||
|
||||
$this->storage->markUsed('signature', $expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{int|float, int}>
|
||||
*/
|
||||
public static function ttlProvider(): iterable {
|
||||
yield 'fractional expiresAt is rounded up' => [self::NOW + 600.2, 606];
|
||||
yield 'already expired challenge still gets a positive TTL' => [self::NOW - 100, 1];
|
||||
}
|
||||
|
||||
public function testRedisFailureIsNotSuppressed(): void {
|
||||
$this->redis->method('executeCommand')->willThrowException(new SocketException('Connection refused'));
|
||||
|
||||
$this->expectException(SocketException::class);
|
||||
$this->storage->markUsed('signature', self::NOW + 600);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\tests\unit\components\Captcha\Providers;
|
||||
|
||||
use AltchaOrg\Altcha\Algorithm\Pbkdf2;
|
||||
use AltchaOrg\Altcha\Altcha as AltchaLib;
|
||||
use AltchaOrg\Altcha\Challenge;
|
||||
use AltchaOrg\Altcha\CreateChallengeOptions;
|
||||
use AltchaOrg\Altcha\Payload;
|
||||
use AltchaOrg\Altcha\Solution;
|
||||
use AltchaOrg\Altcha\SolveChallengeOptions;
|
||||
use api\components\Captcha\Providers\Altcha;
|
||||
use api\components\Captcha\Providers\Altcha\OpensslPbkdf2;
|
||||
use api\components\Captcha\Providers\Altcha\UsedChallengesStorage;
|
||||
use api\tests\unit\TestCase;
|
||||
use Carbon\Carbon;
|
||||
use yii\redis\Connection;
|
||||
|
||||
/**
|
||||
* Carbon isn't frozen here on purpose: the library checks the expiration against the real time.
|
||||
*/
|
||||
final class AltchaTest extends TestCase {
|
||||
|
||||
private const string SIGNATURE_SECRET = 'sig-secret-0123456789abcdef0123456789';
|
||||
private const string KEY_SIGNATURE_SECRET = 'key-secret-0123456789abcdef0123456';
|
||||
|
||||
/**
|
||||
* Generated by altcha-lib 2.6.0 (JS, Node, altcha-lib/algorithms/pbkdf2) with the secrets above:
|
||||
* cost 10, counter 50, expiresAt 4102444800 (2100-01-01). Ensures that PHP accepts payloads produced
|
||||
* by the JS library. Must be regenerated with altcha-lib/algorithms/web when the frontend pins its version.
|
||||
*/
|
||||
private const string GOLDEN_PAYLOAD = 'eyJjaGFsbGVuZ2UiOnsicGFyYW1ldGVycyI6eyJhbGdvcml0aG0iOiJQQktERjIvU0hBLTI1NiIsImNvc3QiOjEwLCJleHBpcmVzQXQiOjQxMDI0NDQ4MDAsImtleUxlbmd0aCI6MzIsImtleVByZWZpeCI6IjI0MTgyYTFjNWZmNTc4MmMyYjgxODI1ZTQ4OTI2YjQ5Iiwia2V5U2lnbmF0dXJlIjoiODg3ZDQ0OTk1OGZjMDdiMDYwMmMwNzEwODU2NDVhM2UwZjVmN2Y5NGQ1ODg2NTJiM2MyYzliN2ExNTA4ZDVjMyIsIm5vbmNlIjoiYjYwOWE0YTBjMTgyYzBlMWJhNzUzMmQ3MzU1MGJmNGMiLCJzYWx0IjoiZDMyMjBjOTczMDFkOGM0ZGNiNTQ4YzUyZDU3MTRkOTEifSwic2lnbmF0dXJlIjoiZjFiYTNhZTk1NDQ0NTk5NDA2ZTE5ZjNlNzE5YTk0NDg4NjY3Y2FmZjEzNjM3YTkyNzU3YmFmYWZiNDkzNzY1NSJ9LCJzb2x1dGlvbiI6eyJjb3VudGVyIjo1MCwiZGVyaXZlZEtleSI6IjI0MTgyYTFjNWZmNTc4MmMyYjgxODI1ZTQ4OTI2YjQ5ODVjZjZiNWM4ZDVmYWRjZDkwZWE3Nzc4MDA2YzY2MGUiLCJ0aW1lIjoxLjN9fQ==';
|
||||
|
||||
private const int TTL = 600;
|
||||
|
||||
private Altcha $provider;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// In-memory SET NX: true for a new key, nil for the existing one
|
||||
$usedKeys = [];
|
||||
$redis = $this->getMockBuilder(Connection::class)->onlyMethods(['executeCommand'])->getMock();
|
||||
$redis->method('executeCommand')->willReturnCallback(function(string $command, array $params) use (&$usedKeys): ?bool {
|
||||
$this->assertSame('SET', $command);
|
||||
if (isset($usedKeys[$params[0]])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$usedKeys[$params[0]] = true;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->provider = new Altcha(
|
||||
altcha: $this->createLibrary(),
|
||||
algorithm: new OpensslPbkdf2(),
|
||||
usedChallenges: new UsedChallengesStorage($redis),
|
||||
cost: 1,
|
||||
counterMin: 1,
|
||||
counterMax: 10,
|
||||
ttl: self::TTL,
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetPublicParams(): void {
|
||||
$this->assertSame(['challengeUrl' => '/api/captcha/altcha/challenge'], $this->provider->getPublicParams());
|
||||
}
|
||||
|
||||
public function testCreateChallenge(): void {
|
||||
$first = $this->provider->createChallenge();
|
||||
$second = $this->provider->createChallenge();
|
||||
|
||||
$this->assertNotSame($first['signature'], $second['signature']);
|
||||
$this->assertSame('PBKDF2/SHA-256', $first['parameters']['algorithm']);
|
||||
$this->assertArrayHasKey('keySignature', $first['parameters'], 'The fast verification path must be enabled');
|
||||
$this->assertEqualsWithDelta(time() + self::TTL, $first['parameters']['expiresAt'], 2);
|
||||
}
|
||||
|
||||
public function testVerifyValidSolution(): void {
|
||||
$this->assertTrue($this->provider->verify($this->solve($this->provider->createChallenge())->toBase64(), null));
|
||||
}
|
||||
|
||||
public function testVerifyGoldenPayload(): void {
|
||||
$this->assertTrue($this->provider->verify(self::GOLDEN_PAYLOAD, null));
|
||||
}
|
||||
|
||||
public function testVerifyReplay(): void {
|
||||
$token = $this->solve($this->provider->createChallenge())->toBase64();
|
||||
|
||||
$this->assertTrue($this->provider->verify($token, null));
|
||||
$this->assertFalse($this->provider->verify($token, null));
|
||||
}
|
||||
|
||||
public function testVerifyReplayWithChangedCounter(): void {
|
||||
$payload = $this->solve($this->provider->createChallenge());
|
||||
// On the fast path the library doesn't check the counter, so this payload is still valid for it
|
||||
$changed = new Payload($payload->challenge, new Solution($payload->solution->counter + 1, $payload->solution->derivedKey));
|
||||
|
||||
$this->assertTrue($this->provider->verify($payload->toBase64(), null));
|
||||
$this->assertFalse($this->provider->verify($changed->toBase64(), null));
|
||||
}
|
||||
|
||||
public function testVerifyExpired(): void {
|
||||
Carbon::setTestNow(Carbon::now()->subSeconds(self::TTL + 10));
|
||||
try {
|
||||
$challenge = $this->provider->createChallenge();
|
||||
} finally {
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
|
||||
$this->assertFalse($this->provider->verify($this->solve($challenge)->toBase64(), null));
|
||||
}
|
||||
|
||||
public function testVerifyChallengeSignedWithAnotherSecret(): void {
|
||||
$foreign = new AltchaLib('another-secret-0123456789abcdef01234', self::KEY_SIGNATURE_SECRET);
|
||||
$challenge = $foreign->createChallenge(new CreateChallengeOptions(algorithm: new Pbkdf2(), cost: 1, counter: 5, expiresAt: time() + self::TTL));
|
||||
|
||||
$this->assertFalse($this->provider->verify($this->solve($challenge->toArray())->toBase64(), null));
|
||||
}
|
||||
|
||||
public function testVerifyTamperedDerivedKey(): void {
|
||||
$payload = $this->solve($this->provider->createChallenge());
|
||||
$tampered = new Payload($payload->challenge, new Solution($payload->solution->counter, str_repeat('0', 64)));
|
||||
|
||||
$this->assertFalse($this->provider->verify($tampered->toBase64(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider noExpirationProvider
|
||||
*/
|
||||
public function testVerifyChallengeWithoutExpiration(?int $expiresAt): void {
|
||||
$challenge = $this->createLibrary()->createChallenge(new CreateChallengeOptions(algorithm: new Pbkdf2(), cost: 1, counter: 5, expiresAt: $expiresAt));
|
||||
|
||||
$this->assertFalse($this->provider->verify($this->solve($challenge->toArray())->toBase64(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{int|null}>
|
||||
*/
|
||||
public static function noExpirationProvider(): iterable {
|
||||
yield 'missing' => [null];
|
||||
yield 'zero' => [0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider malformedTokensProvider
|
||||
*/
|
||||
public function testVerifyMalformedToken(string $token): void {
|
||||
$this->assertFalse($this->provider->verify($token, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function malformedTokensProvider(): iterable {
|
||||
yield 'empty string' => [''];
|
||||
yield 'not base64' => ['!!!'];
|
||||
yield 'not json' => [base64_encode('not json')];
|
||||
yield 'json scalar' => [base64_encode('42')];
|
||||
yield 'no solution' => [base64_encode('{"challenge":{"parameters":{}}}')];
|
||||
yield 'invalid counter' => [base64_encode('{"challenge":{"parameters":{}},"solution":{"counter":"1","derivedKey":"00"}}')];
|
||||
}
|
||||
|
||||
private function createLibrary(): AltchaLib {
|
||||
return new AltchaLib(self::SIGNATURE_SECRET, self::KEY_SIGNATURE_SECRET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Solves with the library's own PBKDF2 implementation, which also cross-checks {@see OpensslPbkdf2}
|
||||
*
|
||||
* @param array<string, mixed> $challenge
|
||||
*/
|
||||
private function solve(array $challenge): Payload {
|
||||
$challenge = Challenge::fromArray($challenge);
|
||||
$solution = $this->createLibrary()->solveChallenge(new SolveChallengeOptions($challenge, new Pbkdf2()));
|
||||
$this->assertNotNull($solution);
|
||||
|
||||
return new Payload($challenge, $solution);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\tests\unit\filters;
|
||||
|
||||
use api\filters\IpRateLimiter;
|
||||
use api\tests\unit\TestCase;
|
||||
use Yii;
|
||||
use yii\base\Action;
|
||||
use yii\base\Controller;
|
||||
use yii\redis\Connection;
|
||||
use yii\web\Request;
|
||||
use yii\web\TooManyRequestsHttpException;
|
||||
|
||||
final class IpRateLimiterTest extends TestCase {
|
||||
|
||||
/**
|
||||
* @var array<string, int> key => counter
|
||||
*/
|
||||
private array $counters = [];
|
||||
|
||||
/**
|
||||
* @var array<string, int> key => TTL
|
||||
*/
|
||||
private array $ttls = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
// Emulates the Lua script: INCR + EXPIRE on the first increment
|
||||
$redis = $this->getMockBuilder(Connection::class)->onlyMethods(['executeCommand'])->getMock();
|
||||
$redis->method('executeCommand')->willReturnCallback(function(string $command, array $params): int {
|
||||
$this->assertSame('EVAL', $command);
|
||||
/** @var array{string, int, string, int} $params */
|
||||
[, , $key, $ttl] = $params;
|
||||
$this->counters[$key] = ($this->counters[$key] ?? 0) + 1;
|
||||
if ($this->counters[$key] === 1) {
|
||||
$this->ttls[$key] = $ttl;
|
||||
}
|
||||
|
||||
return $this->counters[$key];
|
||||
});
|
||||
Yii::$app->set('redis', $redis);
|
||||
}
|
||||
|
||||
public function testLimitIsExceeded(): void {
|
||||
$filter = $this->createFilter([60 => 2]);
|
||||
$this->assertTrue($this->request($filter, '10.0.0.1'));
|
||||
$this->assertTrue($this->request($filter, '10.0.0.1'));
|
||||
|
||||
$this->expectException(TooManyRequestsHttpException::class);
|
||||
$this->request($filter, '10.0.0.1');
|
||||
}
|
||||
|
||||
public function testIpsAreLimitedSeparately(): void {
|
||||
$filter = $this->createFilter([60 => 1]);
|
||||
$this->assertTrue($this->request($filter, '10.0.0.1'));
|
||||
$this->assertTrue($this->request($filter, '10.0.0.2'));
|
||||
}
|
||||
|
||||
public function testIpv6IsLimitedPerSubnet(): void {
|
||||
$filter = $this->createFilter([60 => 1]);
|
||||
$this->assertTrue($this->request($filter, '2001:db8:1:2::1'));
|
||||
|
||||
$this->expectException(TooManyRequestsHttpException::class);
|
||||
$this->request($filter, '2001:db8:1:2:ffff:ffff:ffff:ffff');
|
||||
}
|
||||
|
||||
public function testEveryWindowHasOwnCounterAndTtl(): void {
|
||||
$filter = $this->createFilter([60 => 5, 3600 => 1]);
|
||||
$this->assertTrue($this->request($filter, '10.0.0.1'));
|
||||
$this->assertSame([
|
||||
'test:60:10.0.0.1' => 60,
|
||||
'test:3600:10.0.0.1' => 3600,
|
||||
], $this->ttls);
|
||||
|
||||
$this->expectException(TooManyRequestsHttpException::class);
|
||||
$this->request($filter, '10.0.0.1');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $limits
|
||||
*/
|
||||
private function createFilter(array $limits): IpRateLimiter {
|
||||
return new IpRateLimiter([
|
||||
'keyPrefix' => 'test',
|
||||
'limits' => $limits,
|
||||
]);
|
||||
}
|
||||
|
||||
private function request(IpRateLimiter $filter, string $ip): bool {
|
||||
$request = $this->createMock(Request::class);
|
||||
$request->method('getUserIP')->willReturn($ip);
|
||||
Yii::$app->set('request', $request);
|
||||
|
||||
return $filter->beforeAction(new Action('test', $this->createMock(Controller::class)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
"ext-pdo": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-sodium": "*",
|
||||
"altcha-org/altcha": "~2.3.0",
|
||||
"bacon/bacon-qr-code": "^1.0",
|
||||
"ely/mojang-api": "^0.2.0",
|
||||
"ely/yii2-tempmail-validator": "dev-master",
|
||||
|
||||
Generated
+49
-2
@@ -4,8 +4,55 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "4d149183d9b22be925238bb0b0ea74c5",
|
||||
"content-hash": "3e64cde444aa504866e55aca9c75365a",
|
||||
"packages": [
|
||||
{
|
||||
"name": "altcha-org/altcha",
|
||||
"version": "v2.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/altcha-org/altcha-lib-php.git",
|
||||
"reference": "afcdf1592749726aac37b7671e33b501e06ea99e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/altcha-org/altcha-lib-php/zipball/afcdf1592749726aac37b7671e33b501e06ea99e",
|
||||
"reference": "afcdf1592749726aac37b7671e33b501e06ea99e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.72",
|
||||
"phpstan/extension-installer": "^1.4",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpunit/phpunit": "^10.5 || ^11.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"AltchaOrg\\Altcha\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Daniel Regeci",
|
||||
"email": "536331+ovx@users.noreply.github.com"
|
||||
}
|
||||
],
|
||||
"description": "A lightweight PHP library for creating and verifying ALTCHA challenges.",
|
||||
"support": {
|
||||
"issues": "https://github.com/altcha-org/altcha-lib-php/issues",
|
||||
"source": "https://github.com/altcha-org/altcha-lib-php/tree/v2.3.0"
|
||||
},
|
||||
"time": "2026-10-04T08:28:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
"version": "1.0.1",
|
||||
@@ -10894,5 +10941,5 @@
|
||||
"ext-sodium": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
Reference in new issue
Block a user