feat: Implement SMS sending functionality with KavehNegar and Rangineh providers
- Add SendSmsMessage class for encapsulating SMS message data. - Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS. - Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates. - Develop SendSmsHandler for handling SMS sending messages. - Create SmsService to manage SMS dispatching and logging. - Add UserProfileController for managing user profiles with CRUD operations. - Implement UserProfile entity and repository for user profile data management. - Update symfony.lock and bootstrap.php for project dependencies and environment setup.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
# معماری — تسک ۰۲: ماژول احراز هویت
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Auth/
|
||||
├── Controller/
|
||||
│ ├── OtpController.php ← send-code, verify-code
|
||||
│ ├── AuthController.php ← register, session/token
|
||||
│ ├── OAuthController.php ← /oauth/token, /oauth/userinfo
|
||||
│ └── UserController.php ← delete, patch user
|
||||
├── Service/
|
||||
│ ├── OtpService.php ← تولید، ذخیره و تأیید OTP در Redis
|
||||
│ ├── JwtService.php ← صدور و تمدید JWT
|
||||
│ ├── CaptchaService.php ← اعتبارسنجی captcha_token
|
||||
│ └── UserService.php ← ایجاد، ویرایش، حذف کاربر
|
||||
├── Repository/
|
||||
│ └── UserRepository.php
|
||||
├── Entity/
|
||||
│ └── User.php
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ ├── SendCodeRequest.php
|
||||
│ │ ├── VerifyCodeRequest.php
|
||||
│ │ ├── RegisterRequest.php
|
||||
│ │ ├── RefreshTokenRequest.php
|
||||
│ │ └── UpdateUserRequest.php
|
||||
│ └── Response/
|
||||
│ ├── TokenResponse.php
|
||||
│ └── UserInfoResponse.php
|
||||
└── Voter/
|
||||
└── UserVoter.php ← فقط owner یا admin میتواند ویرایش/حذف کند
|
||||
```
|
||||
|
||||
## نمودار جریان احراز هویت
|
||||
```
|
||||
کاربر
|
||||
│
|
||||
├─► POST /send-code
|
||||
│ └─► OtpService: تولید کد ۵ رقمی
|
||||
│ └─► Redis: ذخیره با کلید otp:{mobile} (TTL=120s)
|
||||
│ └─► SmsService: ارسال پیامک
|
||||
│
|
||||
├─► POST /verify-code
|
||||
│ └─► OtpService: تأیید کد از Redis
|
||||
│ ├─► اگر کاربر جدید: ایجاد User با وضعیت pending
|
||||
│ └─► JwtService: صدور access_token + refresh_token
|
||||
│
|
||||
└─► POST /register (با X-CSRF-Token)
|
||||
└─► UserService: تکمیل اطلاعات کاربر
|
||||
```
|
||||
|
||||
## Entity: User
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: 'users')]
|
||||
class User implements UserInterface
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\Column(length: 20, unique: true)]
|
||||
private string $mobile;
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
private ?string $firstName;
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
private ?string $lastName;
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true, unique: true)]
|
||||
private ?string $email;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
private string $status = 'pending'; // pending, active, blocked
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $roles = ['ROLE_USER'];
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# پایگاه داده — تسک ۰۲: ماژول احراز هویت
|
||||
|
||||
## جدول: users
|
||||
_(از بخش ۲.۱ مستند + بررسی DB backup Drupal)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT AUTO_INCREMENT PK | id | شناسه داخلی |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | شناسه عمومی UUID |
|
||||
| uid | INT FK → users.id NOT NULL | uid | ارجاع به خود جدول (self-reference — در Drupal الزامی) |
|
||||
| mobile_number | VARCHAR(20) UNIQUE NOT NULL | name | شماره موبایل — به عنوان username استفاده میشود |
|
||||
| password | VARCHAR(255) NOT NULL | pass | رمز عبور هششده با bcrypt |
|
||||
| realname | VARCHAR(255) NULL | field_realname | نام و نامخانوادگی کامل (نه first_name/last_name!) |
|
||||
| picture | VARCHAR(500) NULL | user_picture | آدرس تصویر پروفایل |
|
||||
| email | VARCHAR(180) UNIQUE NULL | mail | ایمیل (اختیاری) |
|
||||
| status | TINYINT(1) DEFAULT 1 | status | ۱=فعال، ۰=غیرفعال |
|
||||
| roles | JSON NOT NULL | — | نقشها — مثال: `{"0":"authenticated","2":"doctor"}` |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp — زمان ایجاد |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp — آخرین ویرایش |
|
||||
|
||||
> **⚠ مهم:**
|
||||
> - Drupal از `realname` (یک فیلد) استفاده میکند، **نه** `first_name` + `last_name`!
|
||||
> - timestampها نوع **INT** هستند (Unix timestamp)، نه DATETIME
|
||||
> - `status` نوع **TINYINT** است (نه ENUM)
|
||||
> - `uid` self-reference است — در Drupal هر user به خودش اشاره میکند
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_users_uuid ON users(uuid);
|
||||
CREATE UNIQUE INDEX idx_users_mobile ON users(mobile_number);
|
||||
CREATE UNIQUE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_status ON users(status);
|
||||
```
|
||||
|
||||
## ذخیرهسازی OTP در Redis (نه پایگاه داده)
|
||||
```
|
||||
کلید: otp:{uuid} ← UUID از /api/v1/user/send-code برگردانده میشود (نه mobile!)
|
||||
مقدار: {"code": "12345", "attempts": 0}
|
||||
TTL: 1200 ثانیه (20 دقیقه)
|
||||
```
|
||||
|
||||
## جریان OTP (MobileGrant)
|
||||
```
|
||||
1. POST /api/v1/user/send-code → {mobile, captcha_token}
|
||||
→ UUID تولید + کد OTP ذخیره در Redis با کلید otp:{uuid}
|
||||
→ UUID برگردانده میشود
|
||||
|
||||
2. POST /api/v1/user/verify-code → {mobile, captcha_token}
|
||||
→ کد از Redis با کلید otp:{uuid} تأیید میشود
|
||||
|
||||
3. POST /oauth/token → grant_type=mobile (MobileGrant)
|
||||
→ JWT token صادر میشود
|
||||
|
||||
4. GET /oauth/userinfo → Bearer token
|
||||
→ اطلاعات کاربر + شیء clinic_pro برگردانده میشود
|
||||
```
|
||||
|
||||
## نمونه پاسخ GET /oauth/userinfo (واقعی از Drupal)
|
||||
```json
|
||||
{
|
||||
"email": null,
|
||||
"email_verified": true,
|
||||
"username": "09120671710",
|
||||
"id": "22",
|
||||
"uuid": "d200f5c5-d717-4526-b263-d3bb7d0228d6",
|
||||
"created": "1762262151",
|
||||
"changed": "1762262267",
|
||||
"status": "1",
|
||||
"roles": {
|
||||
"0": "authenticated",
|
||||
"2": "doctor"
|
||||
},
|
||||
"realName": "single doctor",
|
||||
"picture": [],
|
||||
"clinic_pro": {
|
||||
"base_role": "doctor",
|
||||
"db_uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||
"db_key": "22bea8c1dc64d9b0c744810722519efe7290276ecd82d9bc650482aa4539bf0d",
|
||||
"my_doctors_uuid": {
|
||||
"uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||
"id": "29",
|
||||
"name": "single doctor"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **نکات `/oauth/userinfo`:**
|
||||
> - `realName` با حرف بزرگ N (camelCase)
|
||||
> - `roles` یک object است، نه array: `{"0":"authenticated","2":"doctor"}`
|
||||
> - `clinic_pro.base_role` → نقش اصلی: `doctor`, `clinic`, `doctor_s_secretary`
|
||||
> - `clinic_pro.db_uuid` → UUID موجودیت doctor/clinic در جدول clinic_pro
|
||||
> - فقط نقشهای `doctor`, `clinic`, `doctor_s_secretary` میتوانند با پسورد لاگین کنند
|
||||
|
||||
## روابط با سایر جداول
|
||||
```
|
||||
users → user_profiles (OneToOne) : تسک ۰۳
|
||||
users → doctors (OneToOne) : تسک ۰۵
|
||||
users → clinics (OneToOne) : تسک ۰۶
|
||||
users → appointments (OneToMany) : تسک ۱۰
|
||||
users → payments (OneToMany) : تسک ۱۵
|
||||
users → representations (OneToOne): تسک ۱۶
|
||||
```
|
||||
@@ -0,0 +1,162 @@
|
||||
# نکات پیادهسازی — تسک ۰۲: ماژول احراز هویت
|
||||
|
||||
## مهمترین تفاوت با طراحی اولیه
|
||||
|
||||
### جریان OTP با UUID (نه موبایل)
|
||||
در Drupal، کد OTP **با UUID** ذخیره میشود، نه با شماره موبایل:
|
||||
```php
|
||||
// ساختار ذخیرهسازی در KeyValue/Redis:
|
||||
key = uuid (تولیدشده در send-code)
|
||||
value = { "code": "12345", "mobile": "09120671713" }
|
||||
TTL = 1200 ثانیه
|
||||
```
|
||||
|
||||
`verify-code` و `oauth/token` هر دو `uuid` میخواهند، نه موبایل.
|
||||
|
||||
```php
|
||||
// OtpService.php
|
||||
public function generate(string $mobile): array
|
||||
{
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
$code = $this->isDev() ? '12345' : (string) random_int(10000, 99999);
|
||||
$this->redis->setex("otp:{$uuid}", 1200, json_encode([
|
||||
'code' => $code,
|
||||
'mobile' => $mobile,
|
||||
]));
|
||||
return ['uuid' => $uuid, 'code' => $code];
|
||||
}
|
||||
|
||||
public function verify(string $uuid, string $code): bool
|
||||
{
|
||||
$data = json_decode($this->redis->get("otp:{$uuid}"), true);
|
||||
if (!$data || $data['code'] !== $code) return false;
|
||||
$this->redis->del("otp:{$uuid}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getMobileByUuid(string $uuid): ?string
|
||||
{
|
||||
$data = json_decode($this->redis->get("otp:{$uuid}"), true);
|
||||
return $data['mobile'] ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
## MobileGrant در Symfony
|
||||
به جای OAuth2 کامل، یک custom JWT grant پیادهسازی کن:
|
||||
|
||||
```
|
||||
POST /oauth/token
|
||||
grant_type=mobile
|
||||
uuid=...
|
||||
code=...
|
||||
client_id=clinic-pro
|
||||
client_secret=...
|
||||
|
||||
→ OtpService::verify(uuid, code) تأیید کند
|
||||
→ getMobileByUuid(uuid) موبایل را بگیر
|
||||
→ کاربر را پیدا یا بساز
|
||||
→ JWT صادر کن
|
||||
```
|
||||
|
||||
## SMS Providers
|
||||
دو provider واقعی در Drupal:
|
||||
|
||||
**KavehNegar:**
|
||||
```php
|
||||
POST https://api.kavenegar.com/v1/{apiKey}/sms/send.json
|
||||
form: receptor={mobile}&message={code}&sender=10004346
|
||||
```
|
||||
|
||||
**Rangineh:**
|
||||
از پیادهسازی در `sms_provider/src/Plugin/SmsProvider/Rangineh.php` الگو بگیر.
|
||||
|
||||
**Interface در Symfony:**
|
||||
```php
|
||||
interface SmsProviderInterface {
|
||||
public function send(string $mobile, string $message): bool;
|
||||
}
|
||||
```
|
||||
پیکربندی در `.env`:
|
||||
```
|
||||
SMS_PROVIDER=kavenegar # kavenegar | rangineh | null (dev)
|
||||
KAVENEGAR_API_KEY=...
|
||||
KAVENEGAR_SENDER=10004346
|
||||
```
|
||||
|
||||
## Rate Limiting (مقادیر واقعی)
|
||||
```php
|
||||
// IP: 50 درخواست در ساعت
|
||||
// Mobile: 30 درخواست در ساعت
|
||||
// keys Redis:
|
||||
// rate_ip:{ip} TTL=3600
|
||||
// rate_mobile:{mobile} TTL=3600
|
||||
```
|
||||
|
||||
## TTL کد OTP: 1200 ثانیه (20 دقیقه)
|
||||
در طراحی اولیه اشتباهاً 120 ثانیه نوشته شده بود — مقدار واقعی از Drupal ۱۲۰۰ است.
|
||||
|
||||
## Flood Control (از Drupal)
|
||||
علاوه بر rate limiting، Drupal از flood control نیز استفاده میکند:
|
||||
- `oauth2_grant.mobile.failed_login_ip` — IP based
|
||||
- `oauth2_grant.mobile.failed_login_user` — User based
|
||||
در Symfony از Symfony's `RateLimiter` component جایگزین کن.
|
||||
|
||||
## سازگاری با کلاینت: GET /session/token
|
||||
```php
|
||||
return new Response(bin2hex(random_bytes(22)), 200, ['Content-Type' => 'text/plain']);
|
||||
```
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
DELETE /api/v1/user/{id} → ROLE_ADMIN
|
||||
PATCH /api/v1/user/{id} → owner یا ROLE_ADMIN
|
||||
```
|
||||
|
||||
## ساختار واقعی GET /oauth/userinfo (از سرور Drupal)
|
||||
|
||||
```json
|
||||
{
|
||||
"email": null,
|
||||
"email_verified": true,
|
||||
"username": "09120671710",
|
||||
"id": "22",
|
||||
"uuid": "d200f5c5-d717-4526-b263-d3bb7d0228d6",
|
||||
"created": "1762262151",
|
||||
"changed": "1762262267",
|
||||
"status": "1",
|
||||
"roles": {
|
||||
"0": "authenticated",
|
||||
"2": "doctor"
|
||||
},
|
||||
"realName": "single doctor",
|
||||
"picture": [],
|
||||
"clinic_pro": {
|
||||
"base_role": "doctor",
|
||||
"db_uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||
"db_key": "22bea8c1dc64d9b0c744810722519efe7290276ecd82d9bc650482aa4539bf0d",
|
||||
"my_doctors_uuid": {
|
||||
"uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||
"id": "29",
|
||||
"name": "single doctor"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### نکات مهم userinfo:
|
||||
- `roles` یک **object** (نه array) با کلیدهای عددی است: `{"0": "authenticated", "2": "doctor"}`
|
||||
- `id` و `uuid` از users table
|
||||
- `realName` با R بزرگ (camelCase)
|
||||
- `clinic_pro.base_role` = نقش اصلی کاربر
|
||||
- `clinic_pro.db_uuid` = UUID موجودیت مرتبط (doctor/clinic/representation)
|
||||
- `clinic_pro.db_key` = token دسترسی برای عملیات داخلی
|
||||
- `clinic_pro.my_doctors_uuid` (فقط برای doctor) = مشخصات پروفایل دکتر
|
||||
|
||||
## نقشهای مجاز برای login با password
|
||||
```
|
||||
فقط این نقشها میتوانند با POST /oauth/token (grant_type=password) لاگین کنند:
|
||||
- doctor
|
||||
- clinic
|
||||
- doctor_s_secretary
|
||||
```
|
||||
بقیه (patient, representation, admin) فقط از طریق OTP لاگین میکنند.
|
||||
@@ -0,0 +1,488 @@
|
||||
# تسک ۰۲: ماژول احراز هویت
|
||||
|
||||
## توضیح
|
||||
دو روش ورود پشتیبانی میشود:
|
||||
|
||||
**روش اول — OTP موبایل (برای بیماران و عموم):**
|
||||
`send-code` → UUID برمیگرداند → `verify-code` با UUID+code → `oauth/token` صادر میکند JWT + Refresh Token
|
||||
|
||||
**روش دوم — Username/Password (فقط برای دکتر، کلینیک، منشی):**
|
||||
`POST /api/v1/user/login` → مستقیم JWT + Refresh Token صادر میکند
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/user/send-code` | ارسال OTP، بازگشت `uuid` | خیر |
|
||||
| POST | `/api/v1/user/verify-code` | تأیید OTP با `uuid` + `code` | خیر |
|
||||
| POST | `/api/v1/user/register` | تکمیل ثبتنام | خیر |
|
||||
| POST | `/api/v1/user/login` | لاگین با username+password (دکتر/کلینیک/منشی) | خیر |
|
||||
| GET | `/session/token` | CSRF Token (سازگاری با کلاینت) | خیر |
|
||||
| POST | `/oauth/token` | صدور JWT + Refresh Token (OTP flow) | خیر |
|
||||
| POST | `/oauth/token/refresh` | تجدید JWT با Refresh Token | خیر |
|
||||
| GET | `/oauth/userinfo` | اطلاعات کاربر لاگینشده | بله |
|
||||
| POST | `/oauth/logout` | لغو توکنها | بله |
|
||||
| DELETE | `/api/v1/user/{id}` | حذف کاربر | بله (Admin) |
|
||||
| PATCH | `/api/v1/user/{id}` | ویرایش اطلاعات پایه کاربر | بله (Owner) |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱ کامل شده باشد
|
||||
|
||||
## زمان تخمینی
|
||||
۱۲ تا ۱۴ ساعت
|
||||
|
||||
---
|
||||
|
||||
## جریان واقعی OTP
|
||||
|
||||
### مرحله ۱ — POST /api/v1/user/send-code
|
||||
```json
|
||||
// Request
|
||||
{ "mobile": "09120671713", "captcha_token": "" }
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"uuid": "a1b2c3d4-e5f6-...",
|
||||
"message": "کد تایید با موفقیت ارسال شد."
|
||||
}
|
||||
|
||||
// Response 429 (rate limit)
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_AUTH_004", "message": "تعداد تلاشها به حد مجاز رسیده است" }]
|
||||
}
|
||||
```
|
||||
|
||||
**منطق داخلی:**
|
||||
```
|
||||
1. بررسی rate limit (IP: 50/hr، mobile: 30/hr)
|
||||
2. بررسی تعداد تلاشهای OTP برای این mobile (حداکثر 5 بار در TTL)
|
||||
3. تولید کد 5 رقمی
|
||||
4. ذخیره در Redis: key=otp:{uuid}، value={mobile, code, attempts:0}، TTL=1200s
|
||||
5. ارسال SMS async (از طریق Symfony Messenger)
|
||||
6. بازگشت uuid
|
||||
```
|
||||
|
||||
### مرحله ۲ — POST /api/v1/user/verify-code
|
||||
```json
|
||||
// Request
|
||||
{ "uuid": "a1b2c3d4-...", "code": "12345" }
|
||||
|
||||
// Response 200
|
||||
{ "message": "کد با موفقیت تایید شد.", "success": true }
|
||||
|
||||
// Response 400 — کد اشتباه
|
||||
{ "success": false, "errors": [{ "code": "ERR_AUTH_002", "message": "کد OTP نامعتبر است" }] }
|
||||
|
||||
// Response 400 — کد منقضی
|
||||
{ "success": false, "errors": [{ "code": "ERR_AUTH_003", "message": "کد OTP منقضی شده است" }] }
|
||||
```
|
||||
|
||||
**منطق داخلی:**
|
||||
```
|
||||
1. بررسی وجود key در Redis
|
||||
2. مقایسه code با hash_equals() — نه == (جلوگیری از Timing Attack)
|
||||
3. افزایش attempts در Redis
|
||||
4. اگر attempts > 5 → خطای ERR_AUTH_004 و حذف key
|
||||
5. در صورت صحت → افزودن verified:true به Redis
|
||||
```
|
||||
|
||||
```php
|
||||
// ⚠ استفاده از hash_equals برای جلوگیری از Timing Attack
|
||||
if (!hash_equals($storedCode, $submittedCode)) {
|
||||
// کد اشتباه
|
||||
}
|
||||
```
|
||||
|
||||
### مرحله ۳ — POST /oauth/token (MobileGrant)
|
||||
```
|
||||
// Request (form-data)
|
||||
grant_type=mobile
|
||||
client_id=clinic-pro
|
||||
client_secret=secret
|
||||
uuid=a1b2c3d4-...
|
||||
code=12345
|
||||
registration=true
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "a8f3b2...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token_expires_in": 2592000
|
||||
}
|
||||
```
|
||||
|
||||
**منطق داخلی:**
|
||||
```
|
||||
1. خواندن uuid از Redis — بررسی verified:true
|
||||
2. اگر کاربر جدید و registration=true → ایجاد کاربر
|
||||
3. صدور JWT (TTL=3600s)
|
||||
4. تولید Refresh Token (random_bytes(32) → bin2hex → 64 char)
|
||||
5. هش کردن Refresh Token: hash('sha256', $rawToken)
|
||||
6. ذخیره در Redis: key=refresh:{hash}، value=user_id، TTL=2592000s
|
||||
7. حذف OTP از Redis
|
||||
8. بازگشت access_token + raw refresh_token (نه hash)
|
||||
```
|
||||
|
||||
```php
|
||||
// تولید و ذخیره Refresh Token
|
||||
$rawToken = bin2hex(random_bytes(32)); // 64 کاراکتر hex
|
||||
$hashedToken = hash('sha256', $rawToken); // ذخیره hash در Redis
|
||||
$redis->setex("refresh:{$hashedToken}", 2592000, $userId);
|
||||
// ارسال rawToken به کلاینت — هرگز hash را ارسال نکن
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Refresh Token
|
||||
|
||||
### POST /oauth/token/refresh
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"refresh_token": "a8f3b2c1d0..."
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "new_token_here",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
|
||||
// Response 401 — Refresh Token نامعتبر یا منقضی
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_AUTH_001", "message": "Refresh Token نامعتبر یا منقضی شده است" }]
|
||||
}
|
||||
```
|
||||
|
||||
**منطق:**
|
||||
```
|
||||
1. هش کردن token دریافتی: hash('sha256', $submittedToken)
|
||||
2. جستجو key=refresh:{hash} در Redis
|
||||
3. اگر وجود ندارد → 401
|
||||
4. صدور JWT جدید
|
||||
5. Refresh Token Rotation:
|
||||
- حذف hash قدیمی از Redis
|
||||
- تولید rawToken جدید + hash جدید
|
||||
- ذخیره hash جدید با TTL=2592000s
|
||||
6. بازگشت access_token + rawToken جدید
|
||||
```
|
||||
|
||||
### POST /oauth/logout
|
||||
```json
|
||||
// Request — Header: Authorization: Bearer {access_token}
|
||||
// Body:
|
||||
{ "refresh_token": "a8f3b2c1d0..." }
|
||||
|
||||
// Response 200
|
||||
{ "success": true, "message": "خروج با موفقیت انجام شد" }
|
||||
```
|
||||
|
||||
**منطق:**
|
||||
```
|
||||
1. حذف refresh:{token} از Redis
|
||||
2. افزودن JWT به blacklist: key=blacklist:{jti}، TTL=remaining_ttl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /session/token — CSRF
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{ "token": "XwZ9k2P..." }
|
||||
```
|
||||
|
||||
ذخیره در Redis: `key=csrf:{token}` با TTL=3600s
|
||||
|
||||
---
|
||||
|
||||
## GET /oauth/userinfo
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 33,
|
||||
"uuid": "...",
|
||||
"mobile_number": "09120671713",
|
||||
"realName": "علی احمدی",
|
||||
"picture": null,
|
||||
"status": 1,
|
||||
"roles": { "0": "authenticated", "2": "doctor" },
|
||||
"clinic_pro": {
|
||||
"base_role": "doctor",
|
||||
"db_uuid": "...",
|
||||
"db_key": 29,
|
||||
"my_doctors_uuid": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
| نوع | حد | پنجره |
|
||||
|-----|-----|-------|
|
||||
| IP | 50 درخواست | ساعتی |
|
||||
| Mobile | 30 درخواست | ساعتی |
|
||||
| OTP Attempts | 5 تلاش | در طول TTL (1200s) |
|
||||
|
||||
**Rate Limit Headers در Response:**
|
||||
```
|
||||
X-RateLimit-Limit: 50
|
||||
X-RateLimit-Remaining: 47
|
||||
X-RateLimit-Reset: 1748003600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SMS Providers
|
||||
|
||||
```
|
||||
اصلی: KavehNegar (KAVENEGAR_API_KEY)
|
||||
جایگزین: Rangineh (RANGINEH_API_KEY)
|
||||
Fallback logic: اگر KavehNegar خطا داد → Rangineh
|
||||
محیط dev: کد ثابت 12345 (بدون ارسال واقعی)
|
||||
```
|
||||
|
||||
ارسال SMS از طریق **Symfony Messenger** (async) انجام میشود.
|
||||
|
||||
---
|
||||
|
||||
## Redis Key Schema
|
||||
|
||||
| Key | Value | TTL |
|
||||
|-----|-------|-----|
|
||||
| `otp:{uuid}` | `{mobile, code, attempts, verified}` | 1200s |
|
||||
| `refresh:{hash}` | `user_id` | 2592000s |
|
||||
| `blacklist:{jti}` | `1` | remaining JWT TTL |
|
||||
| `csrf:{token}` | `1` | 3600s |
|
||||
| `rate_ip:{ip}` | count | 3600s |
|
||||
| `rate_mobile:{mobile}` | count | 3600s |
|
||||
|
||||
---
|
||||
|
||||
## لاگین با Username/Password (برای دکتر، کلینیک، منشی)
|
||||
|
||||
### POST /api/v1/user/login
|
||||
|
||||
**چه کسانی میتوانند استفاده کنند:**
|
||||
- کاربران با نقش `doctor`
|
||||
- کاربران با نقش `clinic`
|
||||
- کاربران با نقش `doctor_s_secretary`
|
||||
|
||||
بیماران عادی فقط از طریق OTP وارد میشوند — این endpoint برای آنها در دسترس نیست.
|
||||
|
||||
> **⚠ نام کاربری برای همه کاربران و همه نقشها = شماره موبایل است.**
|
||||
> ایمیل به عنوان username پشتیبانی نمیشود.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"mobile_number": "09120671713",
|
||||
"password": "SecurePass123!"
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "a8f3b2c1d0...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token_expires_in": 2592000
|
||||
}
|
||||
|
||||
// Response 401 — اطلاعات اشتباه
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_AUTH_005", "message": "نام کاربری یا رمز عبور اشتباه است" }]
|
||||
}
|
||||
|
||||
// Response 403 — نقش کاربر اجازه ندارد از این روش استفاده کند
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_AUTH_006", "message": "این نوع حساب فقط از طریق کد OTP وارد میشود" }]
|
||||
}
|
||||
```
|
||||
|
||||
**منطق داخلی:**
|
||||
```
|
||||
1. جستجوی کاربر با mobile_number
|
||||
2. بررسی وجود کاربر و password_hash
|
||||
3. تأیید رمز با password_verify()
|
||||
4. بررسی نقش کاربر — فقط doctor / clinic / doctor_s_secretary مجاز
|
||||
5. صدور JWT (TTL=3600s)
|
||||
6. تولید Refresh Token و ذخیره SHA-256 hash در Redis
|
||||
7. ثبت رویداد login_success در security_logs
|
||||
8. بازگشت access_token + raw refresh_token
|
||||
```
|
||||
|
||||
**پیادهسازی در Symfony — Custom Authenticator:**
|
||||
|
||||
```php
|
||||
// src/Auth/Security/PasswordAuthenticator.php
|
||||
class PasswordAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
return $request->getPathInfo() === '/api/v1/user/login'
|
||||
&& $request->isMethod('POST');
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$mobile = $data['mobile_number'] ?? '';
|
||||
$password = $data['password'] ?? '';
|
||||
|
||||
return new Passport(
|
||||
new UserBadge($mobile, fn($m) => $this->userRepo->findByMobile($m)),
|
||||
new PasswordCredentials($password),
|
||||
[new CsrfTokenBadge('login', $data['_csrf'] ?? '')]
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
// بررسی نقش — فقط doctor/clinic/secretary
|
||||
$allowedRoles = ['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_SECRETARY'];
|
||||
if (empty(array_intersect($user->getRoles(), $allowedRoles))) {
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'errors' => [['code' => 'ERR_AUTH_006', 'message' => 'این نوع حساب فقط از طریق کد OTP وارد میشود']]
|
||||
], 403);
|
||||
}
|
||||
|
||||
$accessToken = $this->jwtManager->create($user);
|
||||
$rawToken = bin2hex(random_bytes(32));
|
||||
$hashedToken = hash('sha256', $rawToken);
|
||||
$this->redis->setex("refresh:{$hashedToken}", 2592000, $user->getId());
|
||||
|
||||
$this->auditLog->log('login_success', $user->getId(), $request, ['method' => 'password']);
|
||||
|
||||
return new JsonResponse([
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $rawToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => 3600,
|
||||
'refresh_token_expires_in' => 2592000,
|
||||
]);
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||
{
|
||||
$this->auditLog->log('login_failed', null, $request, ['reason' => $exception->getMessage()]);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'errors' => [['code' => 'ERR_AUTH_005', 'message' => 'نام کاربری یا رمز عبور اشتباه است']]
|
||||
], 401);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**فیلد password در جدول users:**
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN password_hash VARCHAR(255) NULL;
|
||||
-- NULL برای بیمارانی که فقط OTP دارند
|
||||
-- پر شده برای doctor/clinic/secretary
|
||||
```
|
||||
|
||||
**تغییر رمز عبور (دکتر/کلینیک/منشی):**
|
||||
```json
|
||||
PATCH /api/v1/user/{uuid}/password
|
||||
Authorization: Bearer {access_token}
|
||||
|
||||
// Request
|
||||
{
|
||||
"current_password": "OldPass123!",
|
||||
"new_password": "NewPass456!",
|
||||
"new_password_confirmation": "NewPass456!"
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{ "success": true, "message": "رمز عبور با موفقیت تغییر کرد" }
|
||||
```
|
||||
|
||||
**قوانین رمز عبور:**
|
||||
- حداقل ۸ کاراکتر
|
||||
- حداقل یک حرف بزرگ
|
||||
- حداقل یک عدد
|
||||
- bcrypt با cost=12
|
||||
|
||||
---
|
||||
|
||||
## ⚠ استاندارد JWT در Symfony — کجا توکن را ارسال کنیم؟
|
||||
|
||||
این یکی از مهمترین نکات امنیتی پروژه است.
|
||||
|
||||
### Access Token — همیشه در هدر Authorization
|
||||
|
||||
```
|
||||
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
|
||||
```
|
||||
|
||||
**LexikJWTAuthenticationBundle** این هدر را در firewall `api` به صورت خودکار بررسی میکند.
|
||||
هیچ کد اضافهای در Controller لازم نیست — middleware JWT مجاز بودن را تأیید میکند.
|
||||
|
||||
```yaml
|
||||
# config/packages/security.yaml
|
||||
firewalls:
|
||||
api:
|
||||
pattern: ^/(api|oauth)/
|
||||
stateless: true
|
||||
jwt: ~ # ← این خط کافی است؛ خودش هدر Authorization را میخواند
|
||||
```
|
||||
|
||||
**هرگز access_token را اینجا نفرست:**
|
||||
```
|
||||
❌ GET /api/v1/doctor?token=eyJ... ← URL param
|
||||
❌ POST /api/v1/payment body: {token: ...} ← Request body
|
||||
❌ Cookie: access_token=eyJ... ← Cookie
|
||||
```
|
||||
|
||||
### Refresh Token — فقط در body برای endpoint مخصوص
|
||||
|
||||
Refresh Token هیچوقت در `Authorization` header نمیرود. فقط یکبار و فقط به endpoint `/oauth/token/refresh` در body ارسال میشود:
|
||||
|
||||
```
|
||||
POST /oauth/token/refresh
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"refresh_token": "a8f3b2c1d0e4f5..."
|
||||
}
|
||||
```
|
||||
|
||||
این endpoint در security.yaml به صورت `PUBLIC_ACCESS` است چون تأیید هویت با خود Refresh Token انجام میشود (نه با JWT).
|
||||
|
||||
### خلاصه گردش توکنها
|
||||
|
||||
```
|
||||
[ورود] → Response body:
|
||||
{
|
||||
"access_token": "eyJ..." ← ذخیره در memory (نه localStorage)
|
||||
"refresh_token": "a8f3b2..." ← ذخیره در HttpOnly Cookie یا secure storage
|
||||
}
|
||||
|
||||
[هر درخواست محافظتشده]:
|
||||
Authorization: Bearer eyJ... ← فقط access_token در header
|
||||
|
||||
[وقتی access_token منقضی شد]:
|
||||
POST /oauth/token/refresh
|
||||
body: { "refresh_token": "a8f3b2..." }
|
||||
→ Response: { "access_token": "eyJ_new...", "refresh_token": "new_refresh..." }
|
||||
|
||||
[خروج]:
|
||||
POST /oauth/logout
|
||||
Authorization: Bearer eyJ...
|
||||
body: { "refresh_token": "a8f3b2..." }
|
||||
→ هر دو توکن باطل میشوند
|
||||
```
|
||||
@@ -0,0 +1,77 @@
|
||||
# جریان کاربری — تسک ۰۲: ماژول احراز هویت
|
||||
|
||||
## جریان کامل ورود با OTP (جریان واقعی از Drupal)
|
||||
|
||||
```
|
||||
کاربر موبایل را وارد میکند
|
||||
│
|
||||
▼
|
||||
POST /api/v1/user/send-code
|
||||
{ mobile: "09120671713", captcha_token: "" }
|
||||
│
|
||||
├─► اعتبارسنجی فرمت موبایل (/^(\+98|0)?9\d{9}$/)
|
||||
├─► بررسی rate limit IP (max 50/hour)
|
||||
├─► بررسی rate limit Mobile (max 30/hour)
|
||||
├─► تولید UUID + کد OTP
|
||||
├─► ذخیره در Redis: otp:{uuid} = {code, mobile} (TTL=1200s)
|
||||
└─► ارسال SMS
|
||||
│
|
||||
▼
|
||||
Response: { uuid: "a1b2c3d4-...", message: "..." }
|
||||
│
|
||||
▼
|
||||
کاربر کد را وارد میکند
|
||||
│
|
||||
▼
|
||||
POST /api/v1/user/verify-code
|
||||
{ uuid: "a1b2c3d4-...", code: "12345" }
|
||||
│
|
||||
├─► بررسی وجود uuid در Redis
|
||||
├─► مقایسه code
|
||||
│ ├─► نادرست: خطا
|
||||
│ └─► درست: حذف از Redis
|
||||
└─► Response: { message: "کد با موفقیت تایید شد.", success: true }
|
||||
|
||||
⚠️ verify-code در Drupal JWT صادر نمیکند!
|
||||
JWT در مرحله بعد با /oauth/token صادر میشود.
|
||||
│
|
||||
▼
|
||||
POST /oauth/token (MobileGrant)
|
||||
grant_type=mobile
|
||||
uuid=a1b2c3d4-... ← همان uuid
|
||||
code=12345 ← همان code
|
||||
client_id=clinic-pro
|
||||
client_secret=...
|
||||
registration=true ← اگر false باشد، فقط کاربر موجود میتواند وارد شود
|
||||
│
|
||||
├─► OtpService::verify(uuid, code)
|
||||
├─► OtpService::getMobileByUuid(uuid)
|
||||
├─► بررسی وجود کاربر با این موبایل
|
||||
│ ├─► وجود دارد: ادامه
|
||||
│ └─► جدید + registration=true: ایجاد user با status=pending
|
||||
└─► صدور JWT
|
||||
│
|
||||
▼
|
||||
Response: { access_token, refresh_token, token_type: "Bearer", expires_in: 3600 }
|
||||
```
|
||||
|
||||
## جریان تمدید Token
|
||||
|
||||
```
|
||||
POST /oauth/token
|
||||
grant_type=refresh_token
|
||||
client_id=clinic-pro
|
||||
client_secret=...
|
||||
refresh_token=eyJ...
|
||||
│
|
||||
└─► بررسی refresh_token → صدور access_token جدید
|
||||
```
|
||||
|
||||
## جریان دریافت اطلاعات کاربر
|
||||
|
||||
```
|
||||
GET /oauth/userinfo
|
||||
Authorization: Bearer {access_token}
|
||||
│
|
||||
└─► decode JWT → بازگشت: sub, uuid, name, email, phone_number, scope
|
||||
```
|
||||
Reference in New Issue
Block a user