Implement ALTCHA captcha service with challenge generation and solution verification
- Added AltchaService class for managing ALTCHA captcha challenges and solutions. - Created CaptchaController to handle API requests for generating challenges. - Introduced CaptchaGuard for validating captcha solutions on public endpoints. - Developed unit tests for AltchaService to ensure challenge creation and solution verification functionality. - Implemented integration tests for the Captcha API endpoint and captcha bypass behavior when disabled. - Added documentation for the Captcha API in the corresponding markdown file.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared\Captcha;
|
||||
|
||||
use AltchaOrg\Altcha\V1\Altcha;
|
||||
use AltchaOrg\Altcha\V1\Hasher\Algorithm;
|
||||
use App\Shared\Captcha\AltchaService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
/**
|
||||
* ALTCHA service: signed-challenge issuing, solution verification, one-time replay guard.
|
||||
*/
|
||||
class AltchaServiceTest extends TestCase
|
||||
{
|
||||
private const KEY = 'test-hmac-key-please-change';
|
||||
|
||||
private function service(bool $enabled = true, int $maxNumber = 2000, int $expire = 300): AltchaService
|
||||
{
|
||||
return new AltchaService(self::KEY, $enabled, $maxNumber, $expire, new ArrayAdapter());
|
||||
}
|
||||
|
||||
/**
|
||||
* Solve a service-issued challenge the way the browser widget would, and
|
||||
* return the base64 payload the client sends back.
|
||||
*/
|
||||
private function solvedPayload(array $challenge): string
|
||||
{
|
||||
$solver = new Altcha(self::KEY);
|
||||
$solution = $solver->solveChallenge(
|
||||
$challenge['challenge'],
|
||||
$challenge['salt'],
|
||||
Algorithm::SHA256,
|
||||
(int) $challenge['maxnumber'],
|
||||
);
|
||||
self::assertNotNull($solution, 'challenge must be solvable');
|
||||
|
||||
return base64_encode(json_encode([
|
||||
'algorithm' => $challenge['algorithm'],
|
||||
'challenge' => $challenge['challenge'],
|
||||
'number' => $solution->number,
|
||||
'salt' => $challenge['salt'],
|
||||
'signature' => $challenge['signature'],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testCreateChallengeShape(): void
|
||||
{
|
||||
$c = $this->service()->createChallenge();
|
||||
self::assertSame('SHA-256', $c['algorithm']);
|
||||
self::assertArrayHasKey('challenge', $c);
|
||||
self::assertArrayHasKey('salt', $c);
|
||||
self::assertArrayHasKey('signature', $c);
|
||||
self::assertSame(2000, $c['maxnumber']);
|
||||
}
|
||||
|
||||
public function testValidSolutionVerifies(): void
|
||||
{
|
||||
$svc = $this->service();
|
||||
$payload = $this->solvedPayload($svc->createChallenge());
|
||||
self::assertTrue($svc->verifySolution($payload));
|
||||
}
|
||||
|
||||
public function testReplayIsRejected(): void
|
||||
{
|
||||
$svc = $this->service();
|
||||
$payload = $this->solvedPayload($svc->createChallenge());
|
||||
self::assertTrue($svc->verifySolution($payload), 'first use accepted');
|
||||
self::assertFalse($svc->verifySolution($payload), 'second use (replay) rejected');
|
||||
}
|
||||
|
||||
public function testTamperedSignatureRejected(): void
|
||||
{
|
||||
$svc = $this->service();
|
||||
$challenge = $svc->createChallenge();
|
||||
$challenge['signature'] = str_repeat('0', strlen($challenge['signature']));
|
||||
self::assertFalse($svc->verifySolution($this->solvedPayload($challenge)));
|
||||
}
|
||||
|
||||
public function testEmptyAndGarbagePayloadsRejected(): void
|
||||
{
|
||||
$svc = $this->service();
|
||||
self::assertFalse($svc->verifySolution(''));
|
||||
self::assertFalse($svc->verifySolution('not-base64-!@#'));
|
||||
self::assertFalse($svc->verifySolution(base64_encode('{"foo":"bar"}')));
|
||||
}
|
||||
|
||||
public function testEnabledFlag(): void
|
||||
{
|
||||
self::assertTrue($this->service(true)->enabled());
|
||||
self::assertFalse($this->service(false)->enabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared\Captcha;
|
||||
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* ALTCHA HTTP surface: public challenge endpoint, and captcha bypass on public
|
||||
* endpoints while ALTCHA is disabled (the default in the test environment).
|
||||
*/
|
||||
class CaptchaFlowTest extends ApiTestCase
|
||||
{
|
||||
public function testChallengeEndpointIsPublicAndWellFormed(): void
|
||||
{
|
||||
$this->client->request('GET', '/api/v1/altcha/challenge');
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$body = json_decode($this->client->getResponse()->getContent(), true);
|
||||
self::assertSame('SHA-256', $body['algorithm']);
|
||||
foreach (['challenge', 'salt', 'signature', 'maxnumber'] as $key) {
|
||||
self::assertArrayHasKey($key, $body);
|
||||
}
|
||||
}
|
||||
|
||||
public function testPublicEndpointBypassesCaptchaWhenDisabled(): void
|
||||
{
|
||||
// ALTCHA_ENABLED is false in test → guard is a no-op, so send-code proceeds
|
||||
// past the captcha check without an `altcha` field (fails later on validation only).
|
||||
$this->client->request(
|
||||
'POST',
|
||||
'/api/v1/user/send-code',
|
||||
server: ['CONTENT_TYPE' => 'application/json'],
|
||||
content: json_encode(['mobile' => '09123456789']),
|
||||
);
|
||||
|
||||
// Not a 422 captcha rejection: either success or a non-captcha error.
|
||||
$body = json_decode($this->client->getResponse()->getContent(), true) ?? [];
|
||||
$code = $body['errors'][0]['code'] ?? null;
|
||||
self::assertNotSame('ERR_CAPTCHA_001', $code);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user