Files
clinicpro/docs/tasks/task-02-authentication/task.md
T
hamed de1a78a235 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.
2026-06-09 22:00:34 +03:30

489 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# تسک ۰۲: ماژول احراز هویت
## توضیح
دو روش ورود پشتیبانی می‌شود:
**روش اول — 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..." }
→ هر دو توکن باطل می‌شوند
```