Feat add iam auth

This commit is contained in:
mahmood
2026-09-25 19:04:48 +03:30
parent 2de90c6b35
commit 36f239e02c
28 changed files with 2738 additions and 19 deletions
+81
View File
@@ -0,0 +1,81 @@
---
description: اتصال ورود ادمین به سامانه هویت متمرکز (IAM) — مرزها و قواعدی که نباید شکسته شوند
globs:
alwaysApply: true
---
# اتصال به سامانه هویت متمرکز (Central IAM)
مستندها:
- `docs/iam-connection.md` — **نحوه ارتباط**: سه کانال داده و رفتار در خطا
- `docs/iam-sso.md` — پیکربندی، ابطال نشست، محدودیت‌ها
- مرجع تصویری اندپوینت‌های IAM: `<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)
```
+38
View File
@@ -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-host>/api/iam/backchannel-logout
# ---------------------------------------------------------------------------
# ⚠ DELIVERY ONLY — REMOVE BEFORE THE REAL RELEASE (D20 / ISSUES REL-01)
# ---------------------------------------------------------------------------
@@ -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;
@@ -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");
+93
View File
@@ -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).
///
+5
View File
@@ -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,
@@ -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;
}
/**
+6 -3
View File
@@ -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. */
@@ -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<AdminSessionService> => ({
newTokenId: jest.fn().mockReturnValue(null),
record: jest.fn(),
});
const build = (
iam: Partial<IamService>,
user: unknown = adminRow(),
sessions: Partial<AdminSessionService> = 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<IamService> => ({
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<AdminSessionService> = {
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<AdminSessionService> = {
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('دو مرحله‌ای');
});
});
+86 -5
View File
@@ -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:
+19 -1
View File
@@ -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 } } },
@@ -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;
}
/**
@@ -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<string, unknown>, sessions?: Record<string, unknown>) => {
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();
});
});
@@ -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<IamIdentity | null> {
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<string> {
private async issueExpertToken(
adminUserId: string,
mobile: string,
context: IssueContext = {},
): Promise<string> {
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<string> {
private async issueToken(
customerId: string,
mobile: string,
context: IssueContext = {},
): Promise<string> {
// 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;
}
/**
@@ -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<AuthenticatedCustomer> {
// نشست باطل‌شده (خروج سراسری از IdP یا ابطال محلی). در حالت خاموشِ ردیابی،
// بدون هیچ کوئری true برمی‌گردد ⇒ هزینه و رفتار دقیقاً مثل قبل.
if (!(await this.sessions.isActive(payload?.jti))) {
throw new UnauthorizedException();
}
if (payload?.typ === EXPERT_TOKEN_TYPE) {
return this.validateExpert(payload);
}
@@ -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);
@@ -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<string, unknown>,
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<string, unknown> = {}) => ({
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');
});
});
@@ -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<string, KeyObject>();
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<LogoutSubject> {
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<string, unknown>): 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<string, unknown> | 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<KeyObject> {
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<void> {
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<Record<string, unknown>> };
const keys = new Map<string, KeyObject>();
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<string, unknown> {
try {
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')) as Record<string, unknown>;
} catch {
throw new IamLogoutTokenError(`logout token ${what} is not readable`);
}
}
}
@@ -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-host>/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<string, unknown>) {
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' };
}
}
}
+19
View File
@@ -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 {}
@@ -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<string, string> = {}) => {
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,
);
});
});
+249
View File
@@ -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<IamIdentity> {
const body: Record<string, string> = {
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<string, any>;
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<string, any>;
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<IamIdentity> {
const body: Record<string, string> = {
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<string, any>;
if (response.ok) {
if (payload.status !== 'SUCCESS') {
throw new IamUnavailableError('unexpected response from identity service');
}
const user = (payload.user ?? {}) as Record<string, any>;
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}`);
}
}
@@ -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 {}
@@ -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();
});
});
@@ -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<void> {
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<boolean> {
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<number> {
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<number> {
return this.revokeWhere({ iamSub }, reason);
}
/** Revoke every session of a local admin (account disabled, password changed). */
async revokeAllForAdmin(adminUserId: string, reason = 'revoked'): Promise<number> {
return this.revokeWhere({ adminUserId }, reason);
}
private async revokeWhere(
where: { iamSessionId?: string; iamSub?: string; adminUserId?: string },
reason: string,
): Promise<number> {
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 `<n>h` / `<n>m` / `<n>d` / `<n>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);
}
}
@@ -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<void> {
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<boolean> {
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<number> {
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<number> {
return this.revokeWhere({ iamSub }, reason);
}
/** Revoke every app session of one subject (customer archived/deactivated). */
revokeAllForSubject(subjectId: string, reason = 'revoked'): Promise<number> {
return this.revokeWhere({ subjectId }, reason);
}
private async revokeWhere(
where: { iamSessionId?: string; iamSub?: string; subjectId?: string },
reason: string,
): Promise<number> {
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);
}
}
+199
View File
@@ -0,0 +1,199 @@
# نحوه ارتباط gold-bank با سامانه هویت متمرکز (IAM)
این سند **مسیر داده** را توضیح می‌دهد: چه کسی، چه زمانی، چه چیزی را به کجا
می‌فرستد و اگر آن طرف جواب نداد چه اتفاقی می‌افتد.
برای پیکربندی و فعال‌سازی: [`iam-sso.md`](iam-sso.md) ·
مرجع تصویری اندپوینت‌های IAM: `http://<آدرس IAM>/docs`
---
## ۱. خلاصه در یک تصویر
```
پنل ادمین (Vue) apps/api (NestJS) Central IAM
─────────────── ───────────────── ───────────
فرم ورودِ همیشگی
(موبایل + رمز)
│ POST /api/admin/auth/login
├──────────────────────────────▶
│ AuthService.verifyPassword
│ │ POST /api/v1/auth/login
│ │ + client_id/secret + client_ip
│ ├────────────────────────────────▶
│ │◀────────────────────────────────┤
│ │ SUCCESS (sub، sid) │ 401 │ خطا
│ │
│ AdminSession: ثبت jti + sid + sub
│◀─────────────────────────────┤ همان پاسخ همیشگی (api_token, roles)
```
سه نکته که کل طراحی را توضیح می‌دهد:
1. **مرورگر ادمین هرگز به IAM نمی‌رود.** نه ریدایرکت، نه دکمه SSO، نه صفحه جدید.
2. **قالب پاسخ ورود تغییر نکرده است** (`api_token`, `roles`, …)، پس پنل تفاوتی نمی‌بیند.
3. **نگاشت کاربر با `mobile` انجام می‌شود.** هیچ ستونی به `AdminUser` اضافه نشده؛
شناسه‌های IAM روی جدول **جدید** `AdminSession` می‌نشینند.
> **اپ کاربر (`bnk.montzar.ir`) هم متصل است**، ولی از مسیر دیگری: ورودش فقط با کد
> یکبارمصرف است و رمزی ندارد که به IAM فرستاده شود. کد را همین سامانه تولید،
> ارسال و بررسی می‌کند و سپس نتیجه را به IAM اعلام می‌کند
> (`/api/v1/auth/login/delegated-otp`) تا نشست مرکزی، لاگ امنیتی و خروج سراسری
> شامل آن ورود هم بشود. توکن اپ هم مثل توکن پنل `jti` می‌گیرد و در جدول
> `AppSession` ثبت می‌شود، پس قابل ابطال است.
---
## ۲. کانال‌های ارتباطی (همه چیزی که رد و بدل می‌شود)
| # | جهت | مسیر | چه زمانی | چه چیزی می‌رود | اگر خطا بدهد |
| --- | --- | --- | --- | --- | --- |
| ۱ | ما → IAM | `POST /api/v1/auth/login` | هر ورود ادمین | موبایل، رمز، `client_id`، `client_secret`، `client_ip` | طبق `IAM_LOGIN_DELEGATION` (پیش‌فرض: `bcrypt` محلی) |
| ۱.۵ | ما → IAM | `POST /api/v1/auth/login/delegated-otp` | **ورود اپ کاربر** (`/auth/verify-otp`) — این ورود رمز ندارد | موبایل، اعتبارنامه کلاینت، `client_ip`، شماره ماسک‌شده | کاربر آنجا نیست یا سرویس down ⇒ ورود محلی ادامه؛ قفل/غیرفعال/2FA ⇒ ورود رد می‌شود |
| ۲ | ما → IAM | `GET /.well-known/jwks.json` | تأیید توکن خروج سراسری (کش ۲۴ ساعته، `kid` ناشناس ⇒ یک‌بار refetch) | — | اعلان خروج پذیرفته نمی‌شود (۲۰۰ + لاگ) |
| ۳ | **IAM → ما** | `POST /api/iam/backchannel-logout` | خروج سراسری یا ابطال نشست توسط ادمینِ IAM | `logout_token` امضاشده (RS256) | همیشه ۲۰۰؛ دلیل رد فقط در لاگ |
فقط همین سه کانال. جریان مرورگری OAuth در این سامانه استفاده نمی‌شود، چون قرار
است رابط کاربری ورود دست‌نخورده بماند.
---
## ۳. جریان ۱ — ورود ادمین
```
POST /api/admin/auth/login { phone, password }
│
├─ AdminUser با این موبایل هست و فعال است؟ (نه ⇒ ۴۰۱ مبهم، مثل قبل)
│
├─ AuthService.verifyPassword
│ ├─ IAM خاموش ⇒ یک bcrypt.compare (دقیقاً رفتار قبلی، بدون هیچ درخواست شبکه)
│ ├─ IAM تأیید کرد ⇒ { ok: true, identity: { sub, sessionId } }
│ ├─ IAM رد کرد (کاربر آنجا نیست/رمز فرق دارد)
│ │ ├─ fallback ⇒ bcrypt محلی
│ │ └─ exclusive ⇒ ۴۰۱
│ ├─ حساب در IAM قفل است ⇒ **رد قطعی** (قفل IAM دور زده نمی‌شود)
│ ├─ IAM در دسترس نیست / ۴۲۹
│ │ ├─ fallback ⇒ bcrypt محلی
│ │ └─ exclusive ⇒ ۴۰۱ با پیام روشن
│ └─ حساب دو مرحله‌ای است ⇒ پیام روشن (این فرم مرحله کد ندارد)
│
├─ jti تازه (فقط وقتی ردیابی نشست روشن است)
├─ امضای همان JWT همیشگی { sub, mobile, jti? }
├─ AdminSession: { jti, adminUserId, iamSessionId, iamSub, ip, userAgent }
│
└─ پاسخ: همان قالب قبلی
```
**چرا `client_ip` حیاتی است:** محدودیت نرخ IAM روی IP اعمال می‌شود. بدون IP کاربر
نهایی، همه ورودها از IP همین سرور دیده می‌شوند و بعد از چند تلاش ناموفق، **کل**
پنل ادمین ۴۲۹ می‌گیرد. اگر API پشت پراکسی است، `trust proxy` را تنظیم کنید وگرنه
`@Ip()` همیشه IP پراکسی را می‌دهد و همان مشکل برمی‌گردد.
---
## ۴. جریان ۲ — اعتبارسنجی هر درخواست (نشست قابل ابطال)
```
هر درخواست با Bearer توکن
│
├─ JwtStrategy: امضا و انقضا (مثل قبل)
├─ توکن از نوع اپ مشتری نباشد (typ)
│
├─ AdminSessionService.isActive(jti)
│ ├─ ردیابی خاموش ⇒ true، بدون هیچ کوئری (هزینه دقیقاً مثل قبل)
│ ├─ توکن بدون jti (صادرشده پیش از فعال‌سازی) ⇒ true تا انقضا
│ ├─ ردیف AdminSession باطل شده یا وجود ندارد ⇒ ۴۰۱
│ └─ سالم ⇒ ادامه
│
└─ AdminUser + مجوزها از دیتابیس (مثل قبل، تا تغییر دسترسی فوری اثر کند)
```
`AUTH_SESSION_TRACKING`: خالی = همراه `IAM_ENABLED` · `true` = روشن بدون IAM ·
`false` = خاموش (ابطال مرکزی دیگر به اینجا نمی‌رسد).
---
## ۵. جریان ۳ — خروج سراسری (تنها مسیر ورودی از IAM)
```
IAM: خروج کاربر یا «بستن همه نشست‌ها» توسط ادمین
│ logout_token امضاشده با RS256 (application/x-www-form-urlencoded)
▼
POST /api/iam/backchannel-logout ← بدون احراز هویت، عمداً
│
├─ IamLogoutService.verify
│ ├─ alg باید RS256 باشد (alg=none رد می‌شود)
│ ├─ امضا با کلید عمومی JWKS
│ ├─ iss برابر issuer پیکربندی‌شده
│ ├─ aud شامل client_id ما
│ ├─ events شامل رخداد back-channel logout (تا ID Token جای آن نرود)
│ ├─ nonce نباید وجود داشته باشد
│ └─ exp/iat با تحمل ۶۰ ثانیه انحراف ساعت
│
└─ AdminSessionService
├─ sid دارد ⇒ همان نشست باطل می‌شود
└─ فقط sub دارد ⇒ همه نشست‌های آن هویت
⇒ درخواست بعدی با آن توکن ۴۰۱ می‌گیرد
```
پاسخ **همیشه ۲۰۰** است (حتی برای توکن نامعتبر) تا IdP بی‌نهایت تلاش مجدد نکند؛
دلیل رد شدن در لاگ سرویس می‌آید.
آدرسی که باید در IAM برای این کلاینت ثبت شود:
```
backchannel_logout_uri = https://<آدرس این API>/api/iam/backchannel-logout
```
ابطال محلی هم به همین مسیر وصل است: غیرفعال کردن حساب یا تغییر رمز یک ادمین
(`AdminUsersService.update`) نشست‌های او را می‌بندد.
---
## ۶. نگاشت هویت‌ها
| در IAM | در gold-bank | قاعده |
| --- | --- | --- |
| موبایل | `AdminUser.mobile` | تنها معیار نگاشت کاربر — هیچ ستون جدیدی روی این جدول نیست |
| `sub` | `AdminSession.iamSub` | برای خروج سراسریِ «همه نشست‌های این هویت» |
| `sid` | `AdminSession.iamSessionId` | برای ابطال دقیقاً همان نشست |
| نقش‌ها | `UserType` / `Permission` محلی | از IAM **خوانده نمی‌شود**؛ دسترسی‌ها مال همین سامانه است |
**چرا شناسه‌های IAM روی جدول جدید نشستند و نه روی `AdminUser`:** تا فعال کردن این
قابلیت هیچ تغییری در جدول‌های موجود لازم نداشته باشد و برگرداندنش هم به همان
سادگی باشد. اگر روزی فیلد دیگری از IAM لازم شد، همین الگو ادامه یابد.
---
## ۷. رفتار در خطا (خلاصه تصمیم‌ها)
| وضعیت | `fallback` (پیش‌فرض) | `exclusive` |
| --- | --- | --- |
| IAM خاموش/ناقص | یک bcrypt، بدون شبکه — رفتار کاملاً قبلی | همان |
| IAM در دسترس نیست | bcrypt محلی؛ ورود ادمین‌ها قطع نمی‌شود | ۴۰۱ «سرویس هویت در دسترس نیست» |
| کاربر در IAM نیست | bcrypt محلی (مناسب مهاجرت تدریجی) | ۴۰۱ |
| رمز در IAM غلط است | bcrypt محلی | ۴۰۱ |
| حساب در IAM قفل است | **۴۰۱** — قفل IAM دور زده نمی‌شود | ۴۰۱ |
| حساب دو مرحله‌ای است | ۴۰۱ با پیام روشن (این فرم مرحله کد ندارد) | همان |
| ثبت `AdminSession` ناموفق | ورود انجام می‌شود (فقط هشدار لاگ) — بدترین حالت: نشستی که زودتر قابل ابطال نیست | همان |
| توکن خروج نامعتبر | ۲۰۰ + هشدار لاگ؛ هیچ نشستی بسته نمی‌شود | همان |
---
## ۸. بررسی سریع اتصال
```bash
cd apps/api
npx jest src/modules/iam src/modules/auth src/modules/sessions # ۶۱ تست
npx prisma migrate deploy # جدول AdminSession
```
مشکلات پرتکرار:
| نشانه | علت |
| --- | --- |
| بعد از چند ورود همه ۴۲۹ می‌گیرند | `client_ip` نمی‌رسد؛ `trust proxy` تنظیم نشده |
| خروج سراسری بی‌اثر است | `backchannel_logout_uri` ثبت نشده، یا `AUTH_SESSION_TRACKING=false` |
| همه ادمین‌ها ۴۰۱ می‌گیرند | جدول `AdminSession` پاک شده — ردیف نبود یعنی «باطل» |
| `unsupported logout token algorithm` در لاگ | توکن با RS256 امضا نشده (تلاش جعل یا پیکربندی اشتباه IdP) |
| حساب دو مرحله‌ای وارد نمی‌شود | این پنل مرحله «ورود کد» ندارد — بخش ۷ سند `iam-sso.md` |
+206
View File
@@ -0,0 +1,206 @@
# اتصال gold-bank به سامانه هویت متمرکز (Central IAM)
> **نحوه ارتباط (مسیر داده، اندپوینت‌ها، رفتار در خطا):** [`iam-connection.md`](iam-connection.md)
> **مرجع تصویری API سامانه هویت:** `<آدرس IAM>/docs`
این سند توضیح می‌دهد ورود ادمین چطور می‌تواند به‌جای رمز محلی، از سامانه هویت
سازمانی اعتبارسنجی شود — **بدون هیچ تغییری در رابط کاربری**.
---
## ۱. خلاصه تصمیم
| موضوع | تصمیم |
| --- | --- |
| رابط کاربری | **هیچ تغییری نمی‌کند**؛ نه دکمه SSO، نه ریدایرکت، نه صفحه جدید |
| جای اتصال | فقط همان یک نقطه‌ای که رمز بررسی می‌شد (`AuthService.login`) |
| پیش‌فرض | خاموش (`IAM_ENABLED=false`) — رفتار سامانه دقیقاً مثل امروز |
| در دسترس نبودن IAM | بازگشت خودکار به بررسی رمز محلی ⇒ ورود ادمین‌ها قطع نمی‌شود |
| تغییر دیتابیس | دو جدول **جدید** (`AdminSession`، `AppSession`) برای ابطال نشست؛ هیچ جدول موجودی تغییر نکرده |
| ورود مشتری (OTP پیامکی) | کد همچنان اینجا تولید و بررسی می‌شود؛ نتیجه به IAM اعلام می‌گردد تا نشست و خروج سراسری مرکزی شود |
جریان ورود ادمین پس از فعال شدن:
```
پنل ادمین ──POST /api/admin/auth/login──▶ API (NestJS)
(همان فرم و همان بدنه) │
├─ IAM روشن؟ ──خیر──▶ bcrypt محلی (رفتار فعلی)
│
└─بله─▶ POST {IAM}/api/v1/auth/login
│
┌─────────────────┴─────────────────┐
تأیید شد رد/خطا/در دسترس نبود
│ │
صدور همان JWT محلی fallback ⇒ bcrypt محلی
exclusive ⇒ رد ورود
```
پاسخ اندپوینت در هر دو حالت **دقیقاً یکسان** است (`api_token`, `roles`, …)،
بنابراین پنل ادمین هیچ تفاوتی نمی‌بیند.
---
## ۲. فایل‌ها
| مسیر | نقش |
| --- | --- |
| `apps/api/src/modules/iam/iam.service.ts` | ارتباط با IAM و نگاشت خطاها |
| `apps/api/src/modules/iam/iam-logout.service.ts` | تأیید توکن خروج سراسری با JWKS (بدون وابستگی جدید) |
| `apps/api/src/modules/iam/iam.controller.ts` | دریافت اعلان خروج سراسری |
| `apps/api/src/modules/iam/iam.module.ts` | ماژول Global (تا زمان روشن شدن، بی‌اثر) |
| `apps/api/src/modules/sessions/admin-session.service.ts` | نشست‌های قابل ابطال (jti) |
| `apps/api/src/modules/sessions/admin-session.module.ts` | ماژول Global نشست‌ها |
| `prisma/migrations/20260925120000_admin_session/` | جدول `AdminSession` |
| `apps/api/src/modules/**/*.spec.ts` | تست هر چهار سرویس بالا |
تغییر روی کد موجود، حداقلی است:
- `auth.service.ts` — یک `bcrypt.compare` به متد `verifyPassword` منتقل شد و توکن
صادرشده (در صورت روشن بودن ردیابی نشست) ثبت می‌شود
- `auth.controller.ts` — افزودن `@Ip()` و `user-agent`
- `jwt.strategy.ts` — یک بررسی «نشست باطل نشده باشد» (در حالت خاموش، بدون کوئری)
- `admin-users.service.ts` — غیرفعال کردن حساب یا تغییر رمز، نشست‌ها را می‌بندد
- `app.module.ts` — ثبت `IamModule` و `AdminSessionModule`
- `prisma/schema.prisma` — فقط مدل جدید `AdminSession` (+ رابطه روی `AdminUser`)
---
## ۳. فعال‌سازی
### گام ۱ — ثبت این سامانه به‌عنوان کلاینت در IAM
باید کلاینت از نوع `confidential` و **`is_trusted: true`** باشد؛ فقط چنین
کلاینتی اجازه «ورود واگذارشده» دارد.
```bash
curl -X POST https://auth.example.com/api/v1/admin/clients \
-H "Authorization: Bearer $IAM_ADMIN_TOKEN" \
-H 'content-type: application/json' \
-d '{
"client_id": "goldbank_api",
"client_name": "سامانه بانک طلا",
"client_type": "confidential",
"redirect_uris": ["https://goldbank.example.com/unused"],
"allowed_scopes": ["openid", "profile", "email"],
"is_trusted": true
}'
```
> `redirect_uris` در این حالت استفاده نمی‌شود (جریان مرورگری نداریم) ولی
> IAM آن را اجباری می‌داند؛ یک آدرس معتبر بگذارید.
### گام ۲ — تنظیم `.env`
```dotenv
IAM_ENABLED=true
IAM_ISSUER=https://auth.example.com
IAM_CLIENT_ID=goldbank_api
IAM_CLIENT_SECRET=<راز دریافتی>
IAM_TENANT_SLUG=montzar
IAM_LOGIN_DELEGATION=fallback
```
همین. سرویس را ری‌استارت کنید؛ هیچ مایگریشنی لازم نیست.
### گام ۳ — بازگشت به عقب
`IAM_ENABLED=false` و ری‌استارت. تمام.
---
## ۴. حالت‌های `IAM_LOGIN_DELEGATION`
| مقدار | رفتار |
| --- | --- |
| `off` | IAM اصلاً در ورود دخالت نمی‌کند |
| `fallback` (پیش‌فرض) | اول IAM؛ اگر کاربر آنجا نبود یا سرویس در دسترس نبود، رمز محلی بررسی می‌شود — مناسب مهاجرت تدریجی |
| `exclusive` | فقط IAM؛ رمز محلی پذیرفته نمی‌شود و خرابی IAM یعنی توقف ورود |
**پیشنهاد مهاجرت:** با `fallback` شروع کنید، بعد از انتقال همه ادمین‌ها به IAM
و اطمینان از پایداری، به `exclusive` بروید.
---
## ۵. نکات عملیاتی
### محدودیت نرخ
IAM روی «IP» و «هویت» محدودیت نرخ دارد. چون درخواست‌ها از سرور API می‌آیند،
IP کاربر نهایی با فیلد `client_ip` فرستاده می‌شود؛ در غیر این صورت پس از چند
ورود، کل پنل ادمین ۴۲۹ می‌گرفت.
اگر API پشت پراکسی است، مطمئن شوید Nest `trust proxy` دارد وگرنه `@Ip()`
همیشه IP پراکسی را می‌دهد و همان مشکل برمی‌گردد.
### احراز دو مرحله‌ای
فرم ورود این پنل فقط «موبایل + رمز» است و **هیچ مرحله‌ی ورود کدی ندارد**؛ پس جایی
برای گرفتن کد عامل دوم وجود ندارد و این مسیر نمی‌تواند آن را تکمیل کند. ورود چنین
حسابی با پیام روشن رد می‌شود (نه خطای مرموز). سه گزینه:
1. برای حساب‌هایی که از این پنل وارد می‌شوند 2FA را در IAM فعال نکنید (ساده‌ترین)؛
2. مرحله‌ی «ورود کد» را به همین فرم اضافه کنید؛ آن‌وقت می‌توان مثل سامانه «همکار»
کد را واسطه کرد یا با کانال خودِ سامانه فرستاد
(`/api/v1/auth/mfa/verify` و `/api/v1/auth/mfa/delegate` در IAM آماده‌اند)؛
3. یا جریان مرورگری SSO را برای این پنل به کار بگیرید که خودش صفحه کد را می‌دهد.
> در سامانه «همکار» این محدودیت وجود ندارد، چون ورود کاربر عادی آنجا از قبل مرحله‌ی
> «ورود کد» (پیامک) دارد و همان مرحله برای عامل دوم استفاده می‌شود.
### ابطال نشست
توکن ادمین خودبسنده و ۱۲ ساعته است، پس تا پیش از این **هیچ‌چیز** نمی‌توانست نشست را
زودتر تمام کند: نه غیرفعال کردن حساب، نه خروج سراسری در IAM. حالا هر توکن صادرشده
یک `jti` دارد که در جدول `AdminSession` ثبت می‌شود و درخواست بعدیِ نشستِ باطل‌شده
۴۰۱ می‌گیرد.
```
IAM: ابطال نشست / خروج سراسری
│ logout_token (RS256، امضاشده)
▼
POST /api/iam/backchannel-logout ← این آدرس را در IAM به‌عنوان
│ backchannel_logout_uri کلاینت ثبت کنید
├─ تأیید امضا با JWKS، بررسی iss/aud/events و نبودن nonce
└─ ابطال نشست‌های متناظر (sid، یا همه نشست‌های sub)
│
▼
درخواست بعدی با آن توکن ⇒ ۴۰۱
```
کلید تنظیم: `AUTH_SESSION_TRACKING`
| مقدار | رفتار |
| --- | --- |
| خالی (پیش‌فرض) | همراه `IAM_ENABLED` روشن/خاموش می‌شود — تا ابطال مرکزی بی‌اثر نماند |
| `true` | روشن، حتی بدون IAM (ابطال محلی: غیرفعال کردن حساب، تغییر رمز) |
| `false` | خاموش؛ دقیقاً رفتار قبلی (بدون `jti`، بدون کوئری اضافه) و ابطال مرکزی بی‌اثر |
نکته‌های عملی:
- توکن‌هایی که **پیش از** روشن کردن این قابلیت صادر شده‌اند `jti` ندارند و تا انقضا
معتبر می‌مانند؛ یعنی روشن کردن این کلید کسی را یک‌باره بیرون نمی‌اندازد.
- ردیف نشستِ گم‌شده «باطل» تلقی می‌شود، پس پاک کردن جدول یعنی خروج همه.
- اندپوینت خروج سراسری عمداً بدون احراز هویت است (اعتبارش از امضای توکن می‌آید) و
همیشه ۲۰۰ برمی‌گرداند تا IdP بی‌نهایت تلاش مجدد نکند؛ دلیل رد شدن در لاگ می‌آید.
- تنها تغییر شِما یک جدول **جدید** است؛ هیچ جدول موجودی ستون نگرفته. مایگریشن:
`pnpm --filter @gold-bank/api exec prisma migrate deploy`.
### لاگ امنیتی
هر ورود واگذارشده در IAM با IP واقعی کاربر و `client_id` این سامانه ثبت
می‌شود، پس گزارش‌های امنیتی متمرکز کامل باقی می‌مانند.
---
## ۶. تست
```bash
cd apps/api
pnpm test # ۱۱۵ تست، بدون دیتابیس و بدون شبکه
pnpm test -- iam-logout.service # تأیید توکن خروج سراسری با کلید RSA واقعی
pnpm test -- admin-session # نشست‌های قابل ابطال
```
تست‌ها بدون دیتابیس و بدون شبکه اجرا می‌شوند و مهم‌ترین چیزی که تضمین می‌کنند
این است: **با خاموش بودن قابلیت، هیچ درخواستی به IAM نمی‌رود و ورود دقیقاً مثل
قبل کار می‌کند**؛ و با روشن بودن، خرابی IAM ورود ادمین را قطع نمی‌کند.