feat: implement short-lived grant system for OTP verification and enhance rate limiting across authentication endpoints
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
# سختسازی احراز هویت و توکن (C-1, H-1, H-4, M-1, M-3, headers)
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend). این پرامپت **cross-repo** است — قرارداد توکن را سایت عمومی مصرف میکند؛ پرامپت همتا: `nobat724_front/.claude/prompt/fix-token-storage-xss-headers.md` (بعد از این اجرا شود).
|
||||
|
||||
مرجع: گزارش امنیتی این session (OWASP Top 10). یافتههای backend: **C-1 (Critical)**، **H-1 (High)**، **H-4 (High)**، **M-1 (Medium)**، **M-3 (Medium)**، security headers.
|
||||
|
||||
## زمینه
|
||||
|
||||
ممیزی امنیتی نشان داد لایهی احراز هویت backend چند ضعف جدی دارد:
|
||||
|
||||
- **C-1:** تأیید OTP به درخواستکننده bind نمیشود. `verifyCode` فقط فلگ `verified: true` را در cache ست میکند و سپس `oauth/token` / `otp-login` / `reset-password` با همان `uuid` (که در پاسخ `send-code` برگردانده میشود) توکن/ریست میدهند. خودِ `code` در زمان صدور توکن دوباره چک نمیشود. هیچ rate-limit روی `verify-code`، `oauth/token`، `otp-login`، `reset-password` نیست (فقط `attempts >= 5` per-uuid که با گرفتن uuid جدید دور میخورد).
|
||||
- **H-1:** `token_ttl: 604800` (۷ روز) در `lexik_jwt_authentication.yaml`، در حالیکه پاسخها `expires_in: 3600` ادعا میکنند. عملاً JWT هفت روز معتبر است و چون stateless است قابل ابطال نیست.
|
||||
- **H-4:** `reset-password` با هر `uuid` تأییدشده و یک `new_password` (حداقل ۶ کاراکتر) رمز را عوض میکند؛ بدون rate-limit و با ضعف bind بالا.
|
||||
- **M-1:** نام فایل آپلودی randomize نمیشود (نام اصلی حفظ میشود → مسیر قابلپیشبینی/overwrite).
|
||||
- **M-3:** سقف سراسری برای `limit` صفحهبندی نامشخص است.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
جریان OTP را به یک **grant یکبارمصرفِ کوتاهعمر** تبدیل کن (بهجای فلگ چسبندهی `verified`)، rate-limit per-mobile اضافه کن، عمر access token را کوتاه کن، ریست پسورد را سخت کن، نام فایل را randomize کن، سقف pagination بگذار، و security headers را در سطح Symfony اضافه کن.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Auth/Service/OtpService.php` | منطق OTP — افزودن grant یکبارمصرف |
|
||||
| `src/Auth/Controller/AuthController.php` | `verifyCode`, `issueToken`, `otpLogin`, `resetPassword` |
|
||||
| `config/packages/lexik_jwt_authentication.yaml` | `token_ttl` |
|
||||
| `config/packages/rate_limiter.yaml` | limiterهای جدید |
|
||||
| `config/services.yaml` | تزریق limiterهای جدید به controller |
|
||||
| `src/Shared/Service/FileValidatorService.php` | randomize نام فایل |
|
||||
| `src/Shared/Controller/BaseController.php` | سقف `paginated` |
|
||||
| `src/Shared/EventSubscriber/` (جدید) | افزودن security headers روی پاسخها |
|
||||
| `docs/api/auth.md` | مستندسازی قرارداد جدید توکن |
|
||||
|
||||
## وضعیت فعلی (کد واقعی)
|
||||
|
||||
`OtpService::verifyCode` فقط فلگ میزند:
|
||||
```php
|
||||
$data['verified'] = true;
|
||||
$item->set(json_encode($data));
|
||||
$item->expiresAfter($this->otpTtl);
|
||||
$this->cache->save($item);
|
||||
return $data;
|
||||
```
|
||||
|
||||
`OtpService::getVerifiedOtpData` صرفاً فلگ را میخواند:
|
||||
```php
|
||||
if (!($data['verified'] ?? false)) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
|
||||
}
|
||||
return $data;
|
||||
```
|
||||
|
||||
`AuthController::issueToken` / `otpLogin` / `resetPassword` همگی `getVerifiedOtpData($uuid)` را مصرف میکنند و سپس `deleteOtp($uuid)`؛ هیچ rate-limit ندارند.
|
||||
|
||||
`lexik_jwt_authentication.yaml`:
|
||||
```yaml
|
||||
token_ttl: 604800
|
||||
```
|
||||
|
||||
`rate_limiter.yaml` فقط دو limiter دارد (`send_code`, `login`).
|
||||
|
||||
`FileValidatorService::sanitizeFilename` نام اصلی را نگه میدارد:
|
||||
```php
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', basename($filename));
|
||||
// ... ext check ...
|
||||
return $safeName; // ← نام اصلی، randomize نمیشود
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. Grant یکبارمصرف در OtpService (C-1)
|
||||
|
||||
به `OtpService` دو متد اضافه کن: `issueGrant(string $mobile): string` و `consumeGrant(string $grant): string` (موبایل را برمیگرداند و grant را **همان لحظه delete میکند** → یکبارمصرف).
|
||||
|
||||
```php
|
||||
private function grantKey(string $grant): string
|
||||
{
|
||||
return 'otp_grant_' . $grant;
|
||||
}
|
||||
|
||||
public function issueGrant(string $mobile): string
|
||||
{
|
||||
$grant = bin2hex(random_bytes(32));
|
||||
$item = $this->cache->getItem($this->grantKey($grant));
|
||||
$item->set($mobile);
|
||||
$item->expiresAfter(120); // عمر کوتاه: ۲ دقیقه
|
||||
$this->cache->save($item);
|
||||
return $grant;
|
||||
}
|
||||
|
||||
public function consumeGrant(string $grant): string
|
||||
{
|
||||
$item = $this->cache->getItem($this->grantKey($grant));
|
||||
if (!$item->isHit()) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
|
||||
}
|
||||
$mobile = $item->get();
|
||||
$this->cache->delete($this->grantKey($grant)); // یکبارمصرف
|
||||
return $mobile;
|
||||
}
|
||||
```
|
||||
|
||||
`verifyCode` در پایان (بعد از `hash_equals` موفق) بهجای فلگ چسبنده، grant بسازد و **uuidِ OTP را پاک کند** و grant را در آرایهی بازگشتی بگذارد:
|
||||
```php
|
||||
// بهجای ستکردن verified=true:
|
||||
$this->cache->delete($this->key($uuid)); // OTP مصرف شد
|
||||
$data['grant'] = $this->issueGrant($data['mobile']);
|
||||
return $data;
|
||||
```
|
||||
> `getVerifiedOtpData` و فلگ `verified` دیگر لازم نیستند؛ حذفشان کن (و همهی فراخوانها به جریان grant مهاجرت کنند).
|
||||
|
||||
### ۲. مصرف grant در نقاط صدور توکن/ریست (C-1, H-4)
|
||||
|
||||
در `AuthController`:
|
||||
- `verifyCode`: در پاسخ، بهجای صرفِ `is_new_user`، `grant` را هم برگردان:
|
||||
```php
|
||||
$mobile = $otpData['mobile'];
|
||||
$isNewUser = $this->userRepo->findByMobile($mobile) === null;
|
||||
return $this->success(['grant' => $otpData['grant'], 'is_new_user' => $isNewUser]);
|
||||
```
|
||||
- `issueToken` (`/oauth/token`): بهجای `uuid`، فیلد `grant` بگیر و `consumeGrant` کن:
|
||||
```php
|
||||
$grant = trim($data['grant'] ?? '');
|
||||
if ($grant === '') return $this->error(ErrorCodes::ERR_VALIDATION_002, 'grant الزامی است', 422);
|
||||
$mobile = $this->otpService->consumeGrant($grant);
|
||||
$user = $this->userRepo->findByMobile($mobile) ?? new User($mobile);
|
||||
$this->userRepo->save($user);
|
||||
return new JsonResponse($this->tokenService->issueTokens($user));
|
||||
```
|
||||
- `otpLogin` و `resetPassword` و `register`: همگی از `consumeGrant($grant)` بهجای `getVerifiedOtpData($uuid)` + `deleteOtp` استفاده کنند (دیگر `uuid`/`deleteOtp` لازم نیست؛ grant خودش یکبارمصرف است).
|
||||
- `resetPassword`: حداقل طول را به **۸** ببر؛ بعد از ستکردن پسورد جدید، **همهی refresh tokenهای کاربر را ابطال کن** (اگر مکانیزم per-user وجود ندارد، حداقل یک TODO صریح بگذار و در `docs` ذکر کن).
|
||||
|
||||
> سازگاری سایت عمومی: سایت الان `oauth/token` را با `uuid` صدا میزند؛ این تغییر قرارداد را در `docs/api/auth.md` ثبت کن و در پرامپت همتای `nobat724_front` مصرفکننده اصلاح میشود.
|
||||
|
||||
### ۳. Rate-limit روی نقاط حساس (C-1, H-4)
|
||||
|
||||
در `rate_limiter.yaml` اضافه کن (per-mobile، نه فقط per-IP):
|
||||
```yaml
|
||||
verify_code:
|
||||
policy: 'sliding_window'
|
||||
limit: 10
|
||||
interval: '15 minutes'
|
||||
token_issue:
|
||||
policy: 'sliding_window'
|
||||
limit: 10
|
||||
interval: '5 minutes'
|
||||
password_reset:
|
||||
policy: 'sliding_window'
|
||||
limit: 5
|
||||
interval: '60 minutes'
|
||||
```
|
||||
در `services.yaml` این limiterها را به `AuthController` تزریق کن (مثل `sendCodeLimiter` موجود) و کلید را **موبایل** بگیر (در `verify-code`/`reset` موبایل از grant/otp در دسترس است؛ برای `send-code` همچنان IP). در ابتدای `verifyCode`، `issueToken`، `otpLogin`، `resetPassword` مصرف کن:
|
||||
```php
|
||||
$limiter = $this->verifyCodeLimiter->create($mobileOrIpKey);
|
||||
if (!$limiter->consume(1)->isAccepted()) {
|
||||
return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429);
|
||||
}
|
||||
```
|
||||
|
||||
### ۴. کوتاهکردن عمر JWT (H-1)
|
||||
|
||||
`lexik_jwt_authentication.yaml`:
|
||||
```yaml
|
||||
token_ttl: 900 # ۱۵ دقیقه
|
||||
clock_skew: 5
|
||||
```
|
||||
و `expires_in` در پاسخها (`TokenService::issueTokens` و `PasswordAuthenticator::onAuthenticationSuccess`) را به `900` اصلاح کن تا با واقعیت بخواند.
|
||||
|
||||
### ۵. Randomize نام فایل آپلودی (M-1)
|
||||
|
||||
`FileValidatorService::sanitizeFilename` بعد از اعتبارسنجی extension، نام را random کند:
|
||||
```php
|
||||
$ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
|
||||
}
|
||||
return bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
```
|
||||
> اگر جایی به نام اصلی فایل وابسته است، بررسی کن نشکند (نام نمایش را جدا ذخیره کن اگر لازم بود).
|
||||
|
||||
### ۶. سقف سراسری pagination (M-3)
|
||||
|
||||
در `BaseController::paginated` یا هرجا `limit` از query خوانده میشود، سقف بگذار:
|
||||
```php
|
||||
$limit = min(max((int) $limit, 1), 100);
|
||||
```
|
||||
الگوی موجود را پیدا کن (احتمالاً در هر controller جداست) و یک helper مشترک در `BaseController` بساز که همه استفاده کنند.
|
||||
|
||||
### ۷. Security headers در سطح Symfony
|
||||
|
||||
یک `ResponseSubscriber` در `src/Shared/EventSubscriber/SecurityHeadersSubscriber.php` بساز که روی `KernelEvents::RESPONSE` این هدرها را ست کند (اگر قبلاً نبودند):
|
||||
```php
|
||||
$h = $event->getResponse()->headers;
|
||||
$h->set('X-Content-Type-Options', 'nosniff');
|
||||
$h->set('X-Frame-Options', 'DENY');
|
||||
$h->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$h->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
// HSTS فقط روی HTTPS:
|
||||
if ($event->getRequest()->isSecure()) {
|
||||
$h->set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
||||
}
|
||||
```
|
||||
> روی پاسخهای API لازم نیست CSP بگذاری (CSP مال HTML است و در `nobat724_front` اعمال میشود)، ولی این هدرهای پایه را بگذار.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- همهی پاسخها از `BaseController` (`success`/`error`); کدهای خطا از `ErrorCodes`.
|
||||
- جریان grant باید **کاملاً جایگزین** فلگ `verified` شود؛ کد مرده (`getVerifiedOtpData`, `verified`) را حذف کن، نه اینکه موازی نگهداری.
|
||||
- grant یکبارمصرف است: `consumeGrant` همیشه delete میکند حتی اگر ادامهی منطق خطا بدهد (در یک نقطه مصرف شود).
|
||||
- بعد از تغییر config (`lexik`, `rate_limiter`, `services`)، حتماً `cache:clear --env=prod`.
|
||||
- migration لازم نیست (فقط منطق/کانفیگ).
|
||||
- بعد از تغییر هر endpoint، `docs/api/auth.md` را بهروز کن: قرارداد جدید `verify-code` → `grant`، `oauth/token` با `grant` بهجای `uuid`، کدهای 429 جدید، `expires_in: 900`.
|
||||
- تست E2E:
|
||||
- جریان کامل: `send-code` → `verify-code` (grant بگیر) → `oauth/token` با grant → 200 + توکن. همان grant بار دوم → 400/401 (یکبارمصرف).
|
||||
- `oauth/token` با grant نامعتبر/منقضی → خطا.
|
||||
- `verify-code`/`reset` با فراخوانی زیاد → 429.
|
||||
- JWT تازه: decode کن و TTL ≈ 900 ثانیه باشد.
|
||||
- آپلود فایل معتبر → نام ذخیرهشده random و با پسوند درست.
|
||||
- رگرسیون: لاگین staff با پسورد، و دسترسی به یک endpoint محافظتشده با توکن جدید همچنان کار کند.
|
||||
- بعد از تست، هر کاربر/دادهی تستی ساختهشده را پاک کن.
|
||||
@@ -2,4 +2,5 @@ lexik_jwt_authentication:
|
||||
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
|
||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||
token_ttl: 604800
|
||||
token_ttl: 900
|
||||
clock_skew: 5
|
||||
|
||||
@@ -11,3 +11,21 @@ framework:
|
||||
policy: 'fixed_window'
|
||||
limit: 10
|
||||
interval: '1 minute'
|
||||
|
||||
# OTP verify: max 10 attempts per 15 minutes per IP
|
||||
verify_code:
|
||||
policy: 'sliding_window'
|
||||
limit: 10
|
||||
interval: '15 minutes'
|
||||
|
||||
# Token issuance (oauth/token, otp-login): max 10 per 5 minutes per IP
|
||||
token_issue:
|
||||
policy: 'sliding_window'
|
||||
limit: 10
|
||||
interval: '5 minutes'
|
||||
|
||||
# Password reset: max 5 per hour per IP
|
||||
password_reset:
|
||||
policy: 'sliding_window'
|
||||
limit: 5
|
||||
interval: '60 minutes'
|
||||
|
||||
@@ -48,6 +48,9 @@ services:
|
||||
App\Auth\Controller\AuthController:
|
||||
arguments:
|
||||
$sendCodeLimiter: '@limiter.send_code'
|
||||
$verifyCodeLimiter: '@limiter.verify_code'
|
||||
$tokenIssueLimiter: '@limiter.token_issue'
|
||||
$passwordResetLimiter: '@limiter.password_reset'
|
||||
|
||||
App\Payment\Gateway\MellatGateway:
|
||||
arguments:
|
||||
|
||||
+47
-35
@@ -65,19 +65,22 @@ Verify OTP code. Returns whether this is a new or existing user.
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"message": "کد تأیید شد",
|
||||
"is_new_user": false,
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000"
|
||||
"message": "کد با موفقیت تایید شد.",
|
||||
"grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
|
||||
"is_new_user": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `grant` یک توکنِ **یکبارمصرف و کوتاهعمر (۱۲۰ ثانیه)** است که در مرحلهی بعد (`register` / `oauth/token` / `otp-login` / `reset-password`) مصرف میشود. خودِ OTP (`uuid`) پس از تأیید موفق حذف میشود. هر grant فقط یکبار قابلاستفاده است؛ هر action که grant میخواهد یک grant جداگانه لازم دارد (یعنی برای new-user که هم `register` و هم login لازم است، باید دوبار verify انجام شود یا مستقیماً `oauth/token` صدا زده شود که خودش کاربر را میسازد).
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_002` | 401 | Invalid OTP code |
|
||||
| `ERR_AUTH_003` | 401 | OTP expired |
|
||||
| `ERR_AUTH_002` | 400 | Invalid OTP code |
|
||||
| `ERR_AUTH_003` | 400 | OTP expired |
|
||||
| `ERR_VALIDATION_002` | 422 | Missing required field |
|
||||
| `ERR_RATE_LIMIT_001` | 429 | بیش از ۱۰ تلاش در ۱۵ دقیقه (per-IP) |
|
||||
|
||||
---
|
||||
|
||||
@@ -90,14 +93,14 @@ Complete registration for new users (called only when `is_new_user: true`).
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
|
||||
"real_name": "علی احمدی"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `uuid` | string | ✅ | Verified UUID from `verify-code` |
|
||||
| `grant` | string | ✅ | grant یکبارمصرف از `verify-code` |
|
||||
| `real_name` | string | ❌ | User's full name |
|
||||
|
||||
### Response `201`
|
||||
@@ -114,8 +117,8 @@ Complete registration for new users (called only when `is_new_user: true`).
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_VALIDATION_002` | 422 | Missing uuid |
|
||||
| `ERR_CONFLICT_001` | 409 | User already registered |
|
||||
| `ERR_VALIDATION_002` | 422 | Missing grant |
|
||||
| `ERR_AUTH_002` | 400 | grant نامعتبر یا منقضی/مصرفشده |
|
||||
|
||||
---
|
||||
|
||||
@@ -141,26 +144,28 @@ Login with mobile number and password (for users who set a password).
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
|
||||
"refresh_token": "def50200..."
|
||||
}
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
|
||||
"refresh_token": "def50200...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900,
|
||||
"refresh_token_expires_in": 2592000
|
||||
}
|
||||
```
|
||||
|
||||
> فقط برای کاربران staff (`ROLE_ADMIN/DOCTOR/CLINIC/SECRETARY`). Access token TTL: **۹۰۰ ثانیه (۱۵ دقیقه)**.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_005` | 401 | Wrong credentials |
|
||||
| `ERR_AUTH_006` | 403 | Account suspended |
|
||||
| `ERR_AUTH_004` | 429 | Too many attempts |
|
||||
| `ERR_AUTH_006` | 403 | کاربر staff نیست |
|
||||
| `429` | 429 | Too many attempts (۱۰ در دقیقه per-IP) |
|
||||
|
||||
---
|
||||
|
||||
## POST `/oauth/token`
|
||||
|
||||
Exchange verified UUID for JWT access token.
|
||||
Exchange a one-time `grant` (from `verify-code`) for a JWT access token. اگر کاربری با آن موبایل وجود نداشته باشد، ساخته میشود.
|
||||
|
||||
**Permission:** `PUBLIC`
|
||||
|
||||
@@ -168,30 +173,36 @@ Exchange verified UUID for JWT access token.
|
||||
```json
|
||||
{
|
||||
"grant_type": "mobile",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000"
|
||||
"grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `grant_type` | string | ✅ | Must be `"mobile"` |
|
||||
| `uuid` | string | ✅ | UUID from verified OTP flow |
|
||||
| `grant` | string | ✅ | grant یکبارمصرف از `verify-code` (عمر ۱۲۰ ثانیه) |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
|
||||
"refresh_token": "def50200..."
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
|
||||
"refresh_token": "def50200...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900,
|
||||
"refresh_token_expires_in": 2592000
|
||||
}
|
||||
```
|
||||
|
||||
> JWT payload: `{ username: mobile_number, roles: [...], iat, exp }`
|
||||
> Access token TTL: **1 hour** | Refresh token TTL: **30 days** (stored in Redis)
|
||||
> Access token TTL: **۹۰۰ ثانیه (۱۵ دقیقه)** | Refresh token TTL: **۳۰ روز** (در cache، هششده و rotate-on-use)
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_002` | 400 | Invalid or expired UUID |
|
||||
| `ERR_VALIDATION_001` | 400 | `grant_type` نامعتبر |
|
||||
| `ERR_VALIDATION_002` | 422 | `grant` ارسال نشده |
|
||||
| `ERR_AUTH_002` | 400 | grant نامعتبر، منقضی یا قبلاً مصرفشده |
|
||||
| `ERR_RATE_LIMIT_001` | 429 | بیش از ۱۰ درخواست در ۵ دقیقه (per-IP) |
|
||||
|
||||
---
|
||||
|
||||
@@ -578,12 +589,12 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "uuid": "550e8400-e29b-41d4-a716-446655440000" }
|
||||
{ "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039" }
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `uuid` | string | ✅ | UUID از `verify-code` (باید قبلاً verify شده باشد) |
|
||||
| `grant` | string | ✅ | grant یکبارمصرف از `verify-code` |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
@@ -591,7 +602,7 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"expires_in": 900,
|
||||
"refresh_token_expires_in": 2592000
|
||||
}
|
||||
```
|
||||
@@ -599,10 +610,10 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut
|
||||
### Error Codes
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_VALIDATION_002` | 422 | uuid ارسال نشده |
|
||||
| `ERR_AUTH_002` | 400 | uuid نامعتبر یا تأیید نشده |
|
||||
| `ERR_AUTH_003` | 400 | OTP منقضی شده |
|
||||
| `ERR_VALIDATION_002` | 422 | grant ارسال نشده |
|
||||
| `ERR_AUTH_002` | 400 | grant نامعتبر، منقضی یا مصرفشده |
|
||||
| `ERR_AUTH_005` | 401 | کاربری با این شماره یافت نشد |
|
||||
| `ERR_RATE_LIMIT_001` | 429 | بیش از ۱۰ درخواست در ۵ دقیقه (per-IP) |
|
||||
|
||||
---
|
||||
|
||||
@@ -615,15 +626,15 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"new_password": "newpass123"
|
||||
"grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
|
||||
"new_password": "newpass1234"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Validation |
|
||||
|-------|------|----------|------------|
|
||||
| `uuid` | string | ✅ | UUID از `verify-code` (باید قبلاً verify شده باشد) |
|
||||
| `new_password` | string | ✅ | حداقل ۶ کاراکتر |
|
||||
| `grant` | string | ✅ | grant یکبارمصرف از `verify-code` |
|
||||
| `new_password` | string | ✅ | حداقل ۸ کاراکتر |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
@@ -636,6 +647,7 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut
|
||||
### Error Codes
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_VALIDATION_001` | 422 | uuid یا new_password نادرست/ناقص |
|
||||
| `ERR_AUTH_002` | 400 | uuid نامعتبر یا تأیید نشده |
|
||||
| `ERR_VALIDATION_001` | 422 | grant ارسال نشده یا رمز کمتر از ۸ کاراکتر |
|
||||
| `ERR_AUTH_002` | 400 | grant نامعتبر، منقضی یا مصرفشده |
|
||||
| `ERR_NOT_FOUND_001` | 404 | کاربری با این شماره یافت نشد |
|
||||
| `ERR_RATE_LIMIT_001` | 429 | بیش از ۵ درخواست در ۶۰ دقیقه (per-IP) |
|
||||
|
||||
@@ -31,6 +31,9 @@ class AuthController extends BaseController
|
||||
private readonly OtpService $otpService,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly RateLimiterFactory $sendCodeLimiter,
|
||||
private readonly RateLimiterFactory $verifyCodeLimiter,
|
||||
private readonly RateLimiterFactory $tokenIssueLimiter,
|
||||
private readonly RateLimiterFactory $passwordResetLimiter,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
@@ -185,6 +188,10 @@ class AuthController extends BaseController
|
||||
#[Route('/api/v1/user/verify-code', methods: ['POST'])]
|
||||
public function verifyCode(Request $request): JsonResponse
|
||||
{
|
||||
if ($resp = $this->enforceLimit($this->verifyCodeLimiter, $request)) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
$code = trim($data['code'] ?? '');
|
||||
@@ -197,7 +204,8 @@ class AuthController extends BaseController
|
||||
$isNewUser = $this->userRepo->findByMobile($otpData['mobile']) === null;
|
||||
|
||||
return $this->success([
|
||||
'message' => 'کد با موفقیت تایید شد.',
|
||||
'message' => 'کد با موفقیت تایید شد.',
|
||||
'grant' => $otpData['grant'],
|
||||
'is_new_user' => $isNewUser,
|
||||
]);
|
||||
}
|
||||
@@ -208,9 +216,9 @@ class AuthController extends BaseController
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['uuid'],
|
||||
required: ['grant'],
|
||||
properties: [
|
||||
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
|
||||
new OA\Property(property: 'grant', type: 'string', description: 'grant یکبارمصرف از verify-code'),
|
||||
new OA\Property(property: 'real_name', type: 'string', example: 'علی محمدی'),
|
||||
]
|
||||
)
|
||||
@@ -260,15 +268,14 @@ class AuthController extends BaseController
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
$grant = trim($data['grant'] ?? '');
|
||||
$realName = trim($data['real_name'] ?? '');
|
||||
|
||||
if (empty($uuid)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'uuid الزامی است', 422);
|
||||
if ($grant === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'grant الزامی است', 422);
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$mobile = $otpData['mobile'];
|
||||
$mobile = $this->otpService->consumeGrant($grant);
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile) ?? new User($mobile);
|
||||
if ($realName !== '') {
|
||||
@@ -276,22 +283,21 @@ class AuthController extends BaseController
|
||||
}
|
||||
|
||||
$this->userRepo->save($user);
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return $this->success(['message' => 'ثبتنام با موفقیت انجام شد.', 'uuid' => $user->getUuid()], 201);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/oauth/token',
|
||||
summary: 'مرحله ۳ — دریافت JWT با uuid تأییدشده',
|
||||
description: 'uuid را از مرحله ۱ (`/api/v1/user/send-code`) وارد کنید — **بعد از** اینکه در مرحله ۲ (`/api/v1/user/verify-code`) تأیید شد. access_token را در header درخواستهای بعدی استفاده کنید: `Authorization: Bearer <access_token>`',
|
||||
summary: 'مرحله ۳ — دریافت JWT با grant تأییدشده',
|
||||
description: 'فیلد `grant` را از پاسخ مرحله ۲ (`/api/v1/user/verify-code`) وارد کنید. این grant یکبارمصرف و کوتاهعمر (۱۲۰ ثانیه) است. access_token را در header درخواستهای بعدی استفاده کنید: `Authorization: Bearer <access_token>`',
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['grant_type', 'uuid'],
|
||||
required: ['grant_type', 'grant'],
|
||||
properties: [
|
||||
new OA\Property(property: 'grant_type', type: 'string', enum: ['mobile'], example: 'mobile'),
|
||||
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
|
||||
new OA\Property(property: 'grant', type: 'string', description: 'grant یکبارمصرف از verify-code'),
|
||||
]
|
||||
)
|
||||
),
|
||||
@@ -331,20 +337,25 @@ class AuthController extends BaseController
|
||||
#[Route('/oauth/token', methods: ['POST'])]
|
||||
public function issueToken(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$grant = $data['grant_type'] ?? '';
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
|
||||
if ($grant !== 'mobile') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'grant_type نامعتبر است', 400);
|
||||
if ($resp = $this->enforceLimit($this->tokenIssueLimiter, $request)) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$mobile = $otpData['mobile'];
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$grantType = $data['grant_type'] ?? '';
|
||||
$grant = trim($data['grant'] ?? '');
|
||||
|
||||
if ($grantType !== 'mobile') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'grant_type نامعتبر است', 400);
|
||||
}
|
||||
if ($grant === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'grant الزامی است', 422);
|
||||
}
|
||||
|
||||
$mobile = $this->otpService->consumeGrant($grant);
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile) ?? new User($mobile);
|
||||
$this->userRepo->save($user);
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return new JsonResponse($this->tokenService->issueTokens($user));
|
||||
}
|
||||
@@ -352,38 +363,44 @@ class AuthController extends BaseController
|
||||
#[Route('/api/v1/user/otp-login', methods: ['POST'])]
|
||||
public function otpLogin(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
|
||||
if ($uuid === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'uuid الزامی است', 422);
|
||||
if ($resp = $this->enforceLimit($this->tokenIssueLimiter, $request)) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$user = $this->userRepo->findByMobile($otpData['mobile']);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$grant = trim($data['grant'] ?? '');
|
||||
|
||||
if ($grant === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'grant الزامی است', 422);
|
||||
}
|
||||
|
||||
$mobile = $this->otpService->consumeGrant($grant);
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
|
||||
if (!$user) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_005, 'کاربری با این شماره یافت نشد', 401);
|
||||
}
|
||||
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return new JsonResponse($this->tokenService->issueTokens($user));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user/reset-password', methods: ['POST'])]
|
||||
public function resetPassword(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
$newPassword = trim($data['new_password'] ?? '');
|
||||
|
||||
if ($uuid === '' || mb_strlen($newPassword) < 6) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'uuid و رمز عبور (حداقل ۶ کاراکتر) الزامی است', 422);
|
||||
if ($resp = $this->enforceLimit($this->passwordResetLimiter, $request)) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$user = $this->userRepo->findByMobile($otpData['mobile']);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$grant = trim($data['grant'] ?? '');
|
||||
$newPassword = trim($data['new_password'] ?? '');
|
||||
|
||||
if ($grant === '' || mb_strlen($newPassword) < 8) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'grant و رمز عبور (حداقل ۸ کاراکتر) الزامی است', 422);
|
||||
}
|
||||
|
||||
$mobile = $this->otpService->consumeGrant($grant);
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
|
||||
if (!$user) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربری با این شماره یافت نشد', 404);
|
||||
@@ -391,7 +408,6 @@ class AuthController extends BaseController
|
||||
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $newPassword));
|
||||
$this->em->flush();
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return $this->success(['message' => 'رمز عبور با موفقیت تغییر یافت']);
|
||||
}
|
||||
@@ -603,6 +619,15 @@ class AuthController extends BaseController
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function enforceLimit(RateLimiterFactory $factory, Request $request): ?JsonResponse
|
||||
{
|
||||
$limiter = $factory->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);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolvePrimaryRole(User $user): string
|
||||
{
|
||||
$roles = $user->getRoles();
|
||||
|
||||
@@ -84,7 +84,7 @@ class PasswordAuthenticator extends AbstractAuthenticator
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $rawToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => 3600,
|
||||
'expires_in' => 900,
|
||||
'refresh_token_expires_in' => $this->refreshTokenTtl,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,36 @@ class OtpService
|
||||
return 'otp_' . str_replace('-', '_', $uuid);
|
||||
}
|
||||
|
||||
private function grantKey(string $grant): string
|
||||
{
|
||||
return 'otp_grant_' . $grant;
|
||||
}
|
||||
|
||||
public function issueGrant(string $mobile): string
|
||||
{
|
||||
$grant = bin2hex(random_bytes(32));
|
||||
$item = $this->cache->getItem($this->grantKey($grant));
|
||||
$item->set($mobile);
|
||||
$item->expiresAfter(120);
|
||||
$this->cache->save($item);
|
||||
|
||||
return $grant;
|
||||
}
|
||||
|
||||
public function consumeGrant(string $grant): string
|
||||
{
|
||||
$item = $this->cache->getItem($this->grantKey($grant));
|
||||
|
||||
if (!$item->isHit()) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
|
||||
}
|
||||
|
||||
$mobile = $item->get();
|
||||
$this->cache->delete($this->grantKey($grant));
|
||||
|
||||
return $mobile;
|
||||
}
|
||||
|
||||
public function sendCode(string $mobile): string
|
||||
{
|
||||
$uuid = Uuid::v4()->toRfc4122();
|
||||
@@ -69,33 +99,10 @@ class OtpService
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
|
||||
}
|
||||
|
||||
$data['verified'] = true;
|
||||
$item->set(json_encode($data));
|
||||
$item->expiresAfter($this->otpTtl);
|
||||
$this->cache->save($item);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getVerifiedOtpData(string $uuid): array
|
||||
{
|
||||
$item = $this->cache->getItem($this->key($uuid));
|
||||
|
||||
if (!$item->isHit()) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_003, null, 400);
|
||||
}
|
||||
|
||||
$data = json_decode($item->get(), true);
|
||||
|
||||
if (!($data['verified'] ?? false)) {
|
||||
throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function deleteOtp(string $uuid): void
|
||||
{
|
||||
$this->cache->delete($this->key($uuid));
|
||||
|
||||
$data['grant'] = $this->issueGrant($data['mobile']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class TokenService
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $rawToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => 3600,
|
||||
'expires_in' => 900,
|
||||
'refresh_token_expires_in' => $this->refreshTokenTtl,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class SecurityHeadersSubscriber implements EventSubscriberInterface
|
||||
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
|
||||
if ($event->getRequest()->isSecure()) {
|
||||
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
$response->headers->set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
||||
}
|
||||
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
|
||||
@@ -62,7 +62,7 @@ class FileValidatorService
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
|
||||
}
|
||||
|
||||
return $safeName;
|
||||
return bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
}
|
||||
|
||||
public function detectMimeType(string $filePath): string
|
||||
|
||||
Reference in New Issue
Block a user