Implemented Altcha support

This commit is contained in:
ErickSkrauch committed 2026-10-19 22:25:05 +02:00
1 parent 8d72b9564b
commit 5b0c4c6917
26 files changed
+1046 -8

No files matched your search

+23
View File
@@ -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
+103
View File
@@ -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,
];
}
+10
View File
@@ -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 {
+30
View File
@@ -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);
},
+3
View File
@@ -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',
+55
View File
@@ -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();
}
}
+2 -1
View File
@@ -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(),
];
}
+16
View File
@@ -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(),
];
}
}
+84
View File
@@ -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;
}
}
+23
View File
@@ -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();
+64
View File
@@ -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');
+3
View File
@@ -27,6 +27,9 @@ class OptionsCest {
'yandex' => [
'publicKey' => 'yandex-public-key',
],
'altcha' => [
'challengeUrl' => '/api/captcha/altcha/challenge',
],
],
]);
}
+37
View File
@@ -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)));
}
}
+1
View File
@@ -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
View File
@@ -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"
}