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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,9 @@ class ErrorCodes
|
||||
// Rate Limit
|
||||
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
|
||||
|
||||
// Captcha (ALTCHA)
|
||||
public const ERR_CAPTCHA_001 = 'ERR_CAPTCHA_001';
|
||||
|
||||
// Rating
|
||||
public const ERR_RATING_NOT_ELIGIBLE = 'ERR_RATING_NOT_ELIGIBLE';
|
||||
|
||||
@@ -126,6 +129,7 @@ class ErrorCodes
|
||||
self::ERR_SECRETARY_001 => 'پلن فعلی اجازه منشی بیشتر را نمیدهد',
|
||||
self::ERR_CONFLICT_001 => 'تداخل: منبع در حال استفاده است یا قبلاً تغییر کرده است',
|
||||
self::ERR_RATE_LIMIT_001 => 'درخواستهای زیاد. لطفاً بعداً تلاش کنید',
|
||||
self::ERR_CAPTCHA_001 => 'تأیید امنیتی ناموفق بود. لطفاً صفحه را رفرش کنید و دوباره تلاش کنید',
|
||||
self::ERR_STAFF_NOT_FOUND => 'پرسنل یافت نشد',
|
||||
self::ERR_SUBSCRIPTION_REQUIRED => 'این قابلیت نیاز به پنل Basic یا بالاتر دارد',
|
||||
self::ERR_TRIAL_ALREADY_USED => 'قبلاً از تریال استفاده کردهاید',
|
||||
|
||||
Reference in New Issue
Block a user