From 36f239e02cefae54c42f098e4ff08662b7800222 Mon Sep 17 00:00:00 2001 From: mahmood Date: Fri, 25 Sep 2026 19:04:48 +0330 Subject: [PATCH] Feat add iam auth --- .cursor/rules/40-iam-sso.mdc | 81 ++++++ .env.example | 38 +++ .../migration.sql | 38 +++ .../20260925150000_app_session/migration.sql | 33 +++ apps/api/prisma/schema.prisma | 93 ++++++ apps/api/src/app.module.ts | 5 + .../admin-users/admin-users.service.ts | 16 +- apps/api/src/modules/auth/auth.controller.ts | 9 +- .../api/src/modules/auth/auth.service.spec.ts | 271 ++++++++++++++++++ apps/api/src/modules/auth/auth.service.ts | 91 +++++- apps/api/src/modules/auth/jwt.strategy.ts | 20 +- .../modules/customer-auth/app-principal.ts | 7 + .../customer-auth/customer-auth-iam.spec.ts | 166 +++++++++++ .../customer-auth/customer-auth.service.ts | 117 +++++++- .../customer-auth/customer-jwt.strategy.ts | 12 +- .../modules/customer-auth/master-otp.spec.ts | 6 + .../modules/iam/iam-logout.service.spec.ts | 161 +++++++++++ .../api/src/modules/iam/iam-logout.service.ts | 170 +++++++++++ apps/api/src/modules/iam/iam.controller.ts | 87 ++++++ apps/api/src/modules/iam/iam.module.ts | 19 ++ apps/api/src/modules/iam/iam.service.spec.ts | 213 ++++++++++++++ apps/api/src/modules/iam/iam.service.ts | 249 ++++++++++++++++ .../modules/sessions/admin-session.module.ts | 19 ++ .../sessions/admin-session.service.spec.ts | 164 +++++++++++ .../modules/sessions/admin-session.service.ts | 137 +++++++++ .../modules/sessions/app-session.service.ts | 130 +++++++++ docs/iam-connection.md | 199 +++++++++++++ docs/iam-sso.md | 206 +++++++++++++ 28 files changed, 2738 insertions(+), 19 deletions(-) create mode 100644 .cursor/rules/40-iam-sso.mdc create mode 100644 apps/api/prisma/migrations/20260925120000_admin_session/migration.sql create mode 100644 apps/api/prisma/migrations/20260925150000_app_session/migration.sql create mode 100644 apps/api/src/modules/auth/auth.service.spec.ts create mode 100644 apps/api/src/modules/customer-auth/customer-auth-iam.spec.ts create mode 100644 apps/api/src/modules/iam/iam-logout.service.spec.ts create mode 100644 apps/api/src/modules/iam/iam-logout.service.ts create mode 100644 apps/api/src/modules/iam/iam.controller.ts create mode 100644 apps/api/src/modules/iam/iam.module.ts create mode 100644 apps/api/src/modules/iam/iam.service.spec.ts create mode 100644 apps/api/src/modules/iam/iam.service.ts create mode 100644 apps/api/src/modules/sessions/admin-session.module.ts create mode 100644 apps/api/src/modules/sessions/admin-session.service.spec.ts create mode 100644 apps/api/src/modules/sessions/admin-session.service.ts create mode 100644 apps/api/src/modules/sessions/app-session.service.ts create mode 100644 docs/iam-connection.md create mode 100644 docs/iam-sso.md diff --git a/.cursor/rules/40-iam-sso.mdc b/.cursor/rules/40-iam-sso.mdc new file mode 100644 index 0000000..6fae8d2 --- /dev/null +++ b/.cursor/rules/40-iam-sso.mdc @@ -0,0 +1,81 @@ +--- +description: اتصال ورود ادمین به سامانه هویت متمرکز (IAM) — مرزها و قواعدی که نباید شکسته شوند +globs: +alwaysApply: true +--- + +# اتصال به سامانه هویت متمرکز (Central IAM) + +مستندها: +- `docs/iam-connection.md` — **نحوه ارتباط**: سه کانال داده و رفتار در خطا +- `docs/iam-sso.md` — پیکربندی، ابطال نشست، محدودیت‌ها +- مرجع تصویری اندپوینت‌های IAM: `/docs` (Scalar) + +## اصل غیرقابل‌مذاکره + +این قابلیت **افزایشی** است. با `IAM_ENABLED=false` (پیش‌فرض) سامانه باید دقیقاً +مثل قبل رفتار کند: یک `bcrypt.compare` محلی و هیچ درخواست شبکه‌ای. + +- **هیچ تغییری در رابط کاربری نیست.** نه دکمه SSO، نه ریدایرکت، نه صفحه جدید. + ادمین مثل همیشه موبایل و رمز را در همین پنل وارد می‌کند. +- پاسخ `POST /api/admin/auth/login` در هر دو حالت یکسان است (`api_token`, + `roles`, …) پس پنل ادمین تفاوتی نمی‌بیند. + +## نقشه فایل‌ها + +| مسیر | نقش | +| --- | --- | +| `apps/api/src/modules/iam/iam.service.ts` | ارتباط با IAM و نگاشت خطاها | +| `apps/api/src/modules/iam/iam.module.ts` | ماژول Global (بی‌اثر تا زمان روشن شدن) | +| `apps/api/src/modules/auth/auth.service.ts` | متد `verifyPassword` — تنها نقطه اتصال ورود | +| `apps/api/src/modules/sessions/admin-session.service.ts` | نشست‌های قابل ابطال (`jti`) | +| `apps/api/src/modules/iam/iam-logout.service.ts` | تأیید توکن خروج سراسری (JWKS، RS256) | +| `apps/api/src/modules/iam/iam.controller.ts` | `POST /api/iam/backchannel-logout` | + +## قواعد سخت + +1. **هر نتیجه مبهم باید به `bcrypt` محلی برگردد.** سرویس در دسترس نیست، کاربر + آنجا نیست، یا ۴۲۹ گرفتیم ⇒ رمز محلی بررسی شود. فقط `exclusive` اجازه رد + کردن دارد. خرابی IAM هرگز نباید ادمین‌ها را بیرون بیندازد. + +2. **حساب قفل‌شده، «نتیجه قطعی» است نه خطا.** `account_locked` نباید به + `bcrypt` محلی برگردد وگرنه قفلِ IAM بی‌اثر می‌شود. + +3. **`client_ip` همیشه فرستاده می‌شود.** بدون آن، محدودیت نرخِ IAM روی IP همین + سرور اعمال می‌شود و بعد از چند ورود کل پنل ادمین ۴۲۹ می‌گیرد. اگر API پشت + پراکسی رفت، `trust proxy` را تنظیم کن وگرنه `@Ip()` همیشه IP پراکسی است. + +4. **هیچ جدول موجودی تغییر نمی‌کند.** نگاشت کاربر با همان `mobile` انجام می‌شود و + `iamSub`/`iamSessionId` روی جدول **جدید** `AdminSession` می‌نشینند، نه روی + `AdminUser`. اگر روزی چیزی از IAM لازم شد، همین الگو را نگه دار. + +5. **ابطال نشست دو شرط دارد و هر دو لازم است:** توکن باید `jti` داشته باشد و ردیف + `AdminSession` باید موجود و باطل‌نشده باشد. با خاموش بودن ردیابی + (`AUTH_SESSION_TRACKING=false` یا IAM خاموش) نه `jti` صادر می‌شود، نه کوئری + اضافه‌ای در مسیر احراز هویت اجرا می‌شود — یعنی دقیقاً رفتار و هزینه قبلی. + توکن بدون `jti` هرگز نباید رد شود، وگرنه روشن کردن این کلید همه را بیرون می‌اندازد. + +6. **اندپوینت خروج سراسری بدون احراز هویت است و باید بماند.** اعتبارش از امضای + `logout_token` می‌آید: `iss`، `aud`، `events`، نبودن `nonce` و `exp` همه بررسی + می‌شوند. هیچ‌یک از این بررسی‌ها را برای «راحتی تست» حذف نکن. پاسخ همیشه ۲۰۰ است + تا IdP بی‌نهایت تلاش مجدد نکند؛ دلیل رد شدن فقط لاگ می‌شود. + +7. **فرم ورود این پنل مرحله «ورود کد» ندارد**، پس حساب دو مرحله‌ای در IAM از این + مسیر قابل تکمیل نیست و با پیام روشن رد می‌شود. تا وقتی آن مرحله ساخته نشده، + تلاش برای تکمیل 2FA در بک‌اند بی‌معناست. + +8. **ورود اپ کاربر رمز ندارد**، پس از `verifyCredentials` استفاده نمی‌شود؛ بعد از + بررسی موفق کد، `IamService.attestOtpLogin` فراخوانی می‌شود. قاعده خطا: «کاربر + آنجا نیست» و «سرویس در دسترس نیست» ⇒ ورود ادامه می‌یابد؛ «قفل/غیرفعال/دو + مرحله‌ای» ⇒ ورود رد می‌شود. کد اضطراری تحویل (`OTP_MASTER_CODE`) هرگز به IAM + اعلام نمی‌شود. + +9. پیکربندی ناقص = خاموش. `IamService.enabled` وجود `issuer`, `client_id` و + `client_secret` را هم شرط می‌کند (با `envOr` که مقدار خالی را unset می‌شمارد). + +## قبل از تحویل + +```bash +cd apps/api && npx jest src/modules/iam src/modules/auth src/modules/sessions +# و در استقرار: prisma migrate deploy (جدول AdminSession) +``` diff --git a/.env.example b/.env.example index ba292b0..f98e1c3 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,44 @@ JWT_CUSTOMER_SECRET= # Customer token lifetime (default 30d). JWT_CUSTOMER_EXPIRES_IN= +# --------------------------------------------------------------------------- +# Central IAM — delegated admin login (docs/iam-sso.md) +# --------------------------------------------------------------------------- +# OFF by default. When off, nothing changes: admin login stays a local bcrypt +# check and no request ever leaves this server. +# +# When on, the admin's phone + password are verified against the central +# identity service instead. There is NO UI change: no SSO button, no redirect. +# If the identity service is unreachable or does not know the account, the +# local password check still runs (see IAM_LOGIN_DELEGATION). +IAM_ENABLED=false +IAM_ISSUER= +# The client must be registered there as confidential AND trusted. +IAM_CLIENT_ID= +IAM_CLIENT_SECRET= +# One of the two; tenant_id wins when both are set. +IAM_TENANT_ID= +IAM_TENANT_SLUG= +# off | fallback (default, safest) | exclusive (local password no longer accepted) +IAM_LOGIN_DELEGATION=fallback +IAM_HTTP_TIMEOUT_MS=8000 + +# Revocable admin sessions (AdminSession table). +# +# An admin token is self-contained and lives for JWT_EXPIRES_IN (12h), so until +# this existed nothing could end a session early — not disabling the account, not +# a central sign-out. Every issued token now carries a `jti` recorded in the +# database, and a revoked row is refused on the next request. +# +# Unset = follows IAM_ENABLED (on with the IAM, so central revocation reaches +# here). `true` turns it on without the IAM; `false` opts out of the per-request +# lookup and central revocation then cannot end a session here. +AUTH_SESSION_TRACKING= + +# To let the IAM end sessions here, register this path on the client there as its +# backchannel_logout_uri: +# https:///api/iam/backchannel-logout + # --------------------------------------------------------------------------- # ⚠ DELIVERY ONLY — REMOVE BEFORE THE REAL RELEASE (D20 / ISSUES REL-01) # --------------------------------------------------------------------------- diff --git a/apps/api/prisma/migrations/20260925120000_admin_session/migration.sql b/apps/api/prisma/migrations/20260925120000_admin_session/migration.sql new file mode 100644 index 0000000..fa6ff7e --- /dev/null +++ b/apps/api/prisma/migrations/20260925120000_admin_session/migration.sql @@ -0,0 +1,38 @@ +-- Revocable admin sessions. +-- +-- Until now an admin-panel token was purely self-contained: once signed it was +-- valid for its full 12h and nothing could cut it short — not deactivating the +-- account, not a central "sign out everywhere". Connecting the central IAM made +-- that gap concrete, because an IAM session revocation had no effect on a token +-- this API had already issued. +-- +-- Each issued token now carries a `jti` recorded here, and JwtStrategy rejects a +-- token whose row is revoked. Rows are only written (and only enforced) while +-- session tracking is enabled, so a deployment that leaves it off keeps exactly +-- the previous behaviour and the previous query count. +CREATE TABLE "AdminSession" ( + "id" TEXT NOT NULL, + "jti" TEXT NOT NULL, + "adminUserId" TEXT NOT NULL, + "iamSessionId" TEXT, + "iamSub" TEXT, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3), + "lastSeenAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "revokedReason" TEXT, + + CONSTRAINT "AdminSession_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AdminSession_jti_key" ON "AdminSession"("jti"); +CREATE INDEX "AdminSession_adminUserId_idx" ON "AdminSession"("adminUserId"); +CREATE INDEX "AdminSession_iamSessionId_idx" ON "AdminSession"("iamSessionId"); +CREATE INDEX "AdminSession_iamSub_idx" ON "AdminSession"("iamSub"); +CREATE INDEX "AdminSession_expiresAt_idx" ON "AdminSession"("expiresAt"); + +ALTER TABLE "AdminSession" + ADD CONSTRAINT "AdminSession_adminUserId_fkey" + FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260925150000_app_session/migration.sql b/apps/api/prisma/migrations/20260925150000_app_session/migration.sql new file mode 100644 index 0000000..3e61501 --- /dev/null +++ b/apps/api/prisma/migrations/20260925150000_app_session/migration.sql @@ -0,0 +1,33 @@ +-- Revocable app sessions (customer app and sales-expert app). +-- +-- The admin panel got this in 20260925120000_admin_session; the app token family +-- had the same gap: a 30-day self-contained token that nothing could end early. +-- That matters more here, because the app's login is OTP-only — there is no +-- password an operator could change to cut access. +-- +-- No foreign key on purpose: an app token's subject is a Customer for a customer +-- and an AdminUser for a sales expert (D21), which one FK cannot express. +CREATE TABLE "AppSession" ( + "id" TEXT NOT NULL, + "jti" TEXT NOT NULL, + "subjectId" TEXT NOT NULL, + "subjectType" TEXT NOT NULL, + "mobile" TEXT, + "iamSessionId" TEXT, + "iamSub" TEXT, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3), + "lastSeenAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "revokedReason" TEXT, + + CONSTRAINT "AppSession_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AppSession_jti_key" ON "AppSession"("jti"); +CREATE INDEX "AppSession_subjectId_idx" ON "AppSession"("subjectId"); +CREATE INDEX "AppSession_iamSessionId_idx" ON "AppSession"("iamSessionId"); +CREATE INDEX "AppSession_iamSub_idx" ON "AppSession"("iamSub"); +CREATE INDEX "AppSession_expiresAt_idx" ON "AppSession"("expiresAt"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 18cefa4..0fc7797 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -184,6 +184,11 @@ model AdminUser { /// Only ever set for role = SALES_EXPERT (D17). salesExpertProfile SalesExpertProfile? + /// Issued admin-panel tokens, recorded only while session tracking is on + /// (AUTH_SESSION_TRACKING, or implicitly whenever the IAM is enabled) so a + /// token can be revoked before it expires. See AdminSession. + sessions AdminSession[] + @@index([role]) @@index([userTypeId]) @@index([avatarFileId]) @@ -191,6 +196,94 @@ model AdminUser { @@index([lastName]) } +/// An issued APP token (customer app / sales-expert app), so it can be revoked +/// before it expires — the counterpart of AdminSession for the other token family. +/// +/// WHY A SEPARATE TABLE AND NO FOREIGN KEY +/// --------------------------------------- +/// An app token's `sub` is a Customer id for a customer and an **AdminUser id** +/// for a sales expert (D21). One FK cannot point at two tables, so the subject is +/// stored as a plain id plus its type. The rows are short-lived operational data, +/// not part of the accounting model, so the missing FK costs nothing here. +model AppSession { + id String @id @default(cuid()) + + /// The token's `jti` claim. Unique: one row per issued token. + jti String @unique + + /// Customer.id, or AdminUser.id when subjectType is "sales-expert". + subjectId String + subjectType String + + mobile String? + + /// Set when the login was confirmed by the central IAM: its session id (`sid`) + /// and stable user id (`sub`), which is what a back-channel logout arrives with. + iamSessionId String? + iamSub String? + + ipAddress String? + userAgent String? + + createdAt DateTime @default(now()) + expiresAt DateTime? + lastSeenAt DateTime? + + revokedAt DateTime? + revokedReason String? + + @@index([subjectId]) + @@index([iamSessionId]) + @@index([iamSub]) + @@index([expiresAt]) +} + +/// An issued admin-panel JWT, so it can be revoked before it expires. +/// +/// WHY THIS EXISTS +/// --------------- +/// The admin token is self-contained and valid for 12h, which means nothing — +/// not disabling the account, not a central "sign out everywhere" — could cut a +/// session short: the token kept working until it expired. With the central IAM +/// in the loop that gap became visible (an IAM session revocation had no effect +/// here), so every issued token now carries a `jti` recorded on this table and +/// JwtStrategy refuses a token whose row is revoked. +/// +/// Rows are written ONLY while tracking is enabled, and JwtStrategy only +/// enforces them while it is enabled, so a deployment that does not want the +/// extra lookup behaves exactly as before. +model AdminSession { + id String @id @default(cuid()) + + /// The token's `jti` claim. Unique: one row per issued token. + jti String @unique + + adminUserId String + adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade) + + /// Set when the login was verified by the IAM: its session id (`sid`) and the + /// stable user id (`sub`) there. Back-channel logout arrives keyed by these — + /// deliberately here rather than as columns on AdminUser, so enabling the + /// feature needs no change to an existing table. + iamSessionId String? + iamSub String? + + ipAddress String? + userAgent String? + + createdAt DateTime @default(now()) + expiresAt DateTime? + lastSeenAt DateTime? + + revokedAt DateTime? + revokedReason String? + + @@index([adminUserId]) + @@index([iamSessionId]) + @@index([iamSub]) + @@index([expiresAt]) +} + /// The sales-expert fields legacy kept on the `customers` row (type=1) and that /// were lost when a sales expert became an AdminUser (D17). /// diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6588782..1faf14f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,8 @@ import { OrdersModule } from './modules/orders/orders.module'; import { AccountingModule } from './modules/accounting/accounting.module'; import { PricingModule } from './modules/pricing/pricing.module'; import { AuthModule } from './modules/auth/auth.module'; +import { IamModule } from './modules/iam/iam.module'; +import { AdminSessionModule } from './modules/sessions/admin-session.module'; import { AdminTableModule } from './modules/admin-table/admin-table.module'; import { AdminOrdersModule } from './modules/admin-orders/admin-orders.module'; import { AdminCitiesModule } from './modules/admin-cities/admin-cities.module'; @@ -32,6 +34,9 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module'; @Module({ imports: [ + // Inert unless IAM_ENABLED=true — see modules/iam/README.md + IamModule, + AdminSessionModule, PrismaModule, AuthModule, AdminTableModule, diff --git a/apps/api/src/modules/admin-users/admin-users.service.ts b/apps/api/src/modules/admin-users/admin-users.service.ts index db750ea..b442e1e 100644 --- a/apps/api/src/modules/admin-users/admin-users.service.ts +++ b/apps/api/src/modules/admin-users/admin-users.service.ts @@ -3,6 +3,7 @@ import { AdminRole, Prisma } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../../prisma/prisma.service'; import { TableService } from '../admin-table/table.service'; +import { AdminSessionService } from '../sessions/admin-session.service'; import type { TableQueryDto } from '../admin-table/table-query.dto'; import { faToEn } from '../customer-auth/digits'; @@ -24,6 +25,7 @@ export class AdminUsersService { constructor( private readonly prisma: PrismaService, private readonly table: TableService, + private readonly sessions: AdminSessionService, ) {} private static readonly ALLOWED = [ @@ -332,7 +334,7 @@ export class AdminUsersService { }; } - return this.prisma.adminUser.update({ + const updated = await this.prisma.adminUser.update({ where: { id }, data: patch, include: { @@ -342,6 +344,18 @@ export class AdminUsersService { salesExpertProfile: { include: { city: true } }, }, }); + + // Disabling an account or changing its password used to leave every already + // issued token working for up to 12h. With session tracking on, those tokens + // end here; with it off, nothing happens and the behaviour is as before. + if (patch.isActive === false || patch.passwordHash !== undefined) { + await this.sessions.revokeAllForAdmin( + id, + patch.isActive === false ? 'account_disabled' : 'password_changed', + ); + } + + return updated; } /** diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts index 248afd0..941452a 100644 --- a/apps/api/src/modules/auth/auth.controller.ts +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common'; +import { Body, Controller, Get, Headers, HttpCode, Ip, Post } from '@nestjs/common'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; import { CurrentAdmin, Public } from './guards'; @@ -19,8 +19,11 @@ export class AuthController { @Public() @Post('login') @HttpCode(200) - login(@Body() dto: LoginDto) { - return this.authService.login(dto); + login(@Body() dto: LoginDto, @Ip() ip: string, @Headers('user-agent') userAgent?: string) { + // The IP is forwarded to the identity service (when delegation is on) so its + // per-IP rate limit applies to the real admin, not to this API server; both + // are also recorded on the session row when session tracking is on. + return this.authService.login(dto, ip, userAgent); } /** Legacy `users/show` — the router guard's permission refresh. */ diff --git a/apps/api/src/modules/auth/auth.service.spec.ts b/apps/api/src/modules/auth/auth.service.spec.ts new file mode 100644 index 0000000..e58d604 --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.spec.ts @@ -0,0 +1,271 @@ +import { UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; + +/** + * `@nestjs/jwt` v12 ships as ESM and this repo's jest does not transform + * node_modules, so importing AuthService would fail to load. The real class is + * never exercised here (the test injects its own stub), so replacing the module + * keeps the suite runnable without touching the shared jest config. + */ +jest.mock('@nestjs/jwt', () => ({ JwtService: class JwtService {} })); + +import { AuthService } from './auth.service'; +import type { AdminSessionService } from '../sessions/admin-session.service'; +import { + IamInvalidCredentialsError, + IamMfaRequiredError, + IamService, + IamUnavailableError, +} from '../iam/iam.service'; + +/** + * Admin login with the identity service in the loop. + * + * The contract being locked down: with the feature off, login behaves exactly + * as before (one bcrypt compare, no network). With it on, an IAM problem must + * never block an admin who still has a valid local password. + */ +describe('AuthService login', () => { + const PASSWORD = 'Secret123!'; + let passwordHash: string; + + const adminRow = () => ({ + id: 'admin-1', + mobile: '09120000000', + fullName: 'مدیر سامانه', + role: 'admin', + userTypeId: 'ut-1', + branchId: 'br-1', + isActive: true, + passwordHash, + userType: { permissions: [{ rid: 10 }, { rid: 20 }] }, + }); + + /** Session tracking off — the shape of a deployment that does not use it. */ + const untrackedSessions = (): Partial => ({ + newTokenId: jest.fn().mockReturnValue(null), + record: jest.fn(), + }); + + const build = ( + iam: Partial, + user: unknown = adminRow(), + sessions: Partial = untrackedSessions(), + ) => { + const prisma = { + adminUser: { findUnique: jest.fn().mockResolvedValue(user) }, + }; + const jwt = { signAsync: jest.fn().mockResolvedValue('local.jwt.token') }; + + return { + service: new AuthService( + prisma as never, + jwt as never, + iam as IamService, + sessions as AdminSessionService, + ), + prisma, + jwt, + sessions, + }; + }; + + const disabledIam = (): Partial => ({ + enabled: false, + allowsLocalFallback: true, + verifyCredentials: jest.fn(), + }); + + beforeAll(async () => { + passwordHash = await bcrypt.hash(PASSWORD, 8); + }); + + /* ─────────────── feature off: unchanged behaviour ─────────────── */ + + it('logs in with the local password and never calls the identity service', async () => { + const iam = disabledIam(); + const { service, jwt } = build(iam); + + const result = await service.login({ phone: '09120000000', password: PASSWORD }); + + expect(result.api_token).toBe('local.jwt.token'); + expect(result.roles).toEqual([{ rid: 10 }, { rid: 20 }]); + expect(jwt.signAsync).toHaveBeenCalledTimes(1); + expect(iam.verifyCredentials).not.toHaveBeenCalled(); + }); + + it('still rejects a wrong local password', async () => { + const { service } = build(disabledIam()); + + await expect( + service.login({ phone: '09120000000', password: 'wrong' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('still rejects an inactive admin', async () => { + const { service } = build(disabledIam(), { ...adminRow(), isActive: false }); + + await expect( + service.login({ phone: '09120000000', password: PASSWORD }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('still rejects an unknown phone without leaking that it is unknown', async () => { + const { service } = build(disabledIam(), null); + + await expect( + service.login({ phone: '09129999999', password: PASSWORD }), + ).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است'); + }); + + /* ─────────────── feature on ─────────────── */ + + it('accepts a password the identity service approves', async () => { + const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' }); + const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); + + // Deliberately not the local password: the IAM is the authority here + const result = await service.login({ phone: '09120000000', password: 'iam-side-password' }); + + expect(result.api_token).toBe('local.jwt.token'); + expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'iam-side-password', undefined); + }); + + it('forwards the end-user IP to the identity service', async () => { + const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' }); + const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); + + await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5'); + + expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'x', '203.0.113.5'); + }); + + /* ─────────────── the safety net ─────────────── */ + + it('falls back to the local password when the account is unknown there', async () => { + const verifyCredentials = jest + .fn() + .mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials')); + const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); + + const result = await service.login({ phone: '09120000000', password: PASSWORD }); + + expect(result.api_token).toBe('local.jwt.token'); + }); + + it('falls back to the local password when the identity service is down', async () => { + const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down')); + const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); + + // The single most important guarantee: an IAM outage cannot lock admins out + const result = await service.login({ phone: '09120000000', password: PASSWORD }); + + expect(result.api_token).toBe('local.jwt.token'); + }); + + it('rejects a wrong password even when falling back', async () => { + const verifyCredentials = jest + .fn() + .mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials')); + const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); + + await expect( + service.login({ phone: '09120000000', password: 'wrong' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + /* ─────────────── exclusive mode ─────────────── */ + + it('refuses the local password in exclusive mode', async () => { + const verifyCredentials = jest + .fn() + .mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials')); + const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials }); + + await expect( + service.login({ phone: '09120000000', password: PASSWORD }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('fails loudly in exclusive mode when the identity service is down', async () => { + const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down')); + const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials }); + + await expect( + service.login({ phone: '09120000000', password: PASSWORD }), + ).rejects.toThrow('سرویس هویت در دسترس نیست'); + }); + + /* ─────────────── revocable sessions ─────────────── */ + + it('signs the same token as before while session tracking is off', async () => { + const { service, jwt, sessions } = build(disabledIam()); + + await service.login({ phone: '09120000000', password: PASSWORD }); + + // No jti, no row: the previous behaviour, byte for byte + expect(jwt.signAsync).toHaveBeenCalledWith({ sub: 'admin-1', mobile: '09120000000' }); + expect(sessions.record).not.toHaveBeenCalled(); + }); + + it('records the issued token with the IAM session id when tracking is on', async () => { + const tracked: Partial = { + newTokenId: jest.fn().mockReturnValue('jti-1'), + record: jest.fn(), + }; + const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' }); + const { service, jwt } = build( + { enabled: true, allowsLocalFallback: true, verifyCredentials }, + adminRow(), + tracked, + ); + + await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5', 'Firefox'); + + expect(jwt.signAsync).toHaveBeenCalledWith({ + sub: 'admin-1', + mobile: '09120000000', + jti: 'jti-1', + }); + // Without the IAM session id, a back-channel logout could not find this session + expect(tracked.record).toHaveBeenCalledWith({ + jti: 'jti-1', + adminUserId: 'admin-1', + iamSessionId: 'sid-1', + iamSub: 'sub-1', + ipAddress: '203.0.113.5', + userAgent: 'Firefox', + }); + }); + + it('still records a session when the login fell back to the local password', async () => { + const tracked: Partial = { + newTokenId: jest.fn().mockReturnValue('jti-2'), + record: jest.fn(), + }; + const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down')); + const { service } = build( + { enabled: true, allowsLocalFallback: true, verifyCredentials }, + adminRow(), + tracked, + ); + + await service.login({ phone: '09120000000', password: PASSWORD }); + + // No IAM identity to attach, but the session must still be revocable locally + expect(tracked.record).toHaveBeenCalledWith( + expect.objectContaining({ jti: 'jti-2', iamSessionId: null, iamSub: null }), + ); + }); + + /* ─────────────── two-factor ─────────────── */ + + it('explains that a two-factor account cannot log in through this path', async () => { + const verifyCredentials = jest.fn().mockRejectedValue(new IamMfaRequiredError('mfa')); + const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials }); + + await expect( + service.login({ phone: '09120000000', password: PASSWORD }), + ).rejects.toThrow('دو مرحله‌ای'); + }); +}); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index be1ddca..7614430 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -1,14 +1,25 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../../prisma/prisma.service'; +import { + IamInvalidCredentialsError, + IamMfaRequiredError, + IamService, + type IamIdentity, +} from '../iam/iam.service'; +import { AdminSessionService } from '../sessions/admin-session.service'; import type { LoginDto } from './dto/login.dto'; @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, + private readonly iam: IamService, + private readonly sessions: AdminSessionService, ) {} /** @@ -25,7 +36,7 @@ export class AuthService { * 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for * a wrong password, which let anyone enumerate valid admin phone numbers. */ - async login(dto: LoginDto) { + async login(dto: LoginDto, clientIp?: string, userAgent?: string) { const user = await this.prisma.adminUser.findUnique({ where: { mobile: dto.phone }, include: { userType: { include: { permissions: true } } }, @@ -35,12 +46,32 @@ export class AuthService { throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است'); } - const passwordOk = await bcrypt.compare(dto.password, user.passwordHash); - if (!passwordOk) { + const verified = await this.verifyPassword(dto, user.passwordHash, clientIp); + if (!verified.ok) { throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است'); } - const apiToken = await this.jwt.signAsync({ sub: user.id, mobile: user.mobile }); + // While session tracking is off this is null and the token is signed exactly + // as before — same payload, same claims. + const jti = this.sessions.newTokenId(); + const apiToken = await this.jwt.signAsync({ + sub: user.id, + mobile: user.mobile, + ...(jti ? { jti } : {}), + }); + + if (jti) { + // Recorded so a central sign-out (or an admin revoking the session) can end + // this token before it expires — see AdminSessionService. + await this.sessions.record({ + jti, + adminUserId: user.id, + iamSessionId: verified.identity?.sessionId ?? null, + iamSub: verified.identity?.sub ?? null, + ipAddress: clientIp ?? null, + userAgent: userAgent ?? null, + }); + } return { id: user.id, @@ -55,6 +86,56 @@ export class AuthService { }; } + /** + * Password check, optionally delegated to the central identity service. + * + * With `IAM_ENABLED=false` (the default) this is exactly the previous + * behaviour: a single bcrypt compare. With it on, the identity service is + * asked first and the local hash is only consulted when that check could not + * give a definite answer — so an IAM outage never blocks admin login. + */ + private async verifyPassword( + dto: LoginDto, + passwordHash: string, + clientIp?: string, + ): Promise<{ ok: boolean; identity?: IamIdentity }> { + if (!this.iam.enabled) { + return { ok: await bcrypt.compare(dto.password, passwordHash) }; + } + + try { + const identity = await this.iam.verifyCredentials(dto.phone, dto.password, clientIp); + + // The IAM session id travels with the recorded session so a back-channel + // logout can find and end exactly this session. + return { ok: true, identity }; + } catch (error) { + if (error instanceof IamMfaRequiredError) { + // Nowhere to collect the code on this UI-less path; say so plainly + // instead of silently falling back to a password the IAM may have retired. + throw new UnauthorizedException( + 'برای این حساب احراز هویت دو مرحله‌ای فعال است و از این مسیر قابل تکمیل نیست', + ); + } + + if (error instanceof IamInvalidCredentialsError && !this.iam.allowsLocalFallback) { + return { ok: false }; + } + + if (!(error instanceof IamInvalidCredentialsError)) { + this.logger.warn( + `falling back to the local password check: ${(error as Error).message}`, + ); + if (!this.iam.allowsLocalFallback) { + throw new UnauthorizedException('ورود در حال حاضر ممکن نیست؛ سرویس هویت در دسترس نیست'); + } + } + + // fallback mode: the account may not have been migrated to the IAM yet + return { ok: await bcrypt.compare(dto.password, passwordHash) }; + } + } + /** * Port of legacy UserController::showData, which the router guard calls on every * cold load to refresh the permission list: diff --git a/apps/api/src/modules/auth/jwt.strategy.ts b/apps/api/src/modules/auth/jwt.strategy.ts index b52988e..e328948 100644 --- a/apps/api/src/modules/auth/jwt.strategy.ts +++ b/apps/api/src/modules/auth/jwt.strategy.ts @@ -3,11 +3,19 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { PrismaService } from '../../prisma/prisma.service'; import { envOr } from '../../common/env'; +import { AdminSessionService } from '../sessions/admin-session.service'; import { expandWithParents } from './acl.constants'; export interface JwtPayload { sub: string; mobile: string; + /** + * Present only on tokens issued while session tracking is on. It identifies the + * AdminSession row, which is what makes revoking a token before it expires + * possible. A token without it predates tracking and stays valid until it + * expires, so switching the feature on does not sign everyone out. + */ + jti?: string; /** * Set on APP tokens only ('customer' or 'sales-expert'). An admin-panel * token has no `typ`. Checked in validate() below — see the note there. @@ -46,7 +54,10 @@ export interface AuthenticatedAdmin { @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { - constructor(private readonly prisma: PrismaService) { + constructor( + private readonly prisma: PrismaService, + private readonly sessions: AdminSessionService, + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, @@ -60,6 +71,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { throw new UnauthorizedException(); } + // Revoked centrally (IAM back-channel logout) or locally? While tracking is + // off this returns true without touching the database, so the request costs + // exactly what it did before. + if (!(await this.sessions.isActive(payload.jti))) { + throw new UnauthorizedException(); + } + const user = await this.prisma.adminUser.findUnique({ where: { id: payload.sub }, include: { userType: { include: { permissions: true } } }, diff --git a/apps/api/src/modules/customer-auth/app-principal.ts b/apps/api/src/modules/customer-auth/app-principal.ts index 4a2932e..5b483d5 100644 --- a/apps/api/src/modules/customer-auth/app-principal.ts +++ b/apps/api/src/modules/customer-auth/app-principal.ts @@ -33,6 +33,13 @@ export interface AppJwtPayload { sub: string; mobile: string; typ: AppTokenType; + /** + * Present only on tokens issued while session tracking is on. It identifies + * the AppSession row, which is what makes ending a session before its 30-day + * expiry possible (central sign-out, or revoking locally). A token without it + * predates tracking and stays valid until it expires. + */ + jti?: string; } /** diff --git a/apps/api/src/modules/customer-auth/customer-auth-iam.spec.ts b/apps/api/src/modules/customer-auth/customer-auth-iam.spec.ts new file mode 100644 index 0000000..6b596fe --- /dev/null +++ b/apps/api/src/modules/customer-auth/customer-auth-iam.spec.ts @@ -0,0 +1,166 @@ +import { UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; + +/** + * `@nestjs/jwt` v12 is ESM and this repo's jest does not transform node_modules, + * so the real class cannot be imported here. The test injects its own stub. + */ +jest.mock('@nestjs/jwt', () => ({ JwtService: class JwtService {} })); + +import { CustomerAuthService } from './customer-auth.service'; +import { IamAccessDeniedError, IamUnavailableError, IamUserUnknownError } from '../iam/iam.service'; + +/** + * The app's OTP login and the central identity service. + * + * This login has **no password**, so it never reached the identity service at + * all: no central session, nothing in the security log, and a central sign-out + * could not end it. These tests lock down the fix and, more importantly, the + * boundaries: an identity-service problem must never keep a customer out of the + * app, while a refusal (locked / deactivated / two-factor) must be honoured. + */ +describe('CustomerAuthService — OTP login with the identity service', () => { + const MOBILE = '09120000000'; + const CODE = '1234'; + const CUSTOMER = { id: 'cust-1', mobile: MOBILE, isActive: true, isArchived: false }; + + let prisma: any; + let jwt: { signAsync: jest.Mock }; + let sms: { send: jest.Mock }; + + const challenge = async () => ({ + id: 'otp-1', + mobile: MOBILE, + codeHash: await bcrypt.hash(CODE, 8), + attempts: 0, + consumedAt: null, + expiresAt: new Date(Date.now() + 60_000), + customer: CUSTOMER, + }); + + const build = (iam: Record, sessions?: Record) => { + const session = sessions ?? { newTokenId: jest.fn().mockReturnValue(null), record: jest.fn() }; + const service = new CustomerAuthService( + prisma as never, + jwt as never, + sms as never, + iam as never, + session as never, + ); + jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined); + return { service, sessions: session }; + }; + + beforeEach(async () => { + delete process.env.OTP_MASTER_CODE; + process.env.JWT_CUSTOMER_SECRET = 'test-secret'; + + prisma = { + customer: { findFirst: jest.fn().mockResolvedValue(CUSTOMER) }, + customerOtp: { + findFirst: jest.fn().mockResolvedValue(await challenge()), + update: jest.fn().mockResolvedValue({}), + create: jest.fn().mockResolvedValue({}), + count: jest.fn().mockResolvedValue(0), + }, + adminUser: { findFirst: jest.fn().mockResolvedValue(null) }, + appSession: { create: jest.fn(), findUnique: jest.fn(), updateMany: jest.fn() }, + }; + jwt = { signAsync: jest.fn().mockResolvedValue('signed.jwt.token') }; + sms = { send: jest.fn().mockResolvedValue({ ok: true }) }; + }); + + /* ─────────────── feature off: unchanged behaviour ─────────────── */ + + it('never contacts the identity service while the feature is off', async () => { + const attestOtpLogin = jest.fn(); + const { service } = build({ enabled: false, attestOtpLogin }); + + const result = await service.verifyOtp(MOBILE, CODE, '203.0.113.5'); + + expect(result.token).toBe('signed.jwt.token'); + expect(attestOtpLogin).not.toHaveBeenCalled(); + // بدون ردیابی نشست، توکن دقیقاً همان ادعاهای قبلی را دارد + expect(jwt.signAsync.mock.calls[0][0]).toEqual({ sub: 'cust-1', mobile: MOBILE, typ: 'customer' }); + }); + + /* ─────────────── feature on ─────────────── */ + + it('reports the verified code with the end-user IP and a masked number', async () => { + const attestOtpLogin = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' }); + const { service } = build({ enabled: true, attestOtpLogin }); + + await service.verifyOtp(MOBILE, CODE, '203.0.113.5'); + + // بدون IP کاربر نهایی، محدودیت نرخ IdP روی IP همین سرور می‌افتاد + expect(attestOtpLogin).toHaveBeenCalledWith(MOBILE, '203.0.113.5', '0912***0000'); + }); + + it('records the session with the identity service ids so a central sign-out can end it', async () => { + const attestOtpLogin = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' }); + const tracked = { newTokenId: jest.fn().mockReturnValue('jti-1'), record: jest.fn() }; + const { service } = build({ enabled: true, attestOtpLogin }, tracked); + + await service.verifyOtp(MOBILE, CODE, '203.0.113.5'); + + expect(jwt.signAsync.mock.calls[0][0]).toMatchObject({ jti: 'jti-1' }); + expect(tracked.record).toHaveBeenCalledWith( + expect.objectContaining({ + jti: 'jti-1', + subjectId: 'cust-1', + subjectType: 'customer', + iamSessionId: 'sid-1', + iamSub: 'sub-1', + }), + ); + }); + + /* ─────────────── the safety net ─────────────── */ + + it('lets the customer in when the identity service does not know them yet', async () => { + const attestOtpLogin = jest.fn().mockRejectedValue(new IamUserUnknownError(MOBILE)); + const { service } = build({ enabled: true, attestOtpLogin }); + + const result = await service.verifyOtp(MOBILE, CODE, undefined); + + // مناسب مهاجرت تدریجی: هنوز همه کاربران آنجا نیستند + expect(result.token).toBe('signed.jwt.token'); + }); + + it('lets the customer in when the identity service is down', async () => { + const attestOtpLogin = jest.fn().mockRejectedValue(new IamUnavailableError('down')); + const { service } = build({ enabled: true, attestOtpLogin }); + + // مهم‌ترین تضمین: خرابی IdP نباید کاربر را از اپ بیرون نگه دارد + await expect(service.verifyOtp(MOBILE, CODE, undefined)).resolves.toMatchObject({ + token: 'signed.jwt.token', + }); + }); + + it('honours a refusal from the identity service (locked / deactivated / two-factor)', async () => { + const attestOtpLogin = jest.fn().mockRejectedValue(new IamAccessDeniedError('refused', 'account_locked')); + const { service } = build({ enabled: true, attestOtpLogin }); + + // اینجا «نمی‌دانم» نیست، «نه» است — دور زدنش قفل مرکزی را بی‌اثر می‌کرد + await expect(service.verifyOtp(MOBILE, CODE, undefined)).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('still refuses a wrong code before any of this runs', async () => { + const attestOtpLogin = jest.fn(); + const { service } = build({ enabled: true, attestOtpLogin }); + + await expect(service.verifyOtp(MOBILE, '9999', undefined)).rejects.toBeInstanceOf(UnauthorizedException); + expect(attestOtpLogin).not.toHaveBeenCalled(); + }); + + it('does not report the delivery master code as a verified factor', async () => { + process.env.OTP_MASTER_CODE = '4321'; + const attestOtpLogin = jest.fn(); + const { service } = build({ enabled: true, attestOtpLogin }); + + await service.verifyOtp(MOBILE, '4321', undefined); + + // کد اضطراری، عامل واقعی کاربر نیست؛ نشست مرکزی روی آن ساخته نمی‌شود + expect(attestOtpLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/customer-auth/customer-auth.service.ts b/apps/api/src/modules/customer-auth/customer-auth.service.ts index bb98cc3..0f23742 100644 --- a/apps/api/src/modules/customer-auth/customer-auth.service.ts +++ b/apps/api/src/modules/customer-auth/customer-auth.service.ts @@ -3,6 +3,13 @@ import { JwtService } from '@nestjs/jwt'; import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../../prisma/prisma.service'; +import { + IamAccessDeniedError, + IamService, + IamUserUnknownError, + type IamIdentity, +} from '../iam/iam.service'; +import { AppSessionService } from '../sessions/app-session.service'; import { envOr, envOrNull } from '../../common/env'; import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy'; import { faToEn } from './digits'; @@ -47,6 +54,17 @@ export function masterOtpEnabled(): boolean { return masterOtpCode() !== null; } +/** اطلاعات جانبی صدور توکن: هویت تأییدشده در IdP و IP کاربر */ +interface IssueContext { + identity?: IamIdentity | null; + ip?: string; +} + +/** شماره ماسک‌شده برای لاگ امنیتی IdP — بدون افشای کامل شماره */ +function maskMobile(mobile: string): string | null { + return mobile.length < 7 ? null : `${mobile.slice(0, 4)}***${mobile.slice(-4)}`; +} + @Injectable() export class CustomerAuthService { private readonly logger = new Logger(CustomerAuthService.name); @@ -55,8 +73,41 @@ export class CustomerAuthService { private readonly prisma: PrismaService, private readonly jwt: JwtService, private readonly sms: SmsService, + private readonly iam: IamService, + private readonly sessions: AppSessionService, ) {} + /** + * Tell the central identity service about a code this app just verified. + * + * The app's login has no password, so this is the only way an OTP login can + * reach the identity service at all. Deliberate asymmetry in how failures are + * treated, mirroring the admin path: + * + * - unknown user / service down / rate limited → local login continues + * (an IAM problem must never lock customers out of the app); + * - locked, deactivated or two-factor there → the login is refused, + * because that is a decision, not a gap. + */ + private async attestToIam(mobile: string, ip: string | undefined): Promise { + if (!this.iam.enabled) return null; + + try { + return await this.iam.attestOtpLogin(mobile, ip, maskMobile(mobile)); + } catch (error) { + if (error instanceof IamAccessDeniedError) { + this.logger.warn(`identity service refused ${mobile}: ${error.reason}`); + throw new UnauthorizedException('ورود این حساب در حال حاضر مجاز نیست'); + } + + if (!(error instanceof IamUserUnknownError)) { + this.logger.warn(`OTP attestation skipped: ${(error as Error).message}`); + } + + return null; + } + } + /** * Step 1 — port of ApiMobileController::userAuth (henza_server:39-49). * @@ -262,8 +313,10 @@ export class CustomerAuthService { this.logger.warn( `OTP_MASTER_CODE used to sign in SALES EXPERT ${expert.adminUserId} (mobile ${mobile})`, ); + // کد اضطراری تحویل، عامل واقعی نیست؛ عمداً به IdP اعلام نمی‌شود تا + // نشست مرکزی روی چیزی که کاربر واقعاً نداشته ساخته نشود. return { - token: await this.issueExpertToken(expert.adminUserId, mobile), + token: await this.issueExpertToken(expert.adminUserId, mobile, { ip }), customer: { id: expert.tradingCustomerId, mobile }, }; } @@ -281,7 +334,7 @@ export class CustomerAuthService { ); return { - token: await this.issueToken(customer.id, customer.mobile), + token: await this.issueToken(customer.id, customer.mobile, { ip }), customer: { id: customer.id, mobile: customer.mobile }, }; } @@ -327,16 +380,21 @@ export class CustomerAuthService { // D21 — the challenge hangs off the expert's trading Customer row, so a // correct code here may belong to either principal. Resolved from the // mobile, never from anything the client sent. + // کد درست بود؛ حالا همین را به مرجع هویت اعلام می‌کنیم تا نشست مرکزی، + // لاگ امنیتی و خروج سراسری شامل این ورود هم بشود. در حالت خاموش یا خرابی + // IdP، هیچ چیزی تغییر نمی‌کند و ورود مثل همیشه ادامه می‌یابد. + const identity = await this.attestToIam(mobile, ip); + const expert = await this.resolveExpertPrincipal(mobile); if (expert) { return { - token: await this.issueExpertToken(expert.adminUserId, mobile), + token: await this.issueExpertToken(expert.adminUserId, mobile, { identity, ip }), customer: { id: expert.tradingCustomerId, mobile }, }; } return { - token: await this.issueToken(challenge.customer.id, challenge.customer.mobile), + token: await this.issueToken(challenge.customer.id, challenge.customer.mobile, { identity, ip }), customer: { id: challenge.customer.id, mobile: challenge.customer.mobile }, }; } @@ -379,16 +437,37 @@ export class CustomerAuthService { * NOT an admin-panel token: JwtStrategy requires the admin type and refuses * this one on every admin route. */ - private issueExpertToken(adminUserId: string, mobile: string): Promise { + private async issueExpertToken( + adminUserId: string, + mobile: string, + context: IssueContext = {}, + ): Promise { + const jti = this.sessions.newTokenId(); const payload: CustomerJwtPayload = { sub: adminUserId, mobile, typ: EXPERT_TOKEN_TYPE, + ...(jti ? { jti } : {}), }; - return this.jwt.signAsync(payload, { + + const token = await this.jwt.signAsync(payload, { secret: customerJwtSecret(), expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`, }); + + if (jti) { + await this.sessions.record({ + jti, + subjectId: adminUserId, + subjectType: 'sales-expert', + mobile, + iamSessionId: context.identity?.sessionId ?? null, + iamSub: context.identity?.sub ?? null, + ipAddress: context.ip ?? null, + }); + } + + return token; } /** @@ -399,18 +478,40 @@ export class CustomerAuthService { * Reference reused api_token forever once set (:60-62); a fresh token is * issued on every verify here (T1). */ - private issueToken(customerId: string, mobile: string): Promise { + private async issueToken( + customerId: string, + mobile: string, + context: IssueContext = {}, + ): Promise { + // null while session tracking is off ⇒ همان توکن قبلی، بدون ادعای اضافه + const jti = this.sessions.newTokenId(); const payload: CustomerJwtPayload = { sub: customerId, mobile, typ: CUSTOMER_TOKEN_TYPE, + ...(jti ? { jti } : {}), }; - return this.jwt.signAsync(payload, { + + const token = await this.jwt.signAsync(payload, { secret: customerJwtSecret(), // jsonwebtoken types expiresIn as a template-literal union, which a plain // string from process.env does not satisfy — same cast AuthModule uses. expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`, }); + + if (jti) { + await this.sessions.record({ + jti, + subjectId: customerId, + subjectType: 'customer', + mobile, + iamSessionId: context.identity?.sessionId ?? null, + iamSub: context.identity?.sub ?? null, + ipAddress: context.ip ?? null, + }); + } + + return token; } /** diff --git a/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts b/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts index f5645d3..801a0dd 100644 --- a/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts +++ b/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts @@ -10,6 +10,7 @@ import { type AppJwtPayload, type AuthenticatedCustomer, } from './app-principal'; +import { AppSessionService } from '../sessions/app-session.service'; export { CUSTOMER_TOKEN_TYPE, @@ -41,7 +42,10 @@ export type CustomerJwtPayload = AppJwtPayload; */ @Injectable() export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jwt') { - constructor(private readonly prisma: PrismaService) { + constructor( + private readonly prisma: PrismaService, + private readonly sessions: AppSessionService, + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, @@ -50,6 +54,12 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw } async validate(payload: AppJwtPayload): Promise { + // نشست باطل‌شده (خروج سراسری از IdP یا ابطال محلی). در حالت خاموشِ ردیابی، + // بدون هیچ کوئری true برمی‌گردد ⇒ هزینه و رفتار دقیقاً مثل قبل. + if (!(await this.sessions.isActive(payload?.jti))) { + throw new UnauthorizedException(); + } + if (payload?.typ === EXPERT_TOKEN_TYPE) { return this.validateExpert(payload); } diff --git a/apps/api/src/modules/customer-auth/master-otp.spec.ts b/apps/api/src/modules/customer-auth/master-otp.spec.ts index 1e5915c..3c2e6e0 100644 --- a/apps/api/src/modules/customer-auth/master-otp.spec.ts +++ b/apps/api/src/modules/customer-auth/master-otp.spec.ts @@ -58,10 +58,16 @@ describe('OTP_MASTER_CODE (D20)', () => { jwt = { signAsync: jest.fn().mockResolvedValue('signed.jwt.token') }; sms = { send: jest.fn().mockResolvedValue({ ok: true }) }; + // IAM خاموش و ردیابی نشست خاموش ⇒ همان رفتار قبلی این سرویس + const iam = { enabled: false, attestOtpLogin: jest.fn() }; + const sessions = { newTokenId: jest.fn().mockReturnValue(null), record: jest.fn() }; + service = new CustomerAuthService( prisma as never, jwt as never, sms as never, + iam as never, + sessions as never, ); // The bypass logs every use; keep the test output clean. jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined); diff --git a/apps/api/src/modules/iam/iam-logout.service.spec.ts b/apps/api/src/modules/iam/iam-logout.service.spec.ts new file mode 100644 index 0000000..206c02e --- /dev/null +++ b/apps/api/src/modules/iam/iam-logout.service.spec.ts @@ -0,0 +1,161 @@ +import { createSign, generateKeyPairSync, type KeyObject } from 'node:crypto'; +import { IamLogoutService, IamLogoutTokenError } from './iam-logout.service'; +import type { IamService } from './iam.service'; + +/** + * Back-channel logout token verification. + * + * This endpoint is unauthenticated by design: the token's signature is the only + * thing standing between a stranger and "sign out every admin of this system". + * Each test here is one of the checks that keeps that true. + */ +describe('IamLogoutService', () => { + const ISSUER = 'https://auth.test.local'; + const CLIENT_ID = 'goldbank_api'; + const EVENT = 'http://schemas.openid.net/event/backchannel-logout'; + + let keyPair: { publicKey: KeyObject; privateKey: KeyObject }; + let otherKeyPair: { publicKey: KeyObject; privateKey: KeyObject }; + let service: IamLogoutService; + let fetchMock: jest.Mock; + + const jwk = (key: KeyObject, kid: string) => ({ ...key.export({ format: 'jwk' }), kid, alg: 'RS256', use: 'sig' }); + + const sign = ( + payload: Record, + options: { kid?: string; alg?: string; key?: KeyObject } = {}, + ): string => { + const header = { alg: options.alg ?? 'RS256', typ: 'logout+jwt', kid: options.kid ?? 'key-1' }; + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const body = `${encode(header)}.${encode(payload)}`; + const signer = createSign('RSA-SHA256'); + signer.update(body); + return `${body}.${signer.sign(options.key ?? keyPair.privateKey).toString('base64url')}`; + }; + + const validPayload = (overrides: Record = {}) => ({ + iss: ISSUER, + aud: CLIENT_ID, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 120, + jti: 'logout-1', + sub: 'sub-1', + sid: 'sid-1', + events: { [EVENT]: {} }, + ...overrides, + }); + + beforeAll(() => { + keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 }); + otherKeyPair = generateKeyPairSync('rsa', { modulusLength: 2048 }); + }); + + beforeEach(() => { + service = new IamLogoutService({ issuer: ISSUER, clientId: CLIENT_ID } as IamService); + fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ keys: [jwk(keyPair.publicKey, 'key-1')] }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + /* ─────────────── the happy path ─────────────── */ + + it('accepts a properly signed logout token and reports the session', async () => { + const result = await service.verify(sign(validPayload())); + + expect(result).toEqual({ sessionId: 'sid-1', subject: 'sub-1' }); + expect(fetchMock.mock.calls[0][0]).toBe(`${ISSUER}/.well-known/jwks.json`); + }); + + it('accepts a token that names only the identity', async () => { + const result = await service.verify(sign(validPayload({ sid: undefined }))); + + expect(result).toEqual({ sessionId: null, subject: 'sub-1' }); + }); + + it('caches the keys instead of fetching them for every notification', async () => { + await service.verify(sign(validPayload())); + await service.verify(sign(validPayload({ jti: 'logout-2' }))); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('refetches the keys when an unknown key id appears, so rotation needs no restart', async () => { + await service.verify(sign(validPayload())); + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ keys: [jwk(otherKeyPair.publicKey, 'key-2')] }), + }); + + const rotated = sign(validPayload({ jti: 'logout-3' }), { kid: 'key-2', key: otherKeyPair.privateKey }); + await expect(service.verify(rotated)).resolves.toMatchObject({ sessionId: 'sid-1' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + /* ─────────────── what must be refused ─────────────── */ + + it('refuses a token signed with an unknown key', async () => { + const forged = sign(validPayload(), { key: otherKeyPair.privateKey }); + + await expect(service.verify(forged)).rejects.toBeInstanceOf(IamLogoutTokenError); + }); + + it('refuses an unsigned token however it describes itself', async () => { + const parts = sign(validPayload()).split('.'); + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'logout+jwt' })).toString('base64url'); + + await expect(service.verify(`${header}.${parts[1]}.`)).rejects.toBeInstanceOf(IamLogoutTokenError); + }); + + it('refuses a token from another issuer', async () => { + await expect( + service.verify(sign(validPayload({ iss: 'https://evil.test.local' }))), + ).rejects.toThrow('another identity service'); + }); + + it('refuses a token addressed to another client', async () => { + await expect(service.verify(sign(validPayload({ aud: 'someone_else' })))).rejects.toThrow( + 'not addressed to this client', + ); + }); + + it('refuses an ID token replayed as a logout token', async () => { + // Without the events check, an ID token from a normal login would end sessions + await expect(service.verify(sign(validPayload({ events: undefined })))).rejects.toThrow( + 'back-channel logout event', + ); + }); + + it('refuses a logout token carrying a nonce', async () => { + await expect(service.verify(sign(validPayload({ nonce: 'n-1' })))).rejects.toThrow('nonce'); + }); + + it('refuses an expired token', async () => { + const stale = validPayload({ + iat: Math.floor(Date.now() / 1000) - 3600, + exp: Math.floor(Date.now() / 1000) - 3000, + }); + + await expect(service.verify(sign(stale))).rejects.toThrow('expired'); + }); + + it('refuses a token that names neither a session nor an identity', async () => { + await expect( + service.verify(sign(validPayload({ sid: undefined, sub: undefined }))), + ).rejects.toThrow('neither a session nor a subject'); + }); + + it('refuses a malformed token', async () => { + await expect(service.verify('not-a-token')).rejects.toBeInstanceOf(IamLogoutTokenError); + }); + + it('reports an unreachable identity service as an availability problem', async () => { + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(service.verify(sign(validPayload()))).rejects.toThrow('could not reach'); + }); +}); diff --git a/apps/api/src/modules/iam/iam-logout.service.ts b/apps/api/src/modules/iam/iam-logout.service.ts new file mode 100644 index 0000000..6731f8f --- /dev/null +++ b/apps/api/src/modules/iam/iam-logout.service.ts @@ -0,0 +1,170 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { createPublicKey, createVerify, type KeyObject } from 'node:crypto'; +import { IamService, IamUnavailableError } from './iam.service'; + +/** The logout token was not a valid, current back-channel logout token. */ +export class IamLogoutTokenError extends Error {} + +export interface LogoutSubject { + /** The IAM session id (`sid`), when the token names one session. */ + sessionId: string | null; + /** The IAM user id (`sub`), when the token names a whole identity. */ + subject: string | null; +} + +const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout'; + +/** + * Verification of OpenID Connect Back-Channel Logout 1.0 tokens. + * + * The IAM calls this app when a session ends elsewhere (the user signed out, or + * an administrator revoked the session). The request carries no credentials by + * design — its authority is the token's RS256 signature, checked here against the + * IAM's published keys — so every one of the spec's checks matters: + * + * - signature verifies against a key from the IAM's JWKS (keys are cached, and + * an unknown `kid` refetches once so key rotation needs no restart); + * - `iss` is exactly the configured issuer and `aud` contains this client id, + * otherwise another tenant's token could sign out our admins; + * - the `events` claim holds the back-channel logout event, so an ID token + * cannot be replayed here; + * - a `nonce` MUST NOT be present (that would make it an ID token); + * - `iat`/`exp` are honoured with a small clock skew allowance. + * + * No new dependency: Node verifies RS256 and imports a JWK natively. + */ +@Injectable() +export class IamLogoutService { + private readonly logger = new Logger(IamLogoutService.name); + + /** kid -> public key, plus when the set was fetched. */ + private keys = new Map(); + private keysFetchedAt = 0; + + constructor(private readonly iam: IamService) {} + + /** Seconds of clock skew tolerated on `iat`/`exp`. */ + private get leeway(): number { + return 60; + } + + async verify(logoutToken: string): Promise { + const parts = logoutToken.split('.'); + if (parts.length !== 3) throw new IamLogoutTokenError('logout token is malformed'); + + const [rawHeader, rawPayload, rawSignature] = parts as [string, string, string]; + const header = this.decodeJson(rawHeader, 'header'); + const payload = this.decodeJson(rawPayload, 'payload'); + + if (header.alg !== 'RS256') { + // Never trust the token's own word that it needs no signature. + throw new IamLogoutTokenError(`unsupported logout token algorithm: ${String(header.alg)}`); + } + + const key = await this.publicKey(typeof header.kid === 'string' ? header.kid : null); + const verifier = createVerify('RSA-SHA256'); + verifier.update(`${rawHeader}.${rawPayload}`); + if (!verifier.verify(key, Buffer.from(rawSignature, 'base64url'))) { + throw new IamLogoutTokenError('logout token signature does not verify'); + } + + this.assertClaims(payload); + + const sessionId = typeof payload.sid === 'string' && payload.sid !== '' ? payload.sid : null; + const subject = typeof payload.sub === 'string' && payload.sub !== '' ? payload.sub : null; + if (!sessionId && !subject) { + throw new IamLogoutTokenError('logout token names neither a session nor a subject'); + } + + return { sessionId, subject }; + } + + private assertClaims(payload: Record): void { + if (payload.iss !== this.iam.issuer) { + throw new IamLogoutTokenError('logout token was issued by another identity service'); + } + + const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + if (!audiences.includes(this.iam.clientId)) { + throw new IamLogoutTokenError('logout token is not addressed to this client'); + } + + // A logout token carries the logout event; an ID token does not. Without this + // check an ID token obtained during a normal login could end sessions. + const events = payload.events as Record | undefined; + if (!events || typeof events !== 'object' || !(BACKCHANNEL_LOGOUT_EVENT in events)) { + throw new IamLogoutTokenError('logout token does not carry the back-channel logout event'); + } + + if ('nonce' in payload) { + throw new IamLogoutTokenError('a logout token must not carry a nonce'); + } + + const now = Math.floor(Date.now() / 1000); + if (typeof payload.exp === 'number' && payload.exp + this.leeway < now) { + throw new IamLogoutTokenError('logout token has expired'); + } + if (typeof payload.iat === 'number' && payload.iat - this.leeway > now) { + throw new IamLogoutTokenError('logout token is not valid yet'); + } + } + + /** + * The signing key for this `kid`. An unknown kid triggers one refetch, so a key + * rotation at the IAM takes effect without restarting this service. + */ + private async publicKey(kid: string | null): Promise { + const cacheAgeSeconds = (Date.now() - this.keysFetchedAt) / 1000; + const stale = cacheAgeSeconds > 24 * 3600; + const cached = kid ? this.keys.get(kid) : [...this.keys.values()][0]; + + if (cached && !stale) return cached; + + await this.loadKeys(); + + const key = kid ? this.keys.get(kid) : [...this.keys.values()][0]; + if (!key) throw new IamLogoutTokenError('no matching signing key at the identity service'); + + return key; + } + + private async loadKeys(): Promise { + let response: Response; + try { + response = await fetch(`${this.iam.issuer}/.well-known/jwks.json`, { + headers: { accept: 'application/json' }, + }); + } catch (error) { + throw new IamUnavailableError(`could not reach the identity service: ${(error as Error).message}`); + } + + if (!response.ok) { + throw new IamUnavailableError(`the identity service returned ${response.status} for its keys`); + } + + const body = (await response.json()) as { keys?: Array> }; + const keys = new Map(); + + for (const jwk of body.keys ?? []) { + if (jwk.kty !== 'RSA' || typeof jwk.kid !== 'string') continue; + try { + keys.set(jwk.kid, createPublicKey({ key: jwk as never, format: 'jwk' })); + } catch (error) { + this.logger.warn(`skipping an unusable key from the identity service: ${(error as Error).message}`); + } + } + + if (keys.size === 0) throw new IamUnavailableError('the identity service published no usable keys'); + + this.keys = keys; + this.keysFetchedAt = Date.now(); + } + + private decodeJson(segment: string, what: string): Record { + try { + return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')) as Record; + } catch { + throw new IamLogoutTokenError(`logout token ${what} is not readable`); + } + } +} diff --git a/apps/api/src/modules/iam/iam.controller.ts b/apps/api/src/modules/iam/iam.controller.ts new file mode 100644 index 0000000..8d92ffd --- /dev/null +++ b/apps/api/src/modules/iam/iam.controller.ts @@ -0,0 +1,87 @@ +import { Body, Controller, HttpCode, Logger, Post } from '@nestjs/common'; +import { Public } from '../auth/guards'; +import { AdminSessionService } from '../sessions/admin-session.service'; +import { AppSessionService } from '../sessions/app-session.service'; +import { IamLogoutService, IamLogoutTokenError } from './iam-logout.service'; +import { IamService } from './iam.service'; + +/** + * Back-channel logout receiver (OpenID Connect Back-Channel Logout 1.0). + * + * Register this path as the client's `backchannel_logout_uri` in the IAM: + * + * https:///api/iam/backchannel-logout + * + * The endpoint is public because it carries no credentials by design — its + * authority is the logout token's signature, verified against the IAM's keys. It + * always answers 200, as the spec asks, so a rejected token does not make the IAM + * retry forever; the reason is logged here instead. + * + * Nothing in the UI changes: this is how a central "sign out everywhere" or an + * administrator revoking a session actually ends a session in this app. + */ +@Controller('iam') +export class IamController { + private readonly logger = new Logger(IamController.name); + + constructor( + private readonly iam: IamService, + private readonly logout: IamLogoutService, + private readonly sessions: AdminSessionService, + private readonly appSessions: AppSessionService, + ) {} + + @Public() + @Post('backchannel-logout') + @HttpCode(200) + async backchannelLogout(@Body() body: Record) { + if (!this.iam.enabled) { + // Not configured: nothing to act on, and no information to give away. + return { status: 'ignored' }; + } + + const token = typeof body?.logout_token === 'string' ? body.logout_token : ''; + if (!token) { + this.logger.warn('back-channel logout called without a logout token'); + return { status: 'ignored' }; + } + + try { + const { sessionId, subject } = await this.logout.verify(token); + + // `sid` names one session; a token with only `sub` means every session of + // that identity — matching the spec's two forms. + // + // Both token families are ended: the admin panel AND the app (customer / + // sales expert). Leaving the app out would make a central sign-out look + // like it worked while the phone in the user's pocket stayed signed in. + const [admin, app] = sessionId + ? await Promise.all([ + this.sessions.revokeByIamSession(sessionId), + this.appSessions.revokeByIamSession(sessionId), + ]) + : await Promise.all([ + this.sessions.revokeByIamSubject(subject as string), + this.appSessions.revokeByIamSubject(subject as string), + ]); + + const revoked = admin + app; + this.logger.log( + `back-channel logout accepted (${sessionId ? `sid=${sessionId}` : `sub=${subject}`}): ` + + `${admin} admin + ${app} app session(s) revoked`, + ); + + return { status: 'ok', revoked }; + } catch (error) { + // A bad token is a security event worth logging, not a 4xx worth retrying. + const detail = (error as Error).message; + if (error instanceof IamLogoutTokenError) { + this.logger.warn(`rejected a back-channel logout token: ${detail}`); + } else { + this.logger.error(`back-channel logout could not be processed: ${detail}`); + } + + return { status: 'ignored' }; + } + } +} diff --git a/apps/api/src/modules/iam/iam.module.ts b/apps/api/src/modules/iam/iam.module.ts new file mode 100644 index 0000000..46625d5 --- /dev/null +++ b/apps/api/src/modules/iam/iam.module.ts @@ -0,0 +1,19 @@ +import { Global, Module } from '@nestjs/common'; +import { IamController } from './iam.controller'; +import { IamLogoutService } from './iam-logout.service'; +import { IamService } from './iam.service'; + +/** + * Central IAM integration. + * + * Global so any module can delegate credential checks without wiring imports; + * the service itself is inert until `IAM_ENABLED=true`, so importing it costs + * nothing in a deployment that does not use the identity service. + */ +@Global() +@Module({ + controllers: [IamController], + providers: [IamService, IamLogoutService], + exports: [IamService, IamLogoutService], +}) +export class IamModule {} diff --git a/apps/api/src/modules/iam/iam.service.spec.ts b/apps/api/src/modules/iam/iam.service.spec.ts new file mode 100644 index 0000000..5045fbb --- /dev/null +++ b/apps/api/src/modules/iam/iam.service.spec.ts @@ -0,0 +1,213 @@ +import { + IamInvalidCredentialsError, + IamMfaRequiredError, + IamService, + IamUnavailableError, +} from './iam.service'; + +/** + * Credential delegation to the central identity service. + * + * The behaviour these lock down is the safety contract: the feature is inert + * until fully configured, and any uncertain outcome must leave the existing + * local password check in charge so an IAM problem can never lock admins out. + */ +describe('IamService', () => { + const ORIGINAL_ENV = process.env; + let service: IamService; + let fetchMock: jest.Mock; + + const configure = (overrides: Record = {}) => { + process.env = { + ...ORIGINAL_ENV, + IAM_ENABLED: 'true', + IAM_ISSUER: 'https://auth.test.local', + IAM_CLIENT_ID: 'goldbank_api', + IAM_CLIENT_SECRET: 'server-secret', + IAM_TENANT_SLUG: 'montzar', + IAM_LOGIN_DELEGATION: 'fallback', + ...overrides, + }; + }; + + const respondWith = (status: number, body: unknown) => { + fetchMock.mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response); + }; + + beforeEach(() => { + service = new IamService(); + fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + configure(); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + jest.restoreAllMocks(); + }); + + /* ─────────────── the switch ─────────────── */ + + it('is disabled by default', () => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.IAM_ENABLED; + + expect(new IamService().enabled).toBe(false); + }); + + it('stays disabled when the switch is on but configuration is incomplete', () => { + configure({ IAM_CLIENT_SECRET: '' }); + expect(service.enabled).toBe(false); + + configure({ IAM_ISSUER: '' }); + expect(service.enabled).toBe(false); + + configure({ IAM_CLIENT_ID: '' }); + expect(service.enabled).toBe(false); + + configure(); + expect(service.enabled).toBe(true); + }); + + it('treats an empty value as unset, like the rest of the codebase', () => { + // docker-compose passes unset optional variables as "" — see common/env.ts + configure({ IAM_ENABLED: ' ' }); + expect(service.enabled).toBe(false); + }); + + it('is disabled when delegation is explicitly turned off', () => { + configure({ IAM_LOGIN_DELEGATION: 'off' }); + expect(service.enabled).toBe(false); + }); + + it('falls back to the local password check unless exclusive mode is set', () => { + expect(service.allowsLocalFallback).toBe(true); + + configure({ IAM_LOGIN_DELEGATION: 'exclusive' }); + expect(service.allowsLocalFallback).toBe(false); + }); + + it('ignores an unknown delegation mode rather than failing', () => { + configure({ IAM_LOGIN_DELEGATION: 'nonsense' }); + expect(service.mode).toBe('fallback'); + }); + + /* ─────────────── request contract ─────────────── */ + + it('sends client credentials, tenant and the end-user IP', async () => { + respondWith(200, { status: 'SUCCESS', user: { id: 'sub-1' }, session_id: 'sid-1' }); + + await service.verifyCredentials('09120000000', 'secret', '203.0.113.7'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://auth.test.local/api/v1/auth/login'); + + const body = JSON.parse((init as RequestInit).body as string); + expect(body.identifier).toBe('09120000000'); + expect(body.client_id).toBe('goldbank_api'); + expect(body.client_secret).toBe('server-secret'); + expect(body.tenant_slug).toBe('montzar'); + // Without this the IAM would rate-limit on this server's IP and lock + // out every admin after a handful of attempts. + expect(body.client_ip).toBe('203.0.113.7'); + }); + + it('prefers tenant id over slug when both are configured', async () => { + configure({ IAM_TENANT_ID: '11111111-2222-3333-4444-555555555555' }); + respondWith(200, { status: 'SUCCESS', user: { id: 'sub-1' } }); + + await service.verifyCredentials('09120000000', 'secret'); + + const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(body.tenant_id).toBe('11111111-2222-3333-4444-555555555555'); + expect(body.tenant_slug).toBeUndefined(); + }); + + it('returns the identity on success', async () => { + respondWith(200, { + status: 'SUCCESS', + session_id: 'sid-9', + user: { + id: 'sub-9', + email: 'a@b.c', + phone_number: '09120000000', + first_name: 'رضا', + last_name: 'کاظمی', + }, + }); + + const identity = await service.verifyCredentials('09120000000', 'secret'); + + expect(identity).toEqual({ + sub: 'sub-9', + email: 'a@b.c', + phoneNumber: '09120000000', + firstName: 'رضا', + lastName: 'کاظمی', + sessionId: 'sid-9', + }); + }); + + /* ─────────────── failure mapping ─────────────── */ + + it('maps a rejected password to an invalid-credentials error', async () => { + respondWith(401, { error: 'invalid_credentials' }); + + await expect(service.verifyCredentials('09120000000', 'bad')).rejects.toBeInstanceOf( + IamInvalidCredentialsError, + ); + }); + + it('treats a locked account as a definite rejection, not an outage', async () => { + // Falling back to the local password here would defeat the IAM's lockout. + respondWith(403, { error: 'account_locked' }); + + await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( + IamInvalidCredentialsError, + ); + }); + + it('maps a two-factor challenge to its own error', async () => { + respondWith(200, { status: 'MFA_REQUIRED', mfa_session_token: 'x' }); + + await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( + IamMfaRequiredError, + ); + }); + + it('maps a server error to an availability problem', async () => { + respondWith(500, {}); + + await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( + IamUnavailableError, + ); + }); + + it('maps rate limiting to an availability problem so login still falls back', async () => { + respondWith(429, { error: 'rate_limited' }); + + await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( + IamUnavailableError, + ); + }); + + it('maps a network failure to an availability problem', async () => { + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( + IamUnavailableError, + ); + }); + + it('rejects an unexpected success payload instead of trusting it', async () => { + respondWith(200, { status: 'SOMETHING_ELSE' }); + + await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf( + IamUnavailableError, + ); + }); +}); diff --git a/apps/api/src/modules/iam/iam.service.ts b/apps/api/src/modules/iam/iam.service.ts new file mode 100644 index 0000000..82d823f --- /dev/null +++ b/apps/api/src/modules/iam/iam.service.ts @@ -0,0 +1,249 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { envOr, envOrNull } from '../../common/env'; + +/** + * Credential delegation to the central identity service (Central IAM). + * + * The point of this module is that **nothing in the UI changes**. Admins keep + * typing their phone and password into this app's own login form; when the + * feature is switched on, this service checks those credentials against the + * IAM instead of (or before) the local password hash. There is no redirect, no + * SSO button and no extra screen. + * + * Safety first: every uncertain outcome (service down, user unknown there, + * rate limited) falls back to the existing local check, so turning this on can + * never lock admins out. Only `exclusive` mode refuses the local password. + */ + +export type IamDelegationMode = 'off' | 'fallback' | 'exclusive'; + +export interface IamIdentity { + sub: string; + email: string | null; + phoneNumber: string | null; + firstName: string | null; + lastName: string | null; + sessionId: string | null; +} + +/** The identity service rejected the credentials (unknown user or wrong password). */ +export class IamInvalidCredentialsError extends Error {} + +/** The identity service could not be reached or failed; not the user's fault. */ +export class IamUnavailableError extends Error {} + +/** The account has two-factor enabled there, which this UI-less path cannot collect. */ +export class IamMfaRequiredError extends Error {} + +/** The identity service does not know this user — it never creates accounts. */ +export class IamUserUnknownError extends Error {} + +/** + * The identity service refused this identity outright (locked, deactivated, or + * two-factor). Unlike an unknown user, this is a decision that must be honoured + * rather than worked around locally. + */ +export class IamAccessDeniedError extends Error { + constructor( + message: string, + readonly reason: string, + ) { + super(message); + } +} + +@Injectable() +export class IamService { + private readonly logger = new Logger(IamService.name); + + get issuer(): string { + return (envOrNull('IAM_ISSUER') ?? '').replace(/\/$/, ''); + } + + get clientId(): string | null { + return envOrNull('IAM_CLIENT_ID'); + } + + get mode(): IamDelegationMode { + const value = envOr('IAM_LOGIN_DELEGATION', 'fallback'); + return value === 'off' || value === 'exclusive' ? value : 'fallback'; + } + + /** + * Enabled only when the switch is on **and** the minimum configuration is + * present. A half-configured deployment behaves exactly like an unconfigured + * one rather than failing in confusing ways at login time. + */ + get enabled(): boolean { + return ( + envOr('IAM_ENABLED', 'false').toLowerCase() === 'true' && + this.issuer !== '' && + this.clientId !== null && + envOrNull('IAM_CLIENT_SECRET') !== null && + this.mode !== 'off' + ); + } + + /** Should an unknown user / wrong password still be checked locally? */ + get allowsLocalFallback(): boolean { + return this.mode !== 'exclusive'; + } + + /** + * Verify a phone + password pair against the identity service. + * + * `clientIp` is forwarded so the IAM applies its per-IP rate limit to the + * real end user. Without it every login would share this server's IP and a + * handful of attempts would lock out the whole admin panel. + */ + async verifyCredentials( + identifier: string, + password: string, + clientIp?: string, + ): Promise { + const body: Record = { + identifier, + password, + client_id: this.clientId as string, + client_secret: envOrNull('IAM_CLIENT_SECRET') as string, + }; + + const tenantId = envOrNull('IAM_TENANT_ID'); + const tenantSlug = envOrNull('IAM_TENANT_SLUG'); + if (tenantId) body.tenant_id = tenantId; + else if (tenantSlug) body.tenant_slug = tenantSlug; + if (clientIp) body.client_ip = clientIp; + + const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000')); + + let response: Response; + try { + response = await fetch(`${this.issuer}/api/v1/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeout), + }); + } catch (error) { + throw new IamUnavailableError( + `identity service unreachable: ${(error as Error).message}`, + ); + } + + const payload = (await response.json().catch(() => ({}))) as Record; + + if (response.ok) { + if (payload.status === 'MFA_REQUIRED') { + throw new IamMfaRequiredError('two-factor authentication required'); + } + if (payload.status !== 'SUCCESS') { + throw new IamUnavailableError('unexpected response from identity service'); + } + + const user = (payload.user ?? {}) as Record; + return { + sub: String(user.id ?? ''), + email: user.email ?? null, + phoneNumber: user.phone_number ?? null, + firstName: user.first_name ?? null, + lastName: user.last_name ?? null, + sessionId: payload.session_id ?? null, + }; + } + + // 401/403/422 mean the credentials themselves were not accepted. + if ([401, 403, 422].includes(response.status)) { + const code = String(payload.error ?? ''); + if (code === 'account_locked' || code === 'user_inactive') { + // A locked account is a definite answer, not an "unknown user" — + // falling back to the local password would defeat the lockout. + throw new IamInvalidCredentialsError(code); + } + throw new IamInvalidCredentialsError('invalid_credentials'); + } + + this.logger.warn( + `identity service returned ${response.status} for a delegated login`, + ); + throw new IamUnavailableError(`identity service returned ${response.status}`); + } + + /** + * Report a one-time code this app generated, sent and verified itself. + * + * The app's login has **no password**, so `verifyCredentials` cannot be used: + * there is nothing to send. Without this call, an OTP login never reaches the + * identity service at all — no central session, no entry in the security log + * and no way for a central sign-out to end it. + * + * The IAM only accepts this from a trusted confidential client carrying the + * explicit `passwordless_delegation_allowed` flag, and it never creates an + * account: an unknown user is reported as such so this app can carry on with + * its own local login. + */ + async attestOtpLogin( + identifier: string, + clientIp?: string, + factorReference?: string | null, + ): Promise { + const body: Record = { + identifier, + client_id: this.clientId as string, + client_secret: envOrNull('IAM_CLIENT_SECRET') as string, + factor: 'sms_otp', + }; + + const tenantId = envOrNull('IAM_TENANT_ID'); + const tenantSlug = envOrNull('IAM_TENANT_SLUG'); + if (tenantId) body.tenant_id = tenantId; + else if (tenantSlug) body.tenant_slug = tenantSlug; + if (clientIp) body.client_ip = clientIp; + if (factorReference) body.factor_reference = factorReference; + + const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000')); + + let response: Response; + try { + response = await fetch(`${this.issuer}/api/v1/auth/login/delegated-otp`, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeout), + }); + } catch (error) { + throw new IamUnavailableError(`identity service unreachable: ${(error as Error).message}`); + } + + const payload = (await response.json().catch(() => ({}))) as Record; + + if (response.ok) { + if (payload.status !== 'SUCCESS') { + throw new IamUnavailableError('unexpected response from identity service'); + } + const user = (payload.user ?? {}) as Record; + return { + sub: String(user.id ?? ''), + email: user.email ?? null, + phoneNumber: user.phone_number ?? null, + firstName: user.first_name ?? null, + lastName: user.last_name ?? null, + sessionId: payload.session_id ?? null, + }; + } + + const code = String(payload.error ?? ''); + + // Not migrated to the IAM yet: this app stays the authority for them. + if (response.status === 401 && code === 'user_not_found') { + throw new IamUserUnknownError(identifier); + } + + // Locked, deactivated or two-factor: a decision, not a gap. Honour it. + if ([401, 403, 422].includes(response.status)) { + throw new IamAccessDeniedError(`identity service refused the login: ${code}`, code || 'denied'); + } + + this.logger.warn(`identity service returned ${response.status} for an OTP attestation`); + throw new IamUnavailableError(`identity service returned ${response.status}`); + } +} diff --git a/apps/api/src/modules/sessions/admin-session.module.ts b/apps/api/src/modules/sessions/admin-session.module.ts new file mode 100644 index 0000000..dadc70f --- /dev/null +++ b/apps/api/src/modules/sessions/admin-session.module.ts @@ -0,0 +1,19 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminSessionService } from './admin-session.service'; +import { AppSessionService } from './app-session.service'; + +/** + * Revocable admin sessions. + * + * Global so every login path (admin panel and app) and the IAM + * back-channel-logout endpoint can use these without wiring imports. Both + * services are inert while tracking is off (see AdminSessionService). + */ +@Global() +@Module({ + imports: [PrismaModule], + providers: [AdminSessionService, AppSessionService], + exports: [AdminSessionService, AppSessionService], +}) +export class AdminSessionModule {} diff --git a/apps/api/src/modules/sessions/admin-session.service.spec.ts b/apps/api/src/modules/sessions/admin-session.service.spec.ts new file mode 100644 index 0000000..83ac3ea --- /dev/null +++ b/apps/api/src/modules/sessions/admin-session.service.spec.ts @@ -0,0 +1,164 @@ +import { AdminSessionService } from './admin-session.service'; +import type { IamService } from '../iam/iam.service'; + +/** + * Revocable admin sessions. + * + * Two contracts are locked down here. First, with tracking off nothing changes: + * no `jti`, no row, and no extra query on an authenticated request. Second, with + * it on, a revoked session stops working immediately — which is the whole point, + * because before this table an issued token was usable for its full 12h no matter + * what happened centrally. + */ +describe('AdminSessionService', () => { + const ORIGINAL_ENV = process.env; + + const build = (opts: { iamEnabled?: boolean; tracking?: string | null } = {}) => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.AUTH_SESSION_TRACKING; + if (opts.tracking != null) process.env.AUTH_SESSION_TRACKING = opts.tracking; + + const prisma = { + adminSession: { + create: jest.fn().mockResolvedValue({}), + findUnique: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 2 }), + }, + }; + const iam = { enabled: opts.iamEnabled ?? false } as IamService; + + return { service: new AdminSessionService(prisma as never, iam), prisma }; + }; + + afterEach(() => { + process.env = ORIGINAL_ENV; + }); + + /* ─────────────── off by default ─────────────── */ + + it('is off in a deployment that does not use the identity service', () => { + expect(build().service.enabled).toBe(false); + }); + + it('turns itself on with the identity service, so central revocation reaches us', () => { + expect(build({ iamEnabled: true }).service.enabled).toBe(true); + }); + + it('can be turned on on its own', () => { + expect(build({ tracking: 'true' }).service.enabled).toBe(true); + }); + + it('can be turned off explicitly even with the identity service on', () => { + // Opting out of the per-request lookup; central revocation then cannot reach here + expect(build({ iamEnabled: true, tracking: 'false' }).service.enabled).toBe(false); + }); + + /* ─────────────── unchanged behaviour while off ─────────────── */ + + it('issues no token id and writes no row while off', async () => { + const { service, prisma } = build(); + + expect(service.newTokenId()).toBeNull(); + await service.record({ jti: 'x', adminUserId: 'a' }); + + expect(prisma.adminSession.create).not.toHaveBeenCalled(); + }); + + it('authenticates without a database lookup while off', async () => { + const { service, prisma } = build(); + + await expect(service.isActive('some-jti')).resolves.toBe(true); + expect(prisma.adminSession.findUnique).not.toHaveBeenCalled(); + }); + + /* ─────────────── enforcement while on ─────────────── */ + + it('accepts a live session', async () => { + const { service, prisma } = build({ iamEnabled: true }); + prisma.adminSession.findUnique.mockResolvedValue({ revokedAt: null }); + + await expect(service.isActive('jti-1')).resolves.toBe(true); + }); + + it('refuses a revoked session', async () => { + const { service, prisma } = build({ iamEnabled: true }); + prisma.adminSession.findUnique.mockResolvedValue({ revokedAt: new Date() }); + + // The gap this closes: previously the token kept working until it expired + await expect(service.isActive('jti-1')).resolves.toBe(false); + }); + + it('refuses a token whose session row is gone', async () => { + const { service, prisma } = build({ iamEnabled: true }); + prisma.adminSession.findUnique.mockResolvedValue(null); + + await expect(service.isActive('jti-1')).resolves.toBe(false); + }); + + it('keeps tokens issued before tracking was switched on working', async () => { + const { service, prisma } = build({ iamEnabled: true }); + + // Such a token carries no jti; refusing it would sign every admin out at once + await expect(service.isActive(undefined)).resolves.toBe(true); + expect(prisma.adminSession.findUnique).not.toHaveBeenCalled(); + }); + + /* ─────────────── recording and revoking ─────────────── */ + + it('records the session with an expiry derived from the token lifetime', async () => { + process.env.JWT_EXPIRES_IN = '12h'; + const { service, prisma } = build({ iamEnabled: true }); + process.env.JWT_EXPIRES_IN = '12h'; + + await service.record({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1' }); + + const data = prisma.adminSession.create.mock.calls[0][0].data; + expect(data).toMatchObject({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1' }); + expect(data.expiresAt.getTime()).toBeGreaterThan(Date.now() + 11 * 3600 * 1000); + }); + + it('never breaks login when the session row cannot be written', async () => { + const { service, prisma } = build({ iamEnabled: true }); + prisma.adminSession.create.mockRejectedValue(new Error('unique violation')); + + // Worst case is a session that cannot be revoked early — not a failed login + await expect(service.record({ jti: 'jti-1', adminUserId: 'a' })).resolves.toBeUndefined(); + }); + + it('revokes by the identity service session id', async () => { + const { service, prisma } = build({ iamEnabled: true }); + + await expect(service.revokeByIamSession('sid-1')).resolves.toBe(2); + expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({ + where: { iamSessionId: 'sid-1', revokedAt: null }, + data: expect.objectContaining({ revokedReason: 'iam_backchannel_logout' }), + }); + }); + + it('revokes every session of an identity when the token names only a subject', async () => { + const { service, prisma } = build({ iamEnabled: true }); + + await service.revokeByIamSubject('sub-1'); + expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({ + where: { iamSub: 'sub-1', revokedAt: null }, + data: expect.objectContaining({ revokedReason: 'iam_backchannel_logout' }), + }); + }); + + it('revokes every session of a local admin', async () => { + const { service, prisma } = build({ iamEnabled: true }); + + await service.revokeAllForAdmin('admin-1', 'account_disabled'); + expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({ + where: { adminUserId: 'admin-1', revokedAt: null }, + data: expect.objectContaining({ revokedReason: 'account_disabled' }), + }); + }); + + it('does not touch anything while off', async () => { + const { service, prisma } = build(); + + await expect(service.revokeByIamSession('sid-1')).resolves.toBe(0); + expect(prisma.adminSession.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/sessions/admin-session.service.ts b/apps/api/src/modules/sessions/admin-session.service.ts new file mode 100644 index 0000000..14d9800 --- /dev/null +++ b/apps/api/src/modules/sessions/admin-session.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { PrismaService } from '../../prisma/prisma.service'; +import { envOr } from '../../common/env'; +import { IamService } from '../iam/iam.service'; + +/** + * Revocable admin sessions. + * + * An admin token is self-contained and lives for 12h, so until now nothing could + * end a session early: deactivating the account or signing the user out centrally + * left every already-issued token working until it expired. Each issued token now + * carries a `jti` recorded on AdminSession, and JwtStrategy refuses a token whose + * row is revoked or gone. + * + * The previous behaviour is preserved exactly when tracking is off: no row is + * written, no extra query runs on authentication, and tokens keep working as + * before. Tracking turns itself on when the IAM is enabled — without it a central + * revocation would silently do nothing here — and can be turned on independently + * with `AUTH_SESSION_TRACKING=true`. + */ +@Injectable() +export class AdminSessionService { + private readonly logger = new Logger(AdminSessionService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly iam: IamService, + ) {} + + get enabled(): boolean { + if (envOr('AUTH_SESSION_TRACKING', '').toLowerCase() === 'true') return true; + // Explicit opt-out for a deployment that uses the IAM but does not want the + // per-request lookup; central revocation then cannot reach this app. + if (envOr('AUTH_SESSION_TRACKING', '').toLowerCase() === 'false') return false; + return this.iam.enabled; + } + + /** A fresh `jti` for a token about to be signed, or null while tracking is off. */ + newTokenId(): string | null { + return this.enabled ? randomUUID() : null; + } + + /** + * Record an issued token. A failure here must not break login, so it is logged + * and swallowed: the worst case is a session that cannot be revoked early, + * which is exactly the behaviour before this table existed. + */ + async record(input: { + jti: string; + adminUserId: string; + iamSessionId?: string | null; + iamSub?: string | null; + ipAddress?: string | null; + userAgent?: string | null; + }): Promise { + if (!this.enabled) return; + + try { + await this.prisma.adminSession.create({ + data: { + jti: input.jti, + adminUserId: input.adminUserId, + iamSessionId: input.iamSessionId ?? null, + iamSub: input.iamSub ?? null, + ipAddress: input.ipAddress ?? null, + userAgent: input.userAgent ?? null, + expiresAt: this.tokenExpiry(), + }, + }); + } catch (error) { + this.logger.warn(`could not record the admin session: ${(error as Error).message}`); + } + } + + /** + * Is this token still usable? Called on every authenticated request. + * + * A token signed before tracking was switched on carries no `jti`; it stays + * valid until it expires, so enabling the feature does not sign everyone out. + */ + async isActive(jti: string | undefined): Promise { + if (!this.enabled || !jti) return true; + + const session = await this.prisma.adminSession.findUnique({ + where: { jti }, + select: { revokedAt: true }, + }); + + // A missing row means the session was deleted (or the token was signed + // elsewhere): refuse rather than assume. + return session !== null && session.revokedAt === null; + } + + /** Revoke by the IAM's session id (`sid` in a back-channel logout token). */ + async revokeByIamSession(iamSessionId: string, reason = 'iam_backchannel_logout'): Promise { + return this.revokeWhere({ iamSessionId }, reason); + } + + /** Revoke every session of an IAM identity (a logout token carrying only `sub`). */ + async revokeByIamSubject(iamSub: string, reason = 'iam_backchannel_logout'): Promise { + return this.revokeWhere({ iamSub }, reason); + } + + /** Revoke every session of a local admin (account disabled, password changed). */ + async revokeAllForAdmin(adminUserId: string, reason = 'revoked'): Promise { + return this.revokeWhere({ adminUserId }, reason); + } + + private async revokeWhere( + where: { iamSessionId?: string; iamSub?: string; adminUserId?: string }, + reason: string, + ): Promise { + if (!this.enabled) return 0; + + const result = await this.prisma.adminSession.updateMany({ + where: { ...where, revokedAt: null }, + data: { revokedAt: new Date(), revokedReason: reason }, + }); + + return result.count; + } + + /** + * When the recorded token expires, mirroring JWT_EXPIRES_IN so old rows can be + * pruned. Only `h` / `m` / `d` / `s` are understood — anything else + * leaves the column null rather than guessing. + */ + private tokenExpiry(): Date | null { + const raw = envOr('JWT_EXPIRES_IN', '12h'); + const match = /^(\d+)\s*([smhd])$/.exec(raw); + if (!match) return null; + + const seconds = Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2] as 's' | 'm' | 'h' | 'd']; + return new Date(Date.now() + seconds * 1000); + } +} diff --git a/apps/api/src/modules/sessions/app-session.service.ts b/apps/api/src/modules/sessions/app-session.service.ts new file mode 100644 index 0000000..fa59ca7 --- /dev/null +++ b/apps/api/src/modules/sessions/app-session.service.ts @@ -0,0 +1,130 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { PrismaService } from '../../prisma/prisma.service'; +import { envOr } from '../../common/env'; +import { IamService } from '../iam/iam.service'; + +/** What an app token's `sub` points at — a Customer, or an AdminUser (D21). */ +export type AppSubjectType = 'customer' | 'sales-expert'; + +/** + * Revocable app sessions (customer app and sales-expert app). + * + * The counterpart of AdminSessionService for the other token family. It matters + * more here: the app's login is OTP-only, so there is no password an operator + * could change to cut off a stolen 30-day token — before this table, nothing + * could end an app session early. + * + * Exactly as with the admin side, everything is inert while tracking is off: no + * `jti`, no row, and no extra query on the authenticated path. + */ +@Injectable() +export class AppSessionService { + private readonly logger = new Logger(AppSessionService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly iam: IamService, + ) {} + + get enabled(): boolean { + const flag = envOr('AUTH_SESSION_TRACKING', '').toLowerCase(); + if (flag === 'true') return true; + if (flag === 'false') return false; + return this.iam.enabled; + } + + newTokenId(): string | null { + return this.enabled ? randomUUID() : null; + } + + /** + * Record an issued token. A failure must not break the login: the worst case + * is a session that cannot be revoked early, which is the behaviour that + * existed before this table. + */ + async record(input: { + jti: string; + subjectId: string; + subjectType: AppSubjectType; + mobile?: string | null; + iamSessionId?: string | null; + iamSub?: string | null; + ipAddress?: string | null; + userAgent?: string | null; + }): Promise { + if (!this.enabled) return; + + try { + await this.prisma.appSession.create({ + data: { + jti: input.jti, + subjectId: input.subjectId, + subjectType: input.subjectType, + mobile: input.mobile ?? null, + iamSessionId: input.iamSessionId ?? null, + iamSub: input.iamSub ?? null, + ipAddress: input.ipAddress ?? null, + userAgent: input.userAgent ?? null, + expiresAt: this.tokenExpiry(), + }, + }); + } catch (error) { + this.logger.warn(`could not record the app session: ${(error as Error).message}`); + } + } + + /** + * Is this token still usable? A token signed before tracking was switched on + * carries no `jti` and stays valid until it expires, so enabling the feature + * does not sign every app user out at once. + */ + async isActive(jti: string | undefined): Promise { + if (!this.enabled || !jti) return true; + + const session = await this.prisma.appSession.findUnique({ + where: { jti }, + select: { revokedAt: true }, + }); + + return session !== null && session.revokedAt === null; + } + + /** Revoke by the IAM's session id (`sid` in a back-channel logout token). */ + revokeByIamSession(iamSessionId: string, reason = 'iam_backchannel_logout'): Promise { + return this.revokeWhere({ iamSessionId }, reason); + } + + /** Revoke every session of an IAM identity (a logout token carrying only `sub`). */ + revokeByIamSubject(iamSub: string, reason = 'iam_backchannel_logout'): Promise { + return this.revokeWhere({ iamSub }, reason); + } + + /** Revoke every app session of one subject (customer archived/deactivated). */ + revokeAllForSubject(subjectId: string, reason = 'revoked'): Promise { + return this.revokeWhere({ subjectId }, reason); + } + + private async revokeWhere( + where: { iamSessionId?: string; iamSub?: string; subjectId?: string }, + reason: string, + ): Promise { + if (!this.enabled) return 0; + + const result = await this.prisma.appSession.updateMany({ + where: { ...where, revokedAt: null }, + data: { revokedAt: new Date(), revokedReason: reason }, + }); + + return result.count; + } + + /** Mirrors JWT_CUSTOMER_EXPIRES_IN so old rows can be pruned. */ + private tokenExpiry(): Date | null { + const match = /^(\d+)\s*([smhd])$/.exec(envOr('JWT_CUSTOMER_EXPIRES_IN', '30d')); + if (!match) return null; + + const seconds = Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2] as 's' | 'm' | 'h' | 'd']; + return new Date(Date.now() + seconds * 1000); + } +} diff --git a/docs/iam-connection.md b/docs/iam-connection.md new file mode 100644 index 0000000..bfc50f4 --- /dev/null +++ b/docs/iam-connection.md @@ -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` | diff --git a/docs/iam-sso.md b/docs/iam-sso.md new file mode 100644 index 0000000..1712c28 --- /dev/null +++ b/docs/iam-sso.md @@ -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 ورود ادمین را قطع نمی‌کند.