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
@@ -0,0 +1,179 @@
# یکپارچه‌سازی 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:<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 صدا زده شود:
```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` که `<altcha-widget>` را با `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`.