# یکپارچه‌سازی 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`: ```php 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 ```bash 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:` را در 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 صدا زده شود: ```php 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`: ```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 موجود، قبل از منطق): ```php $this->captcha->assertValid($request); ``` `CaptchaGuard` را به constructor آن controllerها inject کن. **rate-limitهای موجود را حذف نکن** — کپچا مکمل آن‌هاست نه جایگزین. ### ۶. تنظیمات `.env` (مقادیر پیش‌فرض؛ کلید واقعی در `.env.local`): ```dotenv ###> 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`: ```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` که `` را با `challengeurl="/api/v1/altcha/challenge"` رندر می‌کند و مقدار حل‌شده را از event `verified` می‌گیرد. - در فرم‌های عمومی ادمین (صفحه‌ی 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`.