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

14 KiB
Raw Blame History

تسک ۰۲: ماژول احراز هویت

توضیح

دو روش ورود پشتیبانی می‌شود:

روش اول — 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

// 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

// 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
// ⚠ استفاده از 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)
// تولید و ذخیره 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

// 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

// 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

// Response 200
{ "token": "XwZ9k2P..." }

ذخیره در Redis: key=csrf:{token} با TTL=3600s


GET /oauth/userinfo

{
  "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 پشتیبانی نمی‌شود.

// 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:

// 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:

ALTER TABLE users ADD COLUMN password_hash VARCHAR(255) NULL;
-- NULL برای بیمارانی که فقط OTP دارند
-- پر شده برای doctor/clinic/secretary

تغییر رمز عبور (دکتر/کلینیک/منشی):

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 مجاز بودن را تأیید می‌کند.

# 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..." }
    → هر دو توکن باطل می‌شوند