- 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.
13 KiB
یکپارچهسازی ALTCHA (کپچای Self-Hosted، Proof-of-Work) روی endpointهای عمومی
پروژه
clinicpro (backend + پنل ادمین React).
Cross-repo: سایت عمومی
nobat724_frontهم همان endpointهای عمومی را مصرف میکند (send-code / register / rate / comment / pre-registration). این پرامپت قرارداد API کپچا را در backend میسازد؛ سپس یک پرامپت جدا درnobat724_frontبرای نصب web-component و ضمیمهکردنaltchaبه بدنهی همان درخواستها لازم است. مسیر challenge و شکل payload که در این فایل تعریف میشود، همان است که سایت عمومی باید استفاده کند.
زمینه
کاربر خواسته «به همهی فرمهای Symfony با ALTCHA کپچا اضافه شود» و صریحاً هر SaaS خارجی (Google/Cloudflare/hCaptcha/Friendly) را رد کرده — چون کاربران داخل ایراناند. انتخاب ALTCHA درست است: خودمیزبان، بدون تصویر، بدون تایپ، بدون CDN، Proof-of-Work سمت مرورگر.
اما یک تصحیح معماری مهم: این پروژه Symfony Forms / Twig برای فرمهای کاربری ندارد. clinicpro یک REST API با envelopeی JSON است و کلاینتها React SPA (پنل ادمین) و Next.js (nobat724_front) هستند. پس «Form Type / Form Extension / Form Theme / Twig block» موضوعیت ندارد. معادل درست در این معماری:
- یک endpoint برای صدور challenge امضاشده،
- یک verifier سرویس که payload حلشدهی ALTCHA را سمت سرور اعتبارسنجی میکند،
- اعمال verifier روی endpointهای POST عمومی که واقعاً قربانی اسپم/باتاند.
هیچکدام از فرمهای ادمین (پشت JWT + ROLE_*) نیازی به کپچا ندارند؛ کپچا فقط روی سطح عمومی و بدون احراز هویت معنی دارد.
مشکل / هدف
افزودن ALTCHA بهصورت یک ماژول مرکزی و قابلاستفادهمجدد، که با یک خط ($this->altcha->assertValid($request)) به هر endpoint عمومی اضافه شود؛ در dev/test غیرفعال و در prod فعال؛ با محافظت در برابر replay (استفادهی یکبارهی هر challenge).
endpointهای عمومی که باید محافظت شوند
از config/packages/security.yaml → firewall public_endpoints (خط ۳۵). فقط POSTهای ارسالکنندهی داده/هزینهزا:
| Endpoint | Controller | چرا کپچا |
|---|---|---|
POST /api/v1/user/send-code |
src/Auth/Controller/AuthController.php::sendCode (خط ۱۳۶) |
بحرانی — هر تماس یک پیامک واقعی + هزینه میفرستد |
POST /api/v1/user/register |
AuthController::register (خط ۲۷۶) |
ساخت اکانت انبوه |
POST /api/v1/user/otp-login |
AuthController::otpLogin (خط ۳۷۲) |
تلاش خودکار ورود |
POST /api/v1/user/reset-password |
AuthController::resetPassword (خط ۳۹۶) |
سوءاستفاده از بازیابی |
POST /api/v1/pre-registration |
src/Auth/Controller/PreRegistrationController.php (خط ۴۰) |
اسپم درخواست ثبتنام |
POST /api/v1/rate |
src/Rating/Controller/RatingController.php (خط ۶۷) |
امتیاز جعلی انبوه |
POST /api/v1/comment |
RatingController.php (خط ۲۱۴) |
اسپم نظر |
verify-codeخودش با rate-limit و uuid محافظت است و کپچا اضافه لازم ندارد (تجربهی کاربر را خراب میکند). اگر لازم شد، فقطsend-codeرا کپچا بزن — بقیه اختیاری با فلگ.
وضعیت فعلی
الگوی هر endpoint عمومی امروز: rate-limit با RateLimiterFactory (Redis)، سپس json_decode بدنه، سپس $this->error(...). نمونهی واقعی از AuthController::sendCode:
public function sendCode(Request $request): JsonResponse
{
$limiter = $this->sendCodeLimiter->create($request->getClientIp() ?? 'unknown');
if (!$limiter->consume(1)->isAccepted()) {
return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429);
}
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim($data['mobile'] ?? '');
// ... اعتبارسنجی موبایل ...
$uuid = $this->otpService->sendCode($mobile, $domain ?: null);
return new JsonResponse(['uuid' => $uuid, 'message' => 'کد تایید با موفقیت ارسال شد.']);
}
زیرساخت موجود که باید استفاده شود:
- Redis آماده است:
config/packages/cache.yaml→app: cache.adapter.redis،REDIS_URL=redis://redis:6379. برای store یکبارهی challengeها از یک cache pool اختصاصی استفاده کن (نه ساخت اتصال دستی). - همهی controllerها
extends BaseControllerو از$this->error($code, $message, $status, $field?)استفاده میکنند. - کدهای خطا در
src/Shared/Constant/ErrorCodes.php(پیام فارسی). - Webpack Encore با entryهای
adminوhome(webpack.config.jsخطوط ۱۱–۱۳). AssetMapper در کار نیست — داراییها با Encore بسته میشوند.
وظایف
۱. نصب کتابخانهی رسمی PHP
ddev exec composer require altcha-org/altcha
اگر نصب پکیج به هر دلیل ممکن نبود، معادل حداقلی طبق مستندات رسمی ALTCHA پیاده کن (HMAC-SHA256 روی salt+number، خروجی base64). ولی اول پکیج رسمی را امتحان کن.
۲. سرویس مرکزی — src/Shared/Captcha/AltchaService.php (namespace جدید App\Shared\Captcha)
مسئولیتها:
createChallenge(): array— تولید challenge امضاشده با HMAC-SHA256 و%env(ALTCHA_HMAC_KEY)%،expires، وmaxNumber = %env(int:ALTCHA_MAX_NUMBER)%.verifySolution(string $payloadBase64): bool— decode payload، بررسی امضا (algorithm,challenge,salt,signature)، بررسی انقضا از داخلsalt(پارامتر?expires=)، سپس one-time: کلیدaltcha:used:<challenge>را در cache pool اتمیکget/saveکن؛ اگر قبلاً بوده →false(ضدِ replay). TTL = زمان باقیمانده تا انقضا.enabled(): bool— از%env(bool:ALTCHA_ENABLED)%.
تزریق: CacheItemPoolInterface $altchaPool, پارامترهای env. از random_bytes برای salt، hash_hmac('sha256', ...) برای امضا.
۳. Guard قابلاستفادهمجدد — src/Shared/Captcha/CaptchaGuard.php
یک متد کوتاه که در هر controller صدا زده شود:
public function assertValid(Request $request): void
{
if (!$this->altcha->enabled()) {
return; // dev/test یا ALTCHA_ENABLED=false
}
$payload = (string) (json_decode($request->getContent(), true)['altcha'] ?? '');
if ($payload === '' || !$this->altcha->verifySolution($payload)) {
throw new AppException(ErrorCodes::ERR_CAPTCHA_001, null, 422);
}
}
AppException توسط ExceptionSubscriber به $this->error() تبدیل میشود — پس نیازی به try/catch در controller نیست. یک کد خطای جدید ERR_CAPTCHA_001 با پیام فارسی («تأیید امنیتی ناموفق بود، صفحه را تازه کنید») به ErrorCodes.php اضافه کن.
۴. Endpoint صدور challenge — عمومی
در یک controller جدید src/Shared/Captcha/CaptchaController.php:
#[Route('/api/v1/altcha/challenge', methods: ['GET'])]
public function challenge(): JsonResponse
{
return new JsonResponse($this->altcha->createChallenge());
}
این مسیر را به public_endpoints در config/packages/security.yaml (خط ۳۵ pattern) اضافه کن: api/v1/altcha/challenge.
۵. اعمال Guard روی endpointها
در ابتدای هر یک از ۷ متد جدول بالا (بعد از rate-limit موجود، قبل از منطق):
$this->captcha->assertValid($request);
CaptchaGuard را به constructor آن controllerها inject کن. rate-limitهای موجود را حذف نکن — کپچا مکمل آنهاست نه جایگزین.
۶. تنظیمات
.env (مقادیر پیشفرض؛ کلید واقعی در .env.local):
###> altcha ###
ALTCHA_ENABLED=false
ALTCHA_HMAC_KEY=change-me-in-env-local
ALTCHA_MAX_NUMBER=100000
ALTCHA_EXPIRE_SECONDS=300
###< altcha ###
config/services.yaml: bind پارامترها به AltchaService. یک cache pool اختصاصی در config/packages/cache.yaml:
framework:
cache:
pools:
altcha.pool:
adapter: cache.adapter.redis
default_lifetime: 600
غیرفعالسازی محیطی: در config/services_dev.yaml و config/packages/test/ مقدار ALTCHA_ENABLED=false تضمین شود؛ در prod مقدار از .env.local سرور true. (بهجای اتکا به env، enabled() مستقیماً ALTCHA_ENABLED را میخواند تا رفتار صریح باشد.)
۷. Frontend پنل ادمین React (assets/admin/)
- نصب web-component محلی:
ddev exec yarn add altcha(بدون CDN؛ Encore آن را bundle میکند). - یک کامپوننت
assets/admin/components/ui/Altcha.tsxکه<altcha-widget>را باchallengeurl="/api/v1/altcha/challenge"رندر میکند و مقدار حلشده را از eventverifiedمیگیرد. - در فرمهای عمومی ادمین (صفحهی login عمومی اگر روی همین endpointها میرود) مقدار
altchaرا به بدنهی درخواست درlib/api.tsضمیمه کن. اگر پنل ادمین از این endpointهای عمومی استفاده نمیکند، این بخش را حداقلی نگهدار و در README مسیر افزودن را مستند کن.
بیشتر مصرفکنندهی این endpointها
nobat724_frontاست؛ سیمکشی کامل widget آنجا در پرامپت همتای frontend انجام میشود.
۸. تست
- Unit
tests/Shared/Captcha/AltchaServiceTest.php: امضای معتبر تأیید شود؛ امضای دستکاریشده رد؛ challenge منقضی رد؛ استفادهی دوم از همان challenge رد (replay). - Functional
tests/Shared/Captcha/CaptchaFlowTest.php:GET /api/v1/altcha/challengeساختار (algorithm/challenge/salt/signature/maxnumber) برگرداند؛ باALTCHA_ENABLED=false(پیشفرض test) endpointهای عمومی بدونaltchaهم ۲۰۰ بدهند؛ سپس با فعالسازی موقت سرویس (mock/override) نبودِaltcha→ ۴۲۲ باERR_CAPTCHA_001. - از
ApiTestCaseارث ببر؛ الگوی موجودtests/Admin/AdminLogsTest.phpرا دنبال کن.
۹. مستندات
docs/api/captcha.mdجدید: مسیر challenge، شکل payload، کد خطایERR_CAPTCHA_001، لیست endpointهای محافظتشده.- در
docs/api/auth.md،docs/api/rating.md، و مستند pre-registration: به هر endpoint یک نکته اضافه کن که درprodهدر/فیلدaltchaالزامی است. READMEبخش ALTCHA: نصب، env، فعال/غیرفعال، افزودن به endpoint جدید ($this->captcha->assertValid($request))، تغییر difficulty (ALTCHA_MAX_NUMBER)، Troubleshooting (کلید HMAC ناهماهنگ بین challenge و verify، ساعت سرور/انقضا، Redis در دسترس نبودن).
نکات مهم
- این پروژه Twig/FormType برای فرمهای کاربری ندارد — پیادهسازی API-محور است (challenge endpoint + verifier)، نه Form Theme. اگر جایی صفحهی Twig عمومی با فرم واقعی بود (
templates/public/)، همانجا web-component را مستقیم بگذار؛ ولی فرض پیشفرض API است. - کپچا فقط روی
public_endpoints؛ هرگز روی endpointهای پشت JWT/ROLE_*(تجربهی ادمین را خراب و بیفایده است). - One-time بودن challenge الزامی است؛ بدون آن replay ممکن میشود. حتماً از Redis pool اتمیک استفاده کن.
- Secret/HMAC key هرگز به client نرود؛ فقط challenge امضاشده و salt عمومیاند.
- rate-limitهای موجود دستنخورده بمانند؛ کپچا لایهی مکمل است.
- envelope پاسخ خطا باید همان
$this->error()استاندارد BaseController بماند ({ success:false, errors:[{code,message}] }). - بعد از تغییر controllerها و افزودن endpoint، طبق قانون ثابت پروژه فایلهای
docs/api/*را در همین session بهروز کن. - بعد از تغییر کد:
ddev exec php bin/console cache:clear(route جدید) وddev exec php bin/phpunitوddev exec yarn dev.