Feat add iam auth

This commit is contained in:
mahmood
2026-09-25 19:04:48 +03:30
parent 2de90c6b35
commit 36f239e02c
28 changed files with 2738 additions and 19 deletions
@@ -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);