# تسک ۰۲: ماژول احراز هویت ## توضیح دو روش ورود پشتیبانی می‌شود: **روش اول — 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..." } → هر دو توکن باطل می‌شوند ```