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,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Captcha;
|
||||
|
||||
use AltchaOrg\Altcha\V1\Altcha;
|
||||
use AltchaOrg\Altcha\V1\ChallengeOptions;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
/**
|
||||
* ALTCHA self-hosted proof-of-work captcha.
|
||||
*
|
||||
* Challenges are HMAC-signed with the server secret and carry an embedded
|
||||
* `expires` timestamp. A solved payload is accepted at most once: its signature
|
||||
* is burned in Redis for the remaining lifetime of the challenge, so a captured
|
||||
* payload cannot be replayed.
|
||||
*/
|
||||
class AltchaService
|
||||
{
|
||||
private readonly Altcha $altcha;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $hmacKey,
|
||||
private readonly bool $enabled,
|
||||
private readonly int $maxNumber,
|
||||
private readonly int $expireSeconds,
|
||||
private readonly CacheItemPoolInterface $altchaPool,
|
||||
) {
|
||||
$this->altcha = new Altcha($this->hmacKey);
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh signed challenge for the widget.
|
||||
*
|
||||
* @return array<string, string|int> keys: algorithm, challenge, maxnumber, salt, signature
|
||||
*/
|
||||
public function createChallenge(): array
|
||||
{
|
||||
$challenge = $this->altcha->createChallenge(new ChallengeOptions(
|
||||
maxNumber: $this->maxNumber,
|
||||
expires: (new \DateTimeImmutable())->add(new \DateInterval('PT' . $this->expireSeconds . 'S')),
|
||||
));
|
||||
|
||||
return [
|
||||
'algorithm' => $challenge->algorithm,
|
||||
'challenge' => $challenge->challenge,
|
||||
'maxnumber' => $challenge->maxNumber,
|
||||
'salt' => $challenge->salt,
|
||||
'signature' => $challenge->signature,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a base64 solution payload sent by the client.
|
||||
* Returns false on any invalid/expired/replayed payload.
|
||||
*/
|
||||
public function verifySolution(string $payloadBase64): bool
|
||||
{
|
||||
if ($payloadBase64 === '' || !$this->altcha->verifySolution($payloadBase64, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->consumeOnce($payloadBase64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically burn the challenge signature so it can be used only once.
|
||||
* Returns false if this signature has already been consumed.
|
||||
*/
|
||||
private function consumeOnce(string $payloadBase64): bool
|
||||
{
|
||||
$decoded = json_decode((string) base64_decode($payloadBase64, true), true);
|
||||
$signature = is_array($decoded) ? ($decoded['signature'] ?? null) : null;
|
||||
if (!is_string($signature) || $signature === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$item = $this->altchaPool->getItem('altcha_used_' . $signature);
|
||||
if ($item->isHit()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$item->set(true)->expiresAfter($this->expireSeconds);
|
||||
$this->altchaPool->save($item);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Captcha;
|
||||
|
||||
use App\Shared\Controller\BaseController;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[OA\Tag(name: 'Captcha')]
|
||||
class CaptchaController extends BaseController
|
||||
{
|
||||
public function __construct(private readonly AltchaService $altcha) {}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/altcha/challenge',
|
||||
summary: 'صدور یک challenge امضاشدهی ALTCHA برای حل proof-of-work سمت مرورگر',
|
||||
responses: [new OA\Response(response: 200, description: 'ALTCHA challenge object')]
|
||||
)]
|
||||
#[Route('/api/v1/altcha/challenge', methods: ['GET'])]
|
||||
public function challenge(): JsonResponse
|
||||
{
|
||||
return new JsonResponse($this->altcha->createChallenge());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Captcha;
|
||||
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Drop-in captcha check for public endpoints.
|
||||
*
|
||||
* Call `assertValid($request)` at the top of any unauthenticated POST handler.
|
||||
* No-op when ALTCHA is disabled (dev/test), so protected handlers stay testable
|
||||
* without solving a proof-of-work.
|
||||
*/
|
||||
class CaptchaGuard
|
||||
{
|
||||
public function __construct(private readonly AltchaService $altcha) {}
|
||||
|
||||
public function assertValid(Request $request): void
|
||||
{
|
||||
if (!$this->altcha->enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$payload = is_array($data) ? (string) ($data['altcha'] ?? '') : '';
|
||||
|
||||
if (!$this->altcha->verifySolution($payload)) {
|
||||
throw new AppException(ErrorCodes::ERR_CAPTCHA_001, null, 422, 'altcha');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user