Feat add iam auth
This commit is contained in:
@@ -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,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");
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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('دو مرحلهای');
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ورود ادمین را قطع نمیکند.
|
||||
Reference in New Issue
Block a user