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:
hamed
2026-07-10 10:31:59 +03:30
parent 11efed4100
commit 10b0743d9a
43 changed files with 5586 additions and 1554 deletions
+10
View File
@@ -11,6 +11,7 @@ use App\Auth\Service\TokenService;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Captcha\CaptchaGuard;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\EntityManagerInterface;
@@ -40,6 +41,7 @@ class AuthController extends BaseController
private readonly UserActiveContextRepository $contextRepo,
private readonly UserPasswordHasherInterface $hasher,
private readonly EntityManagerInterface $em,
private readonly CaptchaGuard $captcha,
) {}
/**
@@ -140,6 +142,8 @@ class AuthController extends BaseController
return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429);
}
$this->captcha->assertValid($request);
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim($data['mobile'] ?? '');
$domain = isset($data['domain']) ? substr(trim((string) $data['domain']), 0, 253) : null;
@@ -275,6 +279,8 @@ class AuthController extends BaseController
#[Route('/api/v1/user/register', methods: ['POST'])]
public function register(Request $request): JsonResponse
{
$this->captcha->assertValid($request);
$data = json_decode($request->getContent(), true) ?? [];
$grant = trim($data['grant'] ?? '');
$realName = trim($data['real_name'] ?? '');
@@ -375,6 +381,8 @@ class AuthController extends BaseController
return $resp;
}
$this->captcha->assertValid($request);
$data = json_decode($request->getContent(), true) ?? [];
$grant = trim($data['grant'] ?? '');
@@ -399,6 +407,8 @@ class AuthController extends BaseController
return $resp;
}
$this->captcha->assertValid($request);
$data = json_decode($request->getContent(), true) ?? [];
$grant = trim($data['grant'] ?? '');
$newPassword = trim($data['new_password'] ?? '');
@@ -10,6 +10,7 @@ use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Captcha\CaptchaGuard;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Service\SmsService;
@@ -35,11 +36,14 @@ class PreRegistrationController extends BaseController
private readonly SmsService $sms,
private readonly LoggerInterface $logger,
private readonly string $appUrl,
private readonly CaptchaGuard $captcha,
) {}
#[Route('/api/v1/pre-registration', methods: ['POST'])]
public function submit(Request $request): JsonResponse
{
$this->captcha->assertValid($request);
$data = json_decode($request->getContent(), true) ?? [];
$type = trim($data['type'] ?? '');
$name = trim($data['name'] ?? '');
+92
View File
@@ -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;
}
}
+25
View File
@@ -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());
}
}
+33
View File
@@ -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');
}
}
}
+4
View File
@@ -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 => 'قبلاً از تریال استفاده کرده‌اید',