Files
clinicpro/docs/api/auth.md
T
hamed 57aeb40934 feat: add staff role functionality with dashboard access and service management
- Implemented SidebarStaff component tests to ensure staff users see only their dashboard and services.
- Created StaffMyServicesPage to display assigned services for staff users.
- Added migration to link clinic staff rows to user accounts for ROLE_STAFF access.
- Defined StaffPermissions class for static permissions related to staff role.
- Introduced StaffRouteGuardSubscriber to restrict API access for staff users.
- Developed StaffAccountService for managing staff user accounts and linking them to clinic staff.
- Added comprehensive tests for StaffAccountService to validate user creation, mobile number handling, and account attachment.
- Implemented tests for staff dashboard access to ensure proper permissions and access control.
- Created tests for staff login context to verify correct environment visibility based on user roles.
2026-07-30 10:18:41 +03:30

25 KiB
Raw Blame History

Authentication API

Prefix: /api/v1/user and /oauth
Permission: All endpoints in this module are PUBLIC (no JWT required) except userinfo and logout

🛡️ ALTCHA captcha: وقتی ALTCHA_ENABLED=true است (در prod)، endpointهای send-code، register، otp-login و reset-password علاوه بر بدنه‌ی خود، فیلد altcha (payload حل‌شده‌ی widget) را الزامی می‌کنند؛ در غیر این صورت 422 با کد ERR_CAPTCHA_001 برمی‌گردد. جزئیات و مسیر challenge در captcha.md.


POST /api/v1/user/send-code

Send OTP code to mobile number.

Permission: PUBLIC

Request Body

{
  "mobile": "09123456789",
  "domain": "yasuj-nobat.ir"
}
Field Type Required Validation
mobile string Format: 09XXXXXXXXX (11 digits)
domain string دامنه‌ی شهرِ درخواست‌کننده (multi-domain). اگر ارسال شود، site_name شهرِ متناظر در جدول cities پیدا شده و به‌عنوان متغیر {site} در متن پیامک OTP قابل استفاده است (شخصی‌سازی متن). اگر نیامد یا شهر پیدا نشد → مقدار پیش‌فرض «کلینیک پرو». حداکثر ۲۵۳ کاراکتر.

Response 200

{
  "success": true,
  "data": {
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "message": "کد تأیید ارسال شد"
  }
}

Errors

Code HTTP Description
ERR_VALIDATION_001 422 Invalid mobile format
ERR_AUTH_004 429 OTP rate limit exceeded
ERR_RATE_LIMIT_001 429 بیش از حد مجاز — هم per-IP (۵ در ۶۰ دقیقه) و هم per-mobile (۵ در ۶۰ دقیقه؛ ضد flood از IPهای چرخشی)

POST /api/v1/user/verify-code

Verify OTP code. Returns whether this is a new or existing user.

Permission: PUBLIC

Request Body

{
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "code": "123456"
}
Field Type Required Description
uuid string UUID returned from send-code
code string 6-digit OTP

Response 200

{
  "success": true,
  "data": {
    "message": "کد با موفقیت تایید شد.",
    "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
    "is_new_user": false
  }
}

grant یک توکنِ یک‌بارمصرف و کوتاه‌عمر (۱۲۰ ثانیه) است که در مرحله‌ی بعد (register / oauth/token / otp-login / reset-password) مصرف می‌شود. خودِ OTP (uuid) پس از تأیید موفق حذف می‌شود. هر grant فقط یک‌بار قابل‌استفاده است؛ هر action که grant می‌خواهد یک grant جداگانه لازم دارد (یعنی برای new-user که هم register و هم login لازم است، باید دوبار verify انجام شود یا مستقیماً oauth/token صدا زده شود که خودش کاربر را می‌سازد).

Errors

Code HTTP Description
ERR_AUTH_002 400 Invalid OTP code
ERR_AUTH_003 400 OTP expired
ERR_VALIDATION_002 422 Missing required field
ERR_RATE_LIMIT_001 429 بیش از ۱۰ تلاش در ۱۵ دقیقه (per-IP)

POST /api/v1/user/register

Complete registration for new users (called only when is_new_user: true).

Permission: PUBLIC

Request Body

{
  "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
  "real_name": "علی احمدی"
}
Field Type Required Description
grant string grant یک‌بارمصرف از verify-code
real_name string User's full name

Response 201

{
  "success": true,
  "data": {
    "message": "ثبت‌نام با موفقیت انجام شد",
    "uuid": "550e8400-e29b-41d4-a716-446655440000"
  }
}

Errors

Code HTTP Description
ERR_VALIDATION_002 422 Missing grant
ERR_AUTH_002 400 grant نامعتبر یا منقضی/مصرف‌شده

POST /api/v1/user/login

Login with mobile number and password (for users who set a password).

Permission: PUBLIC

Request Body

{
  "mobile_number": "09123456789",
  "password": "mypassword"
}
Field Type Required
mobile_number string
password string

Response 200

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
  "refresh_token": "def50200...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token_expires_in": 2592000
}

فقط برای کاربران staff (ROLE_ADMIN/DOCTOR/CLINIC/SECRETARY). Access token TTL: ۹۰۰ ثانیه (۱۵ دقیقه).

Errors

Code HTTP Description
ERR_AUTH_005 401 Wrong credentials
ERR_AUTH_006 403 کاربر staff نیست
429 429 Too many attempts (۱۰ در دقیقه per-IP)

POST /oauth/token

Exchange a one-time grant (from verify-code) for a JWT access token. اگر کاربری با آن موبایل وجود نداشته باشد، ساخته می‌شود.

Permission: PUBLIC

Request Body

{
  "grant_type": "mobile",
  "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039"
}
Field Type Required Description
grant_type string Must be "mobile"
grant string grant یک‌بارمصرف از verify-code (عمر ۱۲۰ ثانیه)

Response 200

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
  "refresh_token": "def50200...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token_expires_in": 2592000
}

JWT payload: { username: mobile_number, roles: [...], iat, exp }
Access token TTL: ۹۰۰ ثانیه (۱۵ دقیقه) | Refresh token TTL: ۳۰ روز (در cache، هش‌شده و rotate-on-use)

Errors

Code HTTP Description
ERR_VALIDATION_001 400 grant_type نامعتبر
ERR_VALIDATION_002 422 grant ارسال نشده
ERR_AUTH_002 400 grant نامعتبر، منقضی یا قبلاً مصرف‌شده
ERR_RATE_LIMIT_001 429 بیش از ۱۰ درخواست در ۵ دقیقه (per-IP)

POST /oauth/token/refresh

Refresh expired JWT using refresh token.

Permission: PUBLIC

Request Body

{
  "refresh_token": "def50200..."
}

قابل استفاده‌ی مجدد: refresh token تا انقضای TTL خود معتبر است و در پاسخ بدون تغییر برگردانده می‌شود (یک‌بارمصرف/چرخشی نیست — سایت عمومی در هر render سمت‌سرور refresh می‌زند و نمی‌تواند توکن چرخش‌یافته را ذخیره کند). کاربر معلق (status != 1) نمی‌تواند refresh کند (401).

Response 200

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
  "refresh_token": "<همان توکن ارسالی — بدون تغییر>",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token_expires_in": 2592000
}

Errors

Code HTTP Description
ERR_AUTH_001 401 Invalid/expired refresh token, or suspended user (status != 1)

GET /oauth/userinfo

Get authenticated user info — extended with multi-context support.

Permission: AUTH — requires valid JWT

Headers

Authorization: Bearer <token>

Response 200

{
  "success": true,
  "data": {
    "id": 4766,
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "mobile_number": "09123456789",
    "realName": "دکتر وحید درویشی",
    "status": 1,
    "roles": ["ROLE_USER", "ROLE_DOCTOR"],
    "primary_role": "doctor",
    "db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
    "db_key": "hmac-sha256-hash...",
    "doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
    "context": {
      "type": "doctor",
      "db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
      "name": "مطب شخصی دکتر وحید درویشی",
      "role": "doctor"
    },
    "available_contexts": [
      {
        "type": "doctor",
        "db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
        "name": "مطب شخصی دکتر وحید درویشی",
        "role": "doctor"
      },
      {
        "type": "clinic",
        "db_uuid": "clinic-uuid-...",
        "name": "کلینیک سلامت",
        "role": "doctor",
        "scope": "clinic",
        "doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
        "permissions": {
          "version": 1,
          "resources": {
            "appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
            "appointment_settings": { "view": true, "update": true },
            "patients": { "view": true, "create": true, "update": true, "delete": false },
            "payments": { "view": true, "create": false, "update": false, "delete": false },
            "services": { "view": true, "update": false },
            "clinic_info": { "view": true, "update": false }
          }
        }
      }
    ]
  }
}
فیلد نوع توضیح
primary_role string نقش اصلی: admin | clinic | doctor | secretary | staff | representation | user
db_uuid string|null UUID موجودیت فعال (null = هنوز context انتخاب نشده)
db_key string|null HMAC-SHA256(db_uuid, APP_SECRET) برای اعتبارسنجی
doctor_uuid string|null UUID دکتر — ثابت است حتی در context کلینیک که db_uuid برابر UUID کلینیک است. برای کاربران غیر دکتر: null
context object|null context فعال انتخاب‌شده
available_contexts array همه محیط‌های کاری قابل انتخاب

مالکِ چند کلینیک: اگر یک کاربر مالک بیش از یک کلینیک باشد، همهٔ آن‌ها در این فهرست می‌آیند و به هرکدام می‌شود سوییچ کرد. تا پیش از این فقط اولی می‌آمد و کلینیک دوم اصلاً قابل انتخاب نبود — که با جداسازی محیط یعنی دادهٔ آن کلینیک برای خودِ مالکش هم نامرئی می‌ماند. (MultiClinicOwnerContextTest)

فیلد permissions در هر context:

حالت context مقدار permissions
مطب شخصی پزشک (type: doctor، role: doctor) null — محیط خودش، محدودیتی ندارد
مالک کلینیک (role: clinic) null — مالک هرگز محدود نمی‌شود
پزشکِ عضو کلینیک (role: doctor، scope: clinic) envelope کامل {version, resources} از clinic_doctor_permissions
پزشکِ عضوی که دسترسی‌اش غیرفعال شده {version: 1, resources: {}} — یعنی هیچ دسترسی
منشی (role: secretary) envelope کامل از doctor_secretaries
پرسنل (role: staff) envelope ثابت {"version":1,"resources":{"services":{"view":true},"appointments":{"view":true}}} — قابل ویرایش نیست

نکتهٔ مهم برای کلاینت: نبودِ permissions (یا null) یعنی «بدون محدودیت»، نه «بدون دسترسی». ساختار و کلیدهای مجوز پزشکِ عضو کلینیک در docs/api/clinic.md → بخش Clinic Doctor Permissions آمده است.

قانون primary_role (اولویت‌بندی):

  • ROLE_ADMIN"admin"
  • ROLE_CLINIC"clinic"
  • ROLE_DOCTOR"doctor"
  • ROLE_SECRETARY"secretary"
  • ROLE_STAFF"staff" (پرسنل کلینیک/مطب؛ عمداً بعد از منشی: کاربری که هر دو نقش را دارد منشی می‌ماند)
  • ROLE_REPRESENTATION"representation" (نماینده؛ دسترسی محدود به پنل ادمین: افزودن پزشک/کلینیک، نوبت‌های پزشکانِ زیرمجموعه، داشبورد نماینده)
  • بقیه → "user"

نقش staff — کاربری که از POST /api/v1/staff با has_account: true ساخته شده (رجوع به staff.md):

  • هر ردیف فعالِ clinic_staff که به این کاربر وصل است، یک context با role: "staff" و scope برابر doctor یا clinic می‌سازد. پرسنلِ غیرفعال هیچ context نمی‌گیرد.
  • ورود با رمز مجاز است (User::isStaff() شامل ROLE_STAFF است).
  • دسترسی API این کاربر پیش‌فرض بسته است: فقط GET /api/v1/dashboard/staff، POST /api/v1/auth/switch-context، POST /api/v1/user/change-password و مسیرهای /oauth/*؛ بقیهٔ /api/v1/* با ERR_FORBIDDEN_001 و ۴۰۳ رد می‌شود (StaffRouteGuardSubscriber).

قانون context.role — نقشی که در آن محیط کاری فعال است:

  • context مطب شخصی دکتر: "doctor"
  • context کلینیک که دکتر عضو آن است (مالک نیست): "doctor" + "scope": "clinic" — پزشک می‌ماند و فقط نوبت‌های خودش در آن کلینیک را می‌بیند؛ دسترسی مدیریتی پنل کلینیک ندارد
  • context کلینیک که دکتر صاحب آن است: "clinic" (دسترسی کامل مالک)
  • context منشی: "secretary"
  • context پرسنل: "staff" + scope برابر نوع محیط (doctor یا clinic)

نکته frontend: پس از switchContext، primaryRole در store از context.role و scope از context.scope آپدیت می‌شود. وقتی role:"doctor" و scope:"clinic" است (پزشکِ مهمان)، Sidebar فقط «داشبورد» و «نوبت‌ها» را نشان می‌دهد و مسیرهای مدیریتی (staff, clinic-services, subscription, my-secretaries, my-patients, profile) به داشبورد ریدایرکت می‌شوند. در سمت backend هم endpointهای مدیریتی برای پزشک فقط scope شخصیِ خودش را برمی‌گردانند (نه کلینیک) و endpointهای ویرایش کلینیک مالکیت را چک می‌کنند (۴۰۳).

قانون db_uuid:

  • اگر یک context وجود دارد: خودکار فعال می‌شود
  • اگر چند context وجود دارد و کاربر هنوز انتخاب نکرده: null — frontend باید صفحه انتخاب نشان دهد
  • پس از POST /api/v1/auth/switch-context: برابر context انتخاب‌شده

سمت سرور: جدول user_active_context علاوه بر db_uuid ستون db_type (doctor یا clinic) هم دارد که از available_contexts[].type پر می‌شود. بدون آن، هر بار حل‌کردن محیط دو کوئری می‌خواست: اول کلینیک با آن uuid، بعد پزشک. این ستون در پاسخ API ظاهر نمی‌شود و قرارداد frontend را عوض نمی‌کند.

Errors

Code HTTP Description
ERR_AUTH_001 401 Invalid or missing token

POST /api/v1/auth/switch-context

تغییر محیط کاری فعال — باید از لیست available_contexts انتخاب شود.

Permission: AUTH

Request Body

{
  "db_uuid": "clinic-uuid-..."
}
فیلد نوع Required توضیح
db_uuid string (UUID) UUID محیط کاری از لیست available_contexts

Response 200

{
  "success": true,
  "data": {
    "db_uuid": "clinic-uuid-...",
    "db_key": "new-hmac-hash...",
    "context": {
      "type": "clinic",
      "db_uuid": "clinic-uuid-...",
      "name": "کلینیک سلامت",
      "role": "clinic",
      "doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966"
    }
  }
}

Errors

Code HTTP Description
ERR_AUTH_001 401 Missing or invalid token
ERR_AUTH_006 403 db_uuid در لیست context های این کاربر نیست
ERR_VALIDATION_001 422 db_uuid ارسال نشده

Notification Mobile (OTP)

Endpoints for setting/verifying a separate SMS notification number for doctors and clinics. This number receives appointment SMS notifications instead of the account login mobile.

Permission: All 4 endpoints require IS_AUTHENTICATED_FULLY (JWT).


POST /api/v1/notification-mobile/request-otp

Request an OTP code to verify a new notification mobile number.

Permission: AUTH

Request Body

{
  "target": "doctor",
  "new_mobile": "09123456789"
}
Field Type Required Validation
target string doctor or clinic
new_mobile string Format: 09XXXXXXXXX

Response 200

{
  "success": true,
  "data": {
    "message": "کد تأیید ارسال شد",
    "expires_in": 300
  }
}

Errors

Code HTTP Description
ERR_VALIDATION_001 422 Invalid target or mobile format
ERR_NOT_FOUND_001 404 No doctor/clinic profile found for this user

Notes

  • Previous unused OTPs for the entity are deleted before creating a new one
  • OTP is 6-digit, expires in 5 minutes
  • Sends via SMS asynchronously

POST /api/v1/notification-mobile/verify

Verify the OTP and save the notification mobile number.

Permission: AUTH

Request Body

{
  "target": "doctor",
  "otp_code": "123456"
}
Field Type Required
target string doctor or clinic
otp_code string 6-digit code

Response 200

{
  "success": true,
  "data": {
    "notification_mobile": "09123456789",
    "message": "شماره اعلان با موفقیت ذخیره شد"
  }
}

Errors

Code HTTP Description
ERR_VALIDATION_001 422 Invalid target / OTP expired / wrong code
ERR_NOT_FOUND_001 404 No OTP request found or profile not found

GET /api/v1/notification-mobile/{target}

Get the current notification mobile for the authenticated user.

Permission: AUTH
Path param: targetdoctor or clinic

Response 200

{
  "success": true,
  "data": {
    "notification_mobile": "09123456789"
  }
}
  • Returns null for notification_mobile if not set

Errors

Code HTTP Description
ERR_NOT_FOUND_001 404 No profile found for this user

DELETE /api/v1/notification-mobile/{target}

Remove the notification mobile number.

Permission: AUTH
Path param: targetdoctor or clinic

Response 200

{
  "success": true,
  "data": {
    "message": "شماره اعلان حذف شد"
  }
}

Errors

Code HTTP Description
ERR_NOT_FOUND_001 404 No profile found for this user

POST /oauth/logout

Invalidate the refresh token (stored in Redis).

Permission: AUTH

Request Body

{
  "refresh_token": "def50200..."
}
Field Type Required
refresh_token string

Response 200

{
  "success": true,
  "data": {
    "message": "با موفقیت خارج شدید"
  }
}

POST /api/v1/pre-registration

Submit a pre-registration request (doctor or clinic). Public endpoint — no auth required.

Permission: Public

🛡️ ALTCHA: وقتی ALTCHA_ENABLED=true است، فیلد altcha (payload حل‌شده‌ی widget) الزامی است؛ در غیر این صورت 422 با ERR_CAPTCHA_001. رجوع به captcha.md.

Request Body

{
  "type": "independent_doctor",
  "name": "علی احمدی",
  "mobile": "09121234567",
  "info": "متخصص داخلی، ۱۰ سال سابقه"
}
Field Type Required Notes
type string independent_doctor | doctor_with_clinic | clinic_manager
name string min 2 chars. عنوان «دکتر» ابتدای نام (و پیشوندهای تکراری «دکتر دکتر …») هنگام ثبت حذف می‌شود؛ نام تمیز ذخیره و در نمایش دوباره «دکتر» افزوده می‌شود.
mobile string 1015 chars
info string specialty, address, etc.

Type meanings:

Value Roles granted on approval Entities created
independent_doctor ROLE_DOCTOR Doctor
doctor_with_clinic ROLE_DOCTOR + ROLE_CLINIC Doctor + Clinic
clinic_manager ROLE_CLINIC Clinic

Response 200

{
  "success": true,
  "data": { "uuid": "...", "status": "pending" }
}

Error Codes

Code HTTP Meaning
VALIDATION_ERROR 422 Invalid type / mobile / name
DUPLICATE_REQUEST 409 Pending request already exists for this mobile

POST /api/v1/user/otp-login

ورود با کد OTP تأییدشده — فقط کاربران موجود، کاربر جدید ایجاد نمی‌شود.

Permission: PUBLIC

Request Body

{ "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039" }
Field Type Required Description
grant string grant یک‌بارمصرف از verify-code

Response 200

{
  "access_token": "eyJ...",
  "refresh_token": "...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token_expires_in": 2592000
}

Error Codes

Code HTTP Description
ERR_VALIDATION_002 422 grant ارسال نشده
ERR_AUTH_002 400 grant نامعتبر، منقضی یا مصرف‌شده
ERR_AUTH_005 401 کاربری با این شماره یافت نشد
ERR_RATE_LIMIT_001 429 بیش از ۱۰ درخواست در ۵ دقیقه (per-IP)

POST /api/v1/user/change-password

تغییر رمز عبور توسط کاربرِ احرازشده (بدون OTP). رمز فعلی راستی‌آزمایی می‌شود.

Permission: IS_AUTHENTICATED_FULLY

Request Body

{
  "current_password": "oldpass1234",
  "new_password": "newpass1234"
}
Field Type Required Validation
current_password string باید با رمز فعلی مطابقت کند
new_password string حداقل ۸ کاراکتر و متفاوت با رمز فعلی

Response 200

{ "success": true, "data": { "message": "رمز عبور با موفقیت تغییر یافت" } }

Errors

HTTP Code field Description
422 ERR_VALIDATION_001 new_password رمز جدید کوتاه یا برابر رمز فعلی
422 ERR_VALIDATION_001 current_password رمز فعلی نادرست

POST /api/v1/user/reset-password

تغییر رمز عبور با تأیید هویت از طریق OTP.

Permission: PUBLIC

Request Body

{
  "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
  "new_password": "newpass1234"
}
Field Type Required Validation
grant string grant یک‌بارمصرف از verify-code
new_password string حداقل ۸ کاراکتر

Response 200

{
  "success": true,
  "data": { "message": "رمز عبور با موفقیت تغییر یافت" }
}

Error Codes

Code HTTP Description
ERR_VALIDATION_001 422 grant ارسال نشده یا رمز کمتر از ۸ کاراکتر
ERR_AUTH_002 400 grant نامعتبر، منقضی یا مصرف‌شده
ERR_NOT_FOUND_001 404 کاربری با این شماره یافت نشد
ERR_RATE_LIMIT_001 429 بیش از ۵ درخواست در ۶۰ دقیقه (per-IP)