# 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](captcha.md). --- ## POST `/api/v1/user/send-code` Send OTP code to mobile number. **Permission:** `PUBLIC` ### Request Body ```json { "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` ```json { "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 ```json { "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` ```json { "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 ```json { "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039", "real_name": "علی احمدی" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` | | `real_name` | string | ❌ | User's full name | ### Response `201` ```json { "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 ```json { "mobile_number": "09123456789", "password": "mypassword" } ``` | Field | Type | Required | |-------|------|----------| | `mobile_number` | string | ✅ | | `password` | string | ✅ | ### Response `200` ```json { "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 ```json { "grant_type": "mobile", "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `grant_type` | string | ✅ | Must be `"mobile"` | | `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` (عمر ۱۲۰ ثانیه) | ### Response `200` ```json { "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 ```json { "refresh_token": "def50200..." } ``` > **قابل استفاده‌ی مجدد:** refresh token تا انقضای TTL خود معتبر است و در پاسخ **بدون تغییر** برگردانده می‌شود (یک‌بارمصرف/چرخشی **نیست** — سایت عمومی در هر render سمت‌سرور refresh می‌زند و نمی‌تواند توکن چرخش‌یافته را ذخیره کند). کاربر **معلق** (`status != 1`) نمی‌تواند refresh کند (`401`). ### Response `200` ```json { "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 ``` ### Response `200` ```json { "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": "clinic", "doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966" } ] } } ``` | فیلد | نوع | توضیح | |------|-----|-------| | `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 | همه محیط‌های کاری قابل انتخاب | **قانون `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 انتخاب‌شده ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Invalid or missing token | --- ## POST `/api/v1/auth/switch-context` تغییر محیط کاری فعال — باید از لیست `available_contexts` انتخاب شود. **Permission:** `AUTH` ### Request Body ```json { "db_uuid": "clinic-uuid-..." } ``` | فیلد | نوع | Required | توضیح | |------|-----|----------|-------| | `db_uuid` | string (UUID) | ✅ | UUID محیط کاری از لیست `available_contexts` | ### Response `200` ```json { "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 ```json { "target": "doctor", "new_mobile": "09123456789" } ``` | Field | Type | Required | Validation | |-------|------|----------|------------| | `target` | string | ✅ | `doctor` or `clinic` | | `new_mobile` | string | ✅ | Format: `09XXXXXXXXX` | ### Response `200` ```json { "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 ```json { "target": "doctor", "otp_code": "123456" } ``` | Field | Type | Required | |-------|------|----------| | `target` | string | ✅ `doctor` or `clinic` | | `otp_code` | string | ✅ 6-digit code | ### Response `200` ```json { "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` ```json { "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:** `target` — `doctor` or `clinic` ### Response `200` ```json { "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 ```json { "refresh_token": "def50200..." } ``` | Field | Type | Required | |-------|------|----------| | `refresh_token` | string | ❌ | ### Response `200` ```json { "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](captcha.md). ### Request Body ```json { "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` ```json { "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 ```json { "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` | ### Response `200` ```json { "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 ```json { "current_password": "oldpass1234", "new_password": "newpass1234" } ``` | Field | Type | Required | Validation | |-------|------|----------|------------| | `current_password` | string | ✅ | باید با رمز فعلی مطابقت کند | | `new_password` | string | ✅ | حداقل ۸ کاراکتر و متفاوت با رمز فعلی | ### Response `200` ```json { "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 ```json { "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039", "new_password": "newpass1234" } ``` | Field | Type | Required | Validation | |-------|------|----------|------------| | `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` | | `new_password` | string | ✅ | حداقل ۸ کاراکتر | ### Response `200` ```json { "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) |