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>
23 KiB
Authentication API
Prefix:
/api/v1/userand/oauth
Permission: All endpoints in this module are PUBLIC (no JWT required) exceptuserinfoandlogout
🛡️ 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: target — doctor or clinic
Response 200
{
"success": true,
"data": {
"notification_mobile": "09123456789"
}
}
- Returns
nullfornotification_mobileif 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: target — doctor 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 | ✅ | 10–15 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) |