Files
clinicpro/docs/api/auth.md
T
hamedandClaude Opus 5 2e0888e0ef refactor(tenant): give every table one spelling of the tenant pair
Phase 3 of the tenant-marking series. The same concept was written four ways,
and the Doctrine filter arriving in phase 4 keys on the field name — so the
tables using a different spelling would have been skipped silently, which is
exactly the leak this work exists to prevent.

- discount_rules: owner_type/owner_id renamed to entity_type/entity_id. Pure
  rename, no data moves.
- doctor_secretaries: owner_type plus a nullable clinic_id replaced by the
  shared pair. The environment now comes from the clinic argument alone, so the
  inconsistent combination (owner_type='clinic', clinic_id=NULL) can no longer
  be constructed, and the redundant constructor parameter is gone.
- user_active_context: added db_type, so resolving an environment is one lookup
  instead of "try clinics, then try doctors". Filled from the type already
  present in available_contexts.
- entity_type is VARCHAR(10) in all twenty tenant tables; four of them were 20.

Behaviour change, the only one in this series: the doctor_secretaries unique key
went from (doctor_id, secretary_id, owner_type) to (doctor_id, secretary_id,
entity_type, entity_id). With clinic_id outside the key, one secretary could not
be assigned to the same doctor in two clinics — the second row collided on
owner_type='clinic'. The duplicate check in SecretaryController had the same
blind spot and would have rejected the request before the database saw it; both
are fixed together.

Correcting an assumption from the phase-3 plan: mobile_verification_otp.entity_type
really is a tenant pair. NotificationMobileController validates the target against
['doctor','clinic'] and stores that entity's id, so the column was normalised with
the rest rather than treated as unrelated.

TenantOwnedTrait gained assignTenantPair() for callers that resolved the pair as
scalars and hold no entity — building an EntityContext from scalars would produce
one where isClinic() is true but ->clinic is null, breaking consumers silently.

tests/ApiTestCase::createUser now retries on a duplicate mobile. db_test is never
reset and already holds ~38k users, so the 9-digit random draw collided often
enough to fail unrelated tests a few percent of runs.

Tests: 830 passing. PHPStan reports no new errors on the changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:56:57 +03:30

23 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 | 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 همه محیط‌های کاری قابل انتخاب

فیلد 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

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

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

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

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

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

نکته 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)