Feat add iam auth

This commit is contained in:
mahmood
2026-09-25 19:04:48 +03:30
parent 2de90c6b35
commit 36f239e02c
28 changed files with 2738 additions and 19 deletions
+199
View File
@@ -0,0 +1,199 @@
# نحوه ارتباط gold-bank با سامانه هویت متمرکز (IAM)
این سند **مسیر داده** را توضیح می‌دهد: چه کسی، چه زمانی، چه چیزی را به کجا
می‌فرستد و اگر آن طرف جواب نداد چه اتفاقی می‌افتد.
برای پیکربندی و فعال‌سازی: [`iam-sso.md`](iam-sso.md) ·
مرجع تصویری اندپوینت‌های IAM: `http://<آدرس IAM>/docs`
---
## ۱. خلاصه در یک تصویر
```
پنل ادمین (Vue) apps/api (NestJS) Central IAM
─────────────── ───────────────── ───────────
فرم ورودِ همیشگی
(موبایل + رمز)
│ POST /api/admin/auth/login
├──────────────────────────────▶
│ AuthService.verifyPassword
│ │ POST /api/v1/auth/login
│ │ + client_id/secret + client_ip
│ ├────────────────────────────────▶
│ │◀────────────────────────────────┤
│ │ SUCCESS (sub، sid) │ 401 │ خطا
│ │
│ AdminSession: ثبت jti + sid + sub
│◀─────────────────────────────┤ همان پاسخ همیشگی (api_token, roles)
```
سه نکته که کل طراحی را توضیح می‌دهد:
1. **مرورگر ادمین هرگز به IAM نمی‌رود.** نه ریدایرکت، نه دکمه SSO، نه صفحه جدید.
2. **قالب پاسخ ورود تغییر نکرده است** (`api_token`, `roles`, …)، پس پنل تفاوتی نمی‌بیند.
3. **نگاشت کاربر با `mobile` انجام می‌شود.** هیچ ستونی به `AdminUser` اضافه نشده؛
شناسه‌های IAM روی جدول **جدید** `AdminSession` می‌نشینند.
> **اپ کاربر (`bnk.montzar.ir`) هم متصل است**، ولی از مسیر دیگری: ورودش فقط با کد
> یکبارمصرف است و رمزی ندارد که به IAM فرستاده شود. کد را همین سامانه تولید،
> ارسال و بررسی می‌کند و سپس نتیجه را به IAM اعلام می‌کند
> (`/api/v1/auth/login/delegated-otp`) تا نشست مرکزی، لاگ امنیتی و خروج سراسری
> شامل آن ورود هم بشود. توکن اپ هم مثل توکن پنل `jti` می‌گیرد و در جدول
> `AppSession` ثبت می‌شود، پس قابل ابطال است.
---
## ۲. کانال‌های ارتباطی (همه چیزی که رد و بدل می‌شود)
| # | جهت | مسیر | چه زمانی | چه چیزی می‌رود | اگر خطا بدهد |
| --- | --- | --- | --- | --- | --- |
| ۱ | ما → IAM | `POST /api/v1/auth/login` | هر ورود ادمین | موبایل، رمز، `client_id`، `client_secret`، `client_ip` | طبق `IAM_LOGIN_DELEGATION` (پیش‌فرض: `bcrypt` محلی) |
| ۱.۵ | ما → IAM | `POST /api/v1/auth/login/delegated-otp` | **ورود اپ کاربر** (`/auth/verify-otp`) — این ورود رمز ندارد | موبایل، اعتبارنامه کلاینت، `client_ip`، شماره ماسک‌شده | کاربر آنجا نیست یا سرویس down ⇒ ورود محلی ادامه؛ قفل/غیرفعال/2FA ⇒ ورود رد می‌شود |
| ۲ | ما → IAM | `GET /.well-known/jwks.json` | تأیید توکن خروج سراسری (کش ۲۴ ساعته، `kid` ناشناس ⇒ یک‌بار refetch) | — | اعلان خروج پذیرفته نمی‌شود (۲۰۰ + لاگ) |
| ۳ | **IAM → ما** | `POST /api/iam/backchannel-logout` | خروج سراسری یا ابطال نشست توسط ادمینِ IAM | `logout_token` امضاشده (RS256) | همیشه ۲۰۰؛ دلیل رد فقط در لاگ |
فقط همین سه کانال. جریان مرورگری OAuth در این سامانه استفاده نمی‌شود، چون قرار
است رابط کاربری ورود دست‌نخورده بماند.
---
## ۳. جریان ۱ — ورود ادمین
```
POST /api/admin/auth/login { phone, password }
│
├─ AdminUser با این موبایل هست و فعال است؟ (نه ⇒ ۴۰۱ مبهم، مثل قبل)
│
├─ AuthService.verifyPassword
│ ├─ IAM خاموش ⇒ یک bcrypt.compare (دقیقاً رفتار قبلی، بدون هیچ درخواست شبکه)
│ ├─ IAM تأیید کرد ⇒ { ok: true, identity: { sub, sessionId } }
│ ├─ IAM رد کرد (کاربر آنجا نیست/رمز فرق دارد)
│ │ ├─ fallback ⇒ bcrypt محلی
│ │ └─ exclusive ⇒ ۴۰۱
│ ├─ حساب در IAM قفل است ⇒ **رد قطعی** (قفل IAM دور زده نمی‌شود)
│ ├─ IAM در دسترس نیست / ۴۲۹
│ │ ├─ fallback ⇒ bcrypt محلی
│ │ └─ exclusive ⇒ ۴۰۱ با پیام روشن
│ └─ حساب دو مرحله‌ای است ⇒ پیام روشن (این فرم مرحله کد ندارد)
│
├─ jti تازه (فقط وقتی ردیابی نشست روشن است)
├─ امضای همان JWT همیشگی { sub, mobile, jti? }
├─ AdminSession: { jti, adminUserId, iamSessionId, iamSub, ip, userAgent }
│
└─ پاسخ: همان قالب قبلی
```
**چرا `client_ip` حیاتی است:** محدودیت نرخ IAM روی IP اعمال می‌شود. بدون IP کاربر
نهایی، همه ورودها از IP همین سرور دیده می‌شوند و بعد از چند تلاش ناموفق، **کل**
پنل ادمین ۴۲۹ می‌گیرد. اگر API پشت پراکسی است، `trust proxy` را تنظیم کنید وگرنه
`@Ip()` همیشه IP پراکسی را می‌دهد و همان مشکل برمی‌گردد.
---
## ۴. جریان ۲ — اعتبارسنجی هر درخواست (نشست قابل ابطال)
```
هر درخواست با Bearer توکن
│
├─ JwtStrategy: امضا و انقضا (مثل قبل)
├─ توکن از نوع اپ مشتری نباشد (typ)
│
├─ AdminSessionService.isActive(jti)
│ ├─ ردیابی خاموش ⇒ true، بدون هیچ کوئری (هزینه دقیقاً مثل قبل)
│ ├─ توکن بدون jti (صادرشده پیش از فعال‌سازی) ⇒ true تا انقضا
│ ├─ ردیف AdminSession باطل شده یا وجود ندارد ⇒ ۴۰۱
│ └─ سالم ⇒ ادامه
│
└─ AdminUser + مجوزها از دیتابیس (مثل قبل، تا تغییر دسترسی فوری اثر کند)
```
`AUTH_SESSION_TRACKING`: خالی = همراه `IAM_ENABLED` · `true` = روشن بدون IAM ·
`false` = خاموش (ابطال مرکزی دیگر به اینجا نمی‌رسد).
---
## ۵. جریان ۳ — خروج سراسری (تنها مسیر ورودی از IAM)
```
IAM: خروج کاربر یا «بستن همه نشست‌ها» توسط ادمین
│ logout_token امضاشده با RS256 (application/x-www-form-urlencoded)
▼
POST /api/iam/backchannel-logout ← بدون احراز هویت، عمداً
│
├─ IamLogoutService.verify
│ ├─ alg باید RS256 باشد (alg=none رد می‌شود)
│ ├─ امضا با کلید عمومی JWKS
│ ├─ iss برابر issuer پیکربندی‌شده
│ ├─ aud شامل client_id ما
│ ├─ events شامل رخداد back-channel logout (تا ID Token جای آن نرود)
│ ├─ nonce نباید وجود داشته باشد
│ └─ exp/iat با تحمل ۶۰ ثانیه انحراف ساعت
│
└─ AdminSessionService
├─ sid دارد ⇒ همان نشست باطل می‌شود
└─ فقط sub دارد ⇒ همه نشست‌های آن هویت
⇒ درخواست بعدی با آن توکن ۴۰۱ می‌گیرد
```
پاسخ **همیشه ۲۰۰** است (حتی برای توکن نامعتبر) تا IdP بی‌نهایت تلاش مجدد نکند؛
دلیل رد شدن در لاگ سرویس می‌آید.
آدرسی که باید در IAM برای این کلاینت ثبت شود:
```
backchannel_logout_uri = https://<آدرس این API>/api/iam/backchannel-logout
```
ابطال محلی هم به همین مسیر وصل است: غیرفعال کردن حساب یا تغییر رمز یک ادمین
(`AdminUsersService.update`) نشست‌های او را می‌بندد.
---
## ۶. نگاشت هویت‌ها
| در IAM | در gold-bank | قاعده |
| --- | --- | --- |
| موبایل | `AdminUser.mobile` | تنها معیار نگاشت کاربر — هیچ ستون جدیدی روی این جدول نیست |
| `sub` | `AdminSession.iamSub` | برای خروج سراسریِ «همه نشست‌های این هویت» |
| `sid` | `AdminSession.iamSessionId` | برای ابطال دقیقاً همان نشست |
| نقش‌ها | `UserType` / `Permission` محلی | از IAM **خوانده نمی‌شود**؛ دسترسی‌ها مال همین سامانه است |
**چرا شناسه‌های IAM روی جدول جدید نشستند و نه روی `AdminUser`:** تا فعال کردن این
قابلیت هیچ تغییری در جدول‌های موجود لازم نداشته باشد و برگرداندنش هم به همان
سادگی باشد. اگر روزی فیلد دیگری از IAM لازم شد، همین الگو ادامه یابد.
---
## ۷. رفتار در خطا (خلاصه تصمیم‌ها)
| وضعیت | `fallback` (پیش‌فرض) | `exclusive` |
| --- | --- | --- |
| IAM خاموش/ناقص | یک bcrypt، بدون شبکه — رفتار کاملاً قبلی | همان |
| IAM در دسترس نیست | bcrypt محلی؛ ورود ادمین‌ها قطع نمی‌شود | ۴۰۱ «سرویس هویت در دسترس نیست» |
| کاربر در IAM نیست | bcrypt محلی (مناسب مهاجرت تدریجی) | ۴۰۱ |
| رمز در IAM غلط است | bcrypt محلی | ۴۰۱ |
| حساب در IAM قفل است | **۴۰۱** — قفل IAM دور زده نمی‌شود | ۴۰۱ |
| حساب دو مرحله‌ای است | ۴۰۱ با پیام روشن (این فرم مرحله کد ندارد) | همان |
| ثبت `AdminSession` ناموفق | ورود انجام می‌شود (فقط هشدار لاگ) — بدترین حالت: نشستی که زودتر قابل ابطال نیست | همان |
| توکن خروج نامعتبر | ۲۰۰ + هشدار لاگ؛ هیچ نشستی بسته نمی‌شود | همان |
---
## ۸. بررسی سریع اتصال
```bash
cd apps/api
npx jest src/modules/iam src/modules/auth src/modules/sessions # ۶۱ تست
npx prisma migrate deploy # جدول AdminSession
```
مشکلات پرتکرار:
| نشانه | علت |
| --- | --- |
| بعد از چند ورود همه ۴۲۹ می‌گیرند | `client_ip` نمی‌رسد؛ `trust proxy` تنظیم نشده |
| خروج سراسری بی‌اثر است | `backchannel_logout_uri` ثبت نشده، یا `AUTH_SESSION_TRACKING=false` |
| همه ادمین‌ها ۴۰۱ می‌گیرند | جدول `AdminSession` پاک شده — ردیف نبود یعنی «باطل» |
| `unsupported logout token algorithm` در لاگ | توکن با RS256 امضا نشده (تلاش جعل یا پیکربندی اشتباه IdP) |
| حساب دو مرحله‌ای وارد نمی‌شود | این پنل مرحله «ورود کد» ندارد — بخش ۷ سند `iam-sso.md` |
+206
View File
@@ -0,0 +1,206 @@
# اتصال gold-bank به سامانه هویت متمرکز (Central IAM)
> **نحوه ارتباط (مسیر داده، اندپوینت‌ها، رفتار در خطا):** [`iam-connection.md`](iam-connection.md)
> **مرجع تصویری API سامانه هویت:** `<آدرس IAM>/docs`
این سند توضیح می‌دهد ورود ادمین چطور می‌تواند به‌جای رمز محلی، از سامانه هویت
سازمانی اعتبارسنجی شود — **بدون هیچ تغییری در رابط کاربری**.
---
## ۱. خلاصه تصمیم
| موضوع | تصمیم |
| --- | --- |
| رابط کاربری | **هیچ تغییری نمی‌کند**؛ نه دکمه SSO، نه ریدایرکت، نه صفحه جدید |
| جای اتصال | فقط همان یک نقطه‌ای که رمز بررسی می‌شد (`AuthService.login`) |
| پیش‌فرض | خاموش (`IAM_ENABLED=false`) — رفتار سامانه دقیقاً مثل امروز |
| در دسترس نبودن IAM | بازگشت خودکار به بررسی رمز محلی ⇒ ورود ادمین‌ها قطع نمی‌شود |
| تغییر دیتابیس | دو جدول **جدید** (`AdminSession`، `AppSession`) برای ابطال نشست؛ هیچ جدول موجودی تغییر نکرده |
| ورود مشتری (OTP پیامکی) | کد همچنان اینجا تولید و بررسی می‌شود؛ نتیجه به IAM اعلام می‌گردد تا نشست و خروج سراسری مرکزی شود |
جریان ورود ادمین پس از فعال شدن:
```
پنل ادمین ──POST /api/admin/auth/login──▶ API (NestJS)
(همان فرم و همان بدنه) │
├─ IAM روشن؟ ──خیر──▶ bcrypt محلی (رفتار فعلی)
│
└─بله─▶ POST {IAM}/api/v1/auth/login
│
┌─────────────────┴─────────────────┐
تأیید شد رد/خطا/در دسترس نبود
│ │
صدور همان JWT محلی fallback ⇒ bcrypt محلی
exclusive ⇒ رد ورود
```
پاسخ اندپوینت در هر دو حالت **دقیقاً یکسان** است (`api_token`, `roles`, …)،
بنابراین پنل ادمین هیچ تفاوتی نمی‌بیند.
---
## ۲. فایل‌ها
| مسیر | نقش |
| --- | --- |
| `apps/api/src/modules/iam/iam.service.ts` | ارتباط با IAM و نگاشت خطاها |
| `apps/api/src/modules/iam/iam-logout.service.ts` | تأیید توکن خروج سراسری با JWKS (بدون وابستگی جدید) |
| `apps/api/src/modules/iam/iam.controller.ts` | دریافت اعلان خروج سراسری |
| `apps/api/src/modules/iam/iam.module.ts` | ماژول Global (تا زمان روشن شدن، بی‌اثر) |
| `apps/api/src/modules/sessions/admin-session.service.ts` | نشست‌های قابل ابطال (jti) |
| `apps/api/src/modules/sessions/admin-session.module.ts` | ماژول Global نشست‌ها |
| `prisma/migrations/20260925120000_admin_session/` | جدول `AdminSession` |
| `apps/api/src/modules/**/*.spec.ts` | تست هر چهار سرویس بالا |
تغییر روی کد موجود، حداقلی است:
- `auth.service.ts` — یک `bcrypt.compare` به متد `verifyPassword` منتقل شد و توکن
صادرشده (در صورت روشن بودن ردیابی نشست) ثبت می‌شود
- `auth.controller.ts` — افزودن `@Ip()` و `user-agent`
- `jwt.strategy.ts` — یک بررسی «نشست باطل نشده باشد» (در حالت خاموش، بدون کوئری)
- `admin-users.service.ts` — غیرفعال کردن حساب یا تغییر رمز، نشست‌ها را می‌بندد
- `app.module.ts` — ثبت `IamModule` و `AdminSessionModule`
- `prisma/schema.prisma` — فقط مدل جدید `AdminSession` (+ رابطه روی `AdminUser`)
---
## ۳. فعال‌سازی
### گام ۱ — ثبت این سامانه به‌عنوان کلاینت در IAM
باید کلاینت از نوع `confidential` و **`is_trusted: true`** باشد؛ فقط چنین
کلاینتی اجازه «ورود واگذارشده» دارد.
```bash
curl -X POST https://auth.example.com/api/v1/admin/clients \
-H "Authorization: Bearer $IAM_ADMIN_TOKEN" \
-H 'content-type: application/json' \
-d '{
"client_id": "goldbank_api",
"client_name": "سامانه بانک طلا",
"client_type": "confidential",
"redirect_uris": ["https://goldbank.example.com/unused"],
"allowed_scopes": ["openid", "profile", "email"],
"is_trusted": true
}'
```
> `redirect_uris` در این حالت استفاده نمی‌شود (جریان مرورگری نداریم) ولی
> IAM آن را اجباری می‌داند؛ یک آدرس معتبر بگذارید.
### گام ۲ — تنظیم `.env`
```dotenv
IAM_ENABLED=true
IAM_ISSUER=https://auth.example.com
IAM_CLIENT_ID=goldbank_api
IAM_CLIENT_SECRET=<راز دریافتی>
IAM_TENANT_SLUG=montzar
IAM_LOGIN_DELEGATION=fallback
```
همین. سرویس را ری‌استارت کنید؛ هیچ مایگریشنی لازم نیست.
### گام ۳ — بازگشت به عقب
`IAM_ENABLED=false` و ری‌استارت. تمام.
---
## ۴. حالت‌های `IAM_LOGIN_DELEGATION`
| مقدار | رفتار |
| --- | --- |
| `off` | IAM اصلاً در ورود دخالت نمی‌کند |
| `fallback` (پیش‌فرض) | اول IAM؛ اگر کاربر آنجا نبود یا سرویس در دسترس نبود، رمز محلی بررسی می‌شود — مناسب مهاجرت تدریجی |
| `exclusive` | فقط IAM؛ رمز محلی پذیرفته نمی‌شود و خرابی IAM یعنی توقف ورود |
**پیشنهاد مهاجرت:** با `fallback` شروع کنید، بعد از انتقال همه ادمین‌ها به IAM
و اطمینان از پایداری، به `exclusive` بروید.
---
## ۵. نکات عملیاتی
### محدودیت نرخ
IAM روی «IP» و «هویت» محدودیت نرخ دارد. چون درخواست‌ها از سرور API می‌آیند،
IP کاربر نهایی با فیلد `client_ip` فرستاده می‌شود؛ در غیر این صورت پس از چند
ورود، کل پنل ادمین ۴۲۹ می‌گرفت.
اگر API پشت پراکسی است، مطمئن شوید Nest `trust proxy` دارد وگرنه `@Ip()`
همیشه IP پراکسی را می‌دهد و همان مشکل برمی‌گردد.
### احراز دو مرحله‌ای
فرم ورود این پنل فقط «موبایل + رمز» است و **هیچ مرحله‌ی ورود کدی ندارد**؛ پس جایی
برای گرفتن کد عامل دوم وجود ندارد و این مسیر نمی‌تواند آن را تکمیل کند. ورود چنین
حسابی با پیام روشن رد می‌شود (نه خطای مرموز). سه گزینه:
1. برای حساب‌هایی که از این پنل وارد می‌شوند 2FA را در IAM فعال نکنید (ساده‌ترین)؛
2. مرحله‌ی «ورود کد» را به همین فرم اضافه کنید؛ آن‌وقت می‌توان مثل سامانه «همکار»
کد را واسطه کرد یا با کانال خودِ سامانه فرستاد
(`/api/v1/auth/mfa/verify` و `/api/v1/auth/mfa/delegate` در IAM آماده‌اند)؛
3. یا جریان مرورگری SSO را برای این پنل به کار بگیرید که خودش صفحه کد را می‌دهد.
> در سامانه «همکار» این محدودیت وجود ندارد، چون ورود کاربر عادی آنجا از قبل مرحله‌ی
> «ورود کد» (پیامک) دارد و همان مرحله برای عامل دوم استفاده می‌شود.
### ابطال نشست
توکن ادمین خودبسنده و ۱۲ ساعته است، پس تا پیش از این **هیچ‌چیز** نمی‌توانست نشست را
زودتر تمام کند: نه غیرفعال کردن حساب، نه خروج سراسری در IAM. حالا هر توکن صادرشده
یک `jti` دارد که در جدول `AdminSession` ثبت می‌شود و درخواست بعدیِ نشستِ باطل‌شده
۴۰۱ می‌گیرد.
```
IAM: ابطال نشست / خروج سراسری
│ logout_token (RS256، امضاشده)
▼
POST /api/iam/backchannel-logout ← این آدرس را در IAM به‌عنوان
│ backchannel_logout_uri کلاینت ثبت کنید
├─ تأیید امضا با JWKS، بررسی iss/aud/events و نبودن nonce
└─ ابطال نشست‌های متناظر (sid، یا همه نشست‌های sub)
│
▼
درخواست بعدی با آن توکن ⇒ ۴۰۱
```
کلید تنظیم: `AUTH_SESSION_TRACKING`
| مقدار | رفتار |
| --- | --- |
| خالی (پیش‌فرض) | همراه `IAM_ENABLED` روشن/خاموش می‌شود — تا ابطال مرکزی بی‌اثر نماند |
| `true` | روشن، حتی بدون IAM (ابطال محلی: غیرفعال کردن حساب، تغییر رمز) |
| `false` | خاموش؛ دقیقاً رفتار قبلی (بدون `jti`، بدون کوئری اضافه) و ابطال مرکزی بی‌اثر |
نکته‌های عملی:
- توکن‌هایی که **پیش از** روشن کردن این قابلیت صادر شده‌اند `jti` ندارند و تا انقضا
معتبر می‌مانند؛ یعنی روشن کردن این کلید کسی را یک‌باره بیرون نمی‌اندازد.
- ردیف نشستِ گم‌شده «باطل» تلقی می‌شود، پس پاک کردن جدول یعنی خروج همه.
- اندپوینت خروج سراسری عمداً بدون احراز هویت است (اعتبارش از امضای توکن می‌آید) و
همیشه ۲۰۰ برمی‌گرداند تا IdP بی‌نهایت تلاش مجدد نکند؛ دلیل رد شدن در لاگ می‌آید.
- تنها تغییر شِما یک جدول **جدید** است؛ هیچ جدول موجودی ستون نگرفته. مایگریشن:
`pnpm --filter @gold-bank/api exec prisma migrate deploy`.
### لاگ امنیتی
هر ورود واگذارشده در IAM با IP واقعی کاربر و `client_id` این سامانه ثبت
می‌شود، پس گزارش‌های امنیتی متمرکز کامل باقی می‌مانند.
---
## ۶. تست
```bash
cd apps/api
pnpm test # ۱۱۵ تست، بدون دیتابیس و بدون شبکه
pnpm test -- iam-logout.service # تأیید توکن خروج سراسری با کلید RSA واقعی
pnpm test -- admin-session # نشست‌های قابل ابطال
```
تست‌ها بدون دیتابیس و بدون شبکه اجرا می‌شوند و مهم‌ترین چیزی که تضمین می‌کنند
این است: **با خاموش بودن قابلیت، هیچ درخواستی به IAM نمی‌رود و ورود دقیقاً مثل
قبل کار می‌کند**؛ و با روشن بودن، خرابی IAM ورود ادمین را قطع نمی‌کند.