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

724 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <token>
```
### 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": "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
```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 | ✅ | 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`
```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) |