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