Files
hedena_gold/apps/api/src/modules/customer-auth/customer-auth.service.ts
T
mahmoodandClaude Sonnet 5.5 5f3b00497e feat(auth): one mobile, one person across sales experts and customers
Experts (AdminUser) and customers (Customer) sign in to the same apps by mobile
but live in different tables, so the database cannot make the number unique
across them. A number could be given to an expert and to a customer, and the
login then silently merged the two (the customer row was adopted as the expert's
trading account), which left the expert on a customer session and the panel's
name edits unreachable.

The reference rejected a duplicate at entry (`unique:customers`, the agent being
a customers row too); this does the same where a number is typed in:

- customer create/update (wholesale, banker, walk-in): refused when an ACTIVE
  expert holds the number;
- expert create/update: refused when a customer holds it (the expert's own
  trading account excepted);
- the app's self sign-up: refused when an active expert holds it.

Only a number being created or CHANGED is checked, so existing duplicates keep
signing in and stay editable; the login's "expert wins" adoption is untouched.
Plain equality on the two unique columns, normalised with faToEn, so both
lookups are index hits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:24:37 +03:30

962 lines
39 KiB
TypeScript

import { createHash, createHmac, randomBytes } from 'node:crypto';
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AdminRole, CustomerKind, MessageAudience, Prisma, VerificationStep } 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 { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
import { masterCodeAllowed } from '../../common/production-checks';
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
import { faToEn } from './digits';
import { SmsService } from '../sms/sms.service';
import { brandName } from '../../common/brand';
import { activeExpertHoldsMobile } from '../../common/mobile-owner';
import type { RegisterDto } from './dto/register.dto';
/** Nest 10 has no TooManyRequestsException; 429 with a Persian message. */
class TooManyRequestsException extends HttpException {
constructor(message: string) {
super(message, HttpStatus.TOO_MANY_REQUESTS);
}
}
/** How long a code stays usable. The reference had no expiry at all (D6). */
const OTP_TTL_MS = 2 * 60 * 1000;
/** Wrong guesses allowed against one challenge before it is burned. */
const MAX_ATTEMPTS = 5;
/** Sends allowed per mobile number inside the window. */
const MAX_SENDS_PER_MOBILE = 5;
/** Sends allowed per source IP inside the window. */
const MAX_SENDS_PER_IP = 20;
const SEND_WINDOW_MS = 10 * 60 * 1000;
/** Minimum gap between two sends to the same number. */
const RESEND_COOLDOWN_MS = 60 * 1000;
/** Self-registrations allowed per source IP inside the window. */
const MAX_REGISTRATIONS_PER_IP = 5;
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
/**
* D20 — the delivery master OTP.
*
* Read from `OTP_MASTER_CODE` and NEVER hardcoded. Unset or empty disables it
* completely, which is the state every real deployment must be in.
*
* Read through a function rather than a module constant so tests (and a
* restart-free config change) see the current value instead of whatever was in
* the environment when this module was first imported.
*/
export function masterOtpCode(): string | null {
if (!masterCodeAllowed(process.env)) return null;
const raw = process.env.OTP_MASTER_CODE?.trim();
return raw ? raw : null;
}
/** Whether the master code is currently armed. */
export function masterOtpEnabled(): boolean {
return masterOtpCode() !== null;
}
/** اطلاعات جانبی صدور توکن: هویت تأییدشده در IdP و IP کاربر */
interface IssueContext {
identity?: IamIdentity | null;
ip?: string;
}
/** شماره ماسک‌شده برای لاگ امنیتی IdP — بدون افشای کامل شماره */
function mobileVariants(raw: string): string[] {
const digits = raw.replace(/\D/g, '');
const variants = new Set<string>([raw.trim(), digits]);
if (digits.startsWith('98') && digits.length >= 12) variants.add(`0${digits.slice(2)}`);
if (digits.startsWith('0') && digits.length === 11) variants.add(`98${digits.slice(1)}`);
return [...variants].filter((value) => value !== '');
}
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);
constructor(
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).
*
* Reference: validate `username`, find an ACTIVE customer by mobile, generate
* rand(1000,9999), store it, send it by SMS, return `true`.
*
* Kept: the active-customer filter, the 4-digit range, and the 404 for a
* number that is not registered (reference `firstOrFail()`).
* Changed: the code is hashed, it expires, and sends are rate limited.
*/
async requestOtp(rawMobile: string, ip: string | undefined) {
const mobile = faToEn(rawMobile);
await this.assertSendAllowed(mobile, ip);
// D21 — a sales expert signs in with the SAME flow, on the mobile of their
// AdminUser. Checked first: the owner ruled that if a number somehow
// existed on both sides, the expert wins.
//
// Their trading Customer row is created here rather than at verify, because
// CustomerOtp.customerId is a required FK and the challenge has to hang off
// a real row. Creating it costs nothing for a number that never completes
// login: it is that expert's own account either way, and it is created once.
const expertAccount = await this.resolveExpertTradingAccount(mobile);
// Legacy used Customer::active(), and the archived filter matches the
// customer-list lookup the app login previously relied on.
const customer =
expertAccount ??
(await this.prisma.customer.findFirst({
where: { mobile, isActive: true, isArchived: false },
select: { id: true },
}));
// Owner ruling 2026-10-12: say so. This used to return `{ sent: true }` for
// a number nobody holds, to stop an outsider enumerating customers. The
// cost landed on real users and on support: the app moved to the code box
// and then nothing arrived, and there was no way to tell apart "the SMS
// gateway is down", "the code is late" and "this number was never
// registered" — the one case the person could actually act on.
//
// This is also what the reference did (`firstOrFail()`,
// ApiMobileController::userAuth:43 → 404), so the opaque response was the
// deviation, not this.
//
// Enumeration is still bounded by the rate limits above — assertSendAllowed
// caps a single IP at MAX_SENDS_PER_IP per window, which it did not do in
// the reference.
if (!customer) {
// A self-registration still awaiting review is inactive by design; tell
// them so rather than claim the number was never registered.
const pending = await this.prisma.customer.findFirst({
where: { mobile, isActive: false, isArchived: false, verificationStep: VerificationStep.STEP_ONE_SUBMITTED },
select: { id: true },
});
if (pending) {
throw new ForbiddenException('ثبت نام شما در انتظار تایید کارشناسان است. پس از تایید می‌توانید وارد شوید.');
}
this.logger.warn(`OTP requested for unknown or inactive mobile ${mobile}`);
throw new NotFoundException('این شماره در سیستم ثبت نشده است. لطفاً با پشتیبانی تماس بگیرید.');
}
const code = String(Math.floor(1000 + Math.random() * 9000));
await this.prisma.customerOtp.create({
data: {
mobile,
customerId: customer.id,
codeHash: await bcrypt.hash(code, 10),
expiresAt: new Date(Date.now() + OTP_TTL_MS),
requestIp: ip ?? null,
},
});
await this.deliver(mobile, code);
return { sent: true };
}
/**
* Self-registration from the app — port of Major/CustomerController::register
* (henza_server :19-75). Before this the app's register form was a stub that
* showed "registered" and saved nothing, so new customers never reached the
* admin's «مشتریان در انتظار» list.
*
* Uses the reference's ORIGINAL review flow (its commented-out "نیاز به تایید
* ادمین" block), not the later auto-approve: the row is inactive with step one
* awaiting review, which is exactly what that list shows. Approving step one
* in the admin panel activates it (AdminCustomersService.verify). Credit limit
* and wage offset stay at their defaults for the admin to set on review.
*/
async register(dto: RegisterDto, ip: string | undefined) {
const mobile = faToEn(dto.mobile);
if (!/^09\d{9}$/.test(mobile)) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
this.assertRegisterAllowed(ip);
const fname = dto.fname.trim();
const lname = dto.lname.trim();
const existing = await this.prisma.customer.findUnique({ where: { mobile }, select: { id: true } });
if (existing || (await activeExpertHoldsMobile(this.prisma, mobile))) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است. از بخش ورود وارد شوید.');
}
try {
await this.prisma.customer.create({
data: {
fullName: `${fname} ${lname}`,
firstName: fname,
lastName: lname,
mobile,
passwordHash: OTP_ONLY_PASSWORD_HASH,
kind: CustomerKind.APP_SHOPKEEPER,
storeName: dto.store_name.trim(),
phone: faToEn(dto.phone),
address: dto.address.trim(),
cityNameRaw: dto.city.trim(),
verificationStep: VerificationStep.STEP_ONE_SUBMITTED,
isActive: false,
},
});
} catch (e) {
// Two submits racing on the same number: the unique index catches the second.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است. از بخش ورود وارد شوید.');
}
throw e;
}
// Reference :57-71 (its "awaiting review" wording). Fire-and-forget: a
// failed SMS never fails the registration (D20).
const brand = brandName();
const fire = (to: string, body: string) => {
void this.sms.send(to, body).catch((err: unknown) => {
this.logger.error(`Registration SMS to ${to} failed: ${String(err)}`);
});
};
fire(
mobile,
`${brand}\nهمکار گرامی ${fname} ${lname}\nثبت نام شما با موفقیت انجام شد و در انتظار تایید کارشناسان است.`,
);
for (const admin of envList('PROFILE_ADMIN_MOBILES')) {
fire(admin, `${brand}\nمشتری با نام ${fname} ${lname} ثبت نام کرد و در انتظار تایید است.`);
}
return { ok: true };
}
/** Public endpoint with an SMS side effect: cap how many accounts one IP can open. */
private readonly registrationsByIp = new Map<string, number[]>();
private assertRegisterAllowed(ip: string | undefined) {
if (!ip) return;
const now = Date.now();
const recent = (this.registrationsByIp.get(ip) ?? []).filter((t) => now - t < REGISTER_WINDOW_MS);
if (recent.length >= MAX_REGISTRATIONS_PER_IP) {
throw new TooManyRequestsException('تعداد ثبت نام‌ها بیش از حد مجاز است. لطفاً بعداً تلاش کنید.');
}
recent.push(now);
this.registrationsByIp.set(ip, recent);
}
/**
* D21 — find an active sales expert by mobile and return their trading
* Customer account, creating it on first sign-in.
*
* WHY A TRADING ACCOUNT EXISTS
* In the reference a sales agent WAS a Customer (type=1), so `setOrder` did
* `Customer::findOrFail(auth()->id())` and read that row's `gram_level`
* (ApiMobileController.php:534-535) — the agent's own order, own credit
* limit, own ledger. Here the expert is an AdminUser (D17), which has no
* ledger and cannot be an Order's subject, so this one linked Customer row
* plays that part. It is NOT one of their referred customers.
*
* Seeded from what the reference used for exactly these two numbers:
* gram_level -> goldCreditLimitGrams (SalesExpertProfile.gramLevel)
* percent -> makingChargeOffsetPercent (AdminUser.wagePercent)
*
* Returns null for any mobile that is not an active sales expert, so an
* ordinary customer's login is completely unaffected.
*/
/** `code` when it is set and no customer holds it yet, else null. */
private async freeNationalCode(code: string | null): Promise<string | null> {
if (!code) return null;
const taken = await this.prisma.customer.findUnique({ where: { nationalCode: code }, select: { id: true } });
return taken ? null : code;
}
private async resolveExpertTradingAccount(mobile: string): Promise<{ id: string } | null> {
const admin = await this.prisma.adminUser.findFirst({
where: { mobile, isActive: true, role: AdminRole.SALES_EXPERT },
select: {
id: true,
fullName: true,
firstName: true,
lastName: true,
mobile: true,
wagePercent: true,
salesExpertProfile: {
select: { id: true, gramLevel: true, nationalCode: true, tradingCustomerId: true },
},
},
});
if (!admin) return null;
const existingId = admin.salesExpertProfile?.tradingCustomerId;
if (existingId) {
// Re-activate nothing and change nothing — just confirm it is usable.
const existing = await this.prisma.customer.findUnique({
where: { id: existingId },
select: { id: true, isActive: true, isArchived: true },
});
if (existing && existing.isActive && !existing.isArchived) return { id: existing.id };
// Deliberately does NOT silently create a second account when the first
// was deactivated: an operator disabled it, and that decision stands.
//
// Told apart from "no such number" on purpose: this expert DOES exist, so
// the «ثبت نشده است» message would be false and would send support
// looking for a registration problem that is not there.
this.logger.warn(
`Sales expert ${admin.id} has a trading account that is inactive or archived; refusing to create another.`,
);
throw new ForbiddenException('حساب شما غیرفعال است. لطفاً با پشتیبانی تماس بگیرید.');
}
if (!admin.salesExpertProfile) {
// Every SALES_EXPERT gets a profile on create and on role change (D17),
// so this means something bypassed both paths. A server-side defect, not
// a wrong number — so not «ثبت نشده است» either.
this.logger.error(`Sales expert ${admin.id} has no SalesExpertProfile; cannot sign in to the app.`);
throw new ForbiddenException('حساب شما کامل نیست. لطفاً با پشتیبانی تماس بگیرید.');
}
// The mobile is unique on Customer, so a pre-existing customer row with
// this number would collide. That cannot normally happen — the owner
// stated expert and customer numbers are never the same — but if it does,
// adopt that row rather than fail the login or create a duplicate.
const clash = await this.prisma.customer.findFirst({
where: { mobile },
select: { id: true, isActive: true, isArchived: true },
});
if (clash) {
this.logger.warn(
`Mobile ${mobile} belongs to both sales expert ${admin.id} and customer ${clash.id}; ` +
`adopting the existing customer row as the expert's trading account.`,
);
if (!clash.isActive || clash.isArchived) return null;
await this.prisma.salesExpertProfile.update({
where: { id: admin.salesExpertProfile.id },
data: { tradingCustomerId: clash.id },
});
return { id: clash.id };
}
const created = await this.prisma.customer.create({
data: {
fullName: admin.fullName,
firstName: admin.firstName,
lastName: admin.lastName,
mobile: admin.mobile,
// The expert's own national code when they have one and no other
// customer holds it (the column is unique); else none. Never the
// mobile — the column holds national codes only.
nationalCode: await this.freeNationalCode(admin.salesExpertProfile.nationalCode),
// No app password: this account is reached only through the expert's
// AdminUser session, never by a password login of its own.
passwordHash: OTP_ONLY_PASSWORD_HASH,
kind: CustomerKind.APP_SHOPKEEPER,
goldCreditLimitGrams: admin.salesExpertProfile.gramLevel,
makingChargeOffsetPercent: admin.wagePercent ?? new Prisma.Decimal(0),
},
select: { id: true },
});
await this.prisma.salesExpertProfile.update({
where: { id: admin.salesExpertProfile.id },
data: { tradingCustomerId: created.id },
});
this.logger.log(
`Created trading account ${created.id} for sales expert ${admin.id} on first app sign-in.`,
);
return { id: created.id };
}
/**
* Step 2 — port of ApiMobileController::userVerfiy (henza_server:51-64).
*
* Reference: `if (faTOen($request->password) != $customer->send_password) abort(422);`
* then issue a token and return THAT ONE customer.
*
* The parity behaviour is the comparison and the rejection. The attempt
* counter, the expiry check and the single-use consumption are the D6
* additions.
*/
async verifyOtp(rawMobile: string, rawCode: string, ip: string | undefined) {
const mobile = faToEn(rawMobile);
const code = faToEn(rawCode);
// One message for every failure mode, so a caller cannot tell "no code was
// sent" from "wrong code" from "expired". Declared before the master-code
// branch so that path can reject identically.
// Deliberately one message for every reason a code can fail: wrong code, no
// challenge, expired, unknown number. Unlike requestOtp — which now names an
// unregistered number, owner ruling 2026-10-12 — there is nothing for a real
// user to act on differently here, and step 1 already told them if the
// number was not on file, so they never reach this with an unknown number.
const reject = () => new UnauthorizedException('کد وارد شده صحیح نیست');
// D20 — the delivery master code. Checked BEFORE the stored challenge so it
// works even when the customer never requested a code (no CustomerOtp row
// exists to compare against). It does not weaken any other rule: the
// customer must still exist, be active and not archived, and the send
// throttle on requestOtp is untouched.
//
// MUST be removed from the environment before the real release — see D20
// and DELIVERY.md.
const master = masterOtpCode();
if (master !== null && code === master) {
// D21 — the master code works for a sales expert too, and the expert is
// checked first for the same reason requestOtp does it.
const expert = await this.resolveExpertPrincipal(mobile);
if (expert) {
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, { ip }),
customer: { id: expert.tradingCustomerId, mobile },
};
}
const customer = await this.prisma.customer.findFirst({
where: { mobile, isActive: true, isArchived: false },
select: { id: true, mobile: true },
});
if (!customer) throw reject();
// Audited with the mobile, as D20 requires, so every bypass login is
// traceable in the server log.
this.logger.warn(
`OTP_MASTER_CODE used to sign in customer ${customer.id} (mobile ${customer.mobile})`,
);
return {
token: await this.issueToken(customer.id, customer.mobile, { ip }),
customer: { id: customer.id, mobile: customer.mobile },
};
}
const challenge = await this.prisma.customerOtp.findFirst({
where: { mobile, consumedAt: null },
orderBy: { createdAt: 'desc' },
include: {
customer: {
select: { id: true, mobile: true, isActive: true, isArchived: true },
},
},
});
if (!challenge) throw reject();
if (challenge.expiresAt.getTime() < Date.now()) throw reject();
if (challenge.attempts >= MAX_ATTEMPTS) {
throw new TooManyRequestsException(
'تعداد تلاش‌های ناموفق بیش از حد مجاز است. لطفاً کد جدیدی درخواست کنید.',
);
}
const ok = await bcrypt.compare(code, challenge.codeHash);
if (!ok) {
await this.prisma.customerOtp.update({
where: { id: challenge.id },
data: { attempts: { increment: 1 } },
});
throw reject();
}
if (!challenge.customer.isActive || challenge.customer.isArchived) {
throw reject();
}
// Single use: burn the challenge before issuing the token.
await this.prisma.customerOtp.update({
where: { id: challenge.id },
data: { consumedAt: new Date() },
});
// 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 this.withBootstrap(
{
token: await this.issueExpertToken(expert.adminUserId, mobile, { identity, ip }),
customer: { id: expert.tradingCustomerId, mobile },
},
identity,
);
}
return this.withBootstrap(
{
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile, { identity, ip }),
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
},
identity,
);
}
/**
* D21 — is this mobile an active sales expert with a usable trading account?
*
* Read-only: unlike `resolveExpertTradingAccount`, this never creates
* anything, because by verify time requestOtp has already done so. Returns
* null for every ordinary customer, so their path is untouched.
*/
private async resolveExpertPrincipal(
mobile: string,
): Promise<{ adminUserId: string; tradingCustomerId: string } | null> {
const admin = await this.prisma.adminUser.findFirst({
where: { mobile, isActive: true, role: AdminRole.SALES_EXPERT },
select: {
id: true,
salesExpertProfile: {
select: {
tradingCustomer: { select: { id: true, isActive: true, isArchived: true } },
},
},
},
});
const trading = admin?.salesExpertProfile?.tradingCustomer;
if (!admin || !trading || !trading.isActive || trading.isArchived) return null;
return { adminUserId: admin.id, tradingCustomerId: trading.id };
}
/**
* Sign a SALES EXPERT app token.
*
* `sub` is the AdminUser id — the durable identity — not the trading
* Customer id, so the token survives anything that changes the trading row
* and the role check can be re-applied on every request.
*
* Same secret and lifetime as a customer token; only `typ` differs. It is
* NOT an admin-panel token: JwtStrategy requires the admin type and refuses
* this one on every admin route.
*/
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 } : {}),
};
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;
}
/**
* Sign a customer JWT. Shared by the normal verify path and the D20 master
* code so both issue exactly the same token — the bypass grants no extra
* privilege and no longer lifetime.
*
* Reference reused api_token forever once set (:60-62); a fresh token is
* issued on every verify here (T1).
*/
/**
* شروع بررسی بی‌صدای نشست IAM. کوکی امضاشده code_verifier را نگه می‌دارد
* تا در آدرس بازگشت دیده نشود.
*/
beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null {
if (!this.iam.enabled) return null;
const redirectUri = pickExactUri(requestedRedirect, [
envOrNull('IAM_REDIRECT_URI'),
...envList('IAM_REDIRECT_URIS'),
]);
const clientId = envOrNull('IAM_CLIENT_ID');
if (!redirectUri || !clientId) return null;
const state = randomBytes(24).toString('base64url');
const nonce = randomBytes(24).toString('base64url');
const verifier = randomBytes(48).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const payload = Buffer.from(JSON.stringify({ state, nonce, verifier, redirectUri })).toString('base64url');
const sig = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
const secure = redirectUri.startsWith('https:');
const cookie =
`gb_iam_sso=${payload}.${sig}; HttpOnly; Path=/; Max-Age=180; SameSite=Lax` +
(secure ? '; Secure' : '');
const url = new URL(`${this.iam.issuer}/oauth/authorize`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
url.searchParams.set('nonce', nonce);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('prompt', 'none');
return { authorizeUrl: url.toString(), cookie };
}
async completeBrowserSso(cookieValue: string | null, code: string, state: string, ip: string | undefined) {
const pending = this.readSsoCookie(cookieValue);
if (!pending || pending.state !== state) {
throw new UnauthorizedException('درخواست ورود مشترک معتبر نیست');
}
const claims = await this.iam.exchangeAuthorizationCode(code, pending.verifier, pending.redirectUri);
if (!claims.phoneNumber || !claims.sub) {
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
}
const mobiles = mobileVariants(claims.phoneNumber);
let expert: { adminUserId: string; tradingCustomerId: string } | null = null;
let expertMobile = claims.phoneNumber;
for (const candidate of mobiles) {
expert = await this.resolveExpertPrincipal(candidate);
if (expert) {
expertMobile = candidate;
break;
}
}
const identity: IamIdentity = {
sub: claims.sub,
email: null,
phoneNumber: claims.phoneNumber,
firstName: null,
lastName: null,
sessionId: claims.sessionId,
browserTicket: null,
};
if (expert) {
return {
token: await this.issueExpertToken(expert.adminUserId, expertMobile, { identity, ip }),
customer: { id: expert.tradingCustomerId, mobile: expertMobile },
};
}
const customer = await this.prisma.customer.findFirst({
where: { mobile: { in: mobiles }, isActive: true, isArchived: false },
select: { id: true, mobile: true },
});
if (!customer) {
throw new UnauthorizedException('حسابی با این شماره در این سامانه نیست');
}
return {
token: await this.issueToken(customer.id, customer.mobile, { identity, ip }),
customer: { id: customer.id, mobile: customer.mobile },
};
}
centralLogoutUrl(requestedReturn?: string): string | null {
if (!this.iam.enabled) return null;
const redirect = pickExactUri(requestedReturn, [
envOrNull('IAM_POST_LOGOUT_REDIRECT_URI'),
envOrNull('IAM_BROWSER_RETURN_URL'),
...envList('IAM_ALLOWED_RETURN_URLS'),
]);
const clientId = envOrNull('IAM_CLIENT_ID');
if (!redirect || !clientId) return null;
const url = new URL(`${this.iam.issuer}/oauth/logout`);
url.searchParams.set('client_id', clientId);
url.searchParams.set('post_logout_redirect_uri', redirect);
return url.toString();
}
/**
* نشست IAM همین ورود را می‌بندد تا اپ دیگر هم با رفرش خارج شود.
* شکست اینجا خروج محلی را متوقف نمی‌کند؛ پرش مرورگر به IAM هنوز کوکی را پاک می‌کند.
*/
async endSharedSessions(mobile: string): Promise<{ ended: number }> {
if (!this.iam.enabled) return { ended: 0 };
const ids = await this.sessions.activeIamSessionIds(mobile);
let ended = 0;
for (const id of ids) {
try {
await this.iam.endDelegatedSession(id);
ended += 1;
} catch (error) {
this.logger.warn(`central sign-out skipped for ${id}: ${(error as Error).message}`);
}
}
return { ended };
}
private withBootstrap<T extends { token: string; customer: { id: string; mobile: string } }>(
body: T,
identity: IamIdentity | null,
): T & { iamBootstrapUrl?: string } {
const url = this.iam.browserBootstrapUrl(identity?.browserTicket, 'customer');
return url ? { ...body, iamBootstrapUrl: url } : body;
}
private ssoSecret(): string {
return envOrNull('JWT_CUSTOMER_SECRET') || envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso');
}
private readSsoCookie(value: string | null): { state: string; nonce: string; verifier: string; redirectUri: string } | null {
if (!value || !value.includes('.')) return null;
const split = value.lastIndexOf('.');
const payload = value.slice(0, split);
const sig = value.slice(split + 1);
const expected = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
if (expected.length !== sig.length) return null;
let same = 0;
for (let i = 0; i < expected.length; i++) same |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
if (same !== 0) return null;
try {
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
} catch {
return null;
}
}
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 } : {}),
};
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;
}
/**
* Per-mobile and per-IP send throttle.
*
* Counted off the CustomerOtp rows rather than an in-memory map, so the limit
* survives a restart and holds across replicas. The reference had no limit:
* userAuth could be called in a loop, each call sending a real (billed) SMS.
*/
private async assertSendAllowed(mobile: string, ip: string | undefined) {
const since = new Date(Date.now() - SEND_WINDOW_MS);
const lastToMobile = await this.prisma.customerOtp.findFirst({
where: { mobile },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
if (lastToMobile && Date.now() - lastToMobile.createdAt.getTime() < RESEND_COOLDOWN_MS) {
const wait = Math.ceil(
(RESEND_COOLDOWN_MS - (Date.now() - lastToMobile.createdAt.getTime())) / 1000,
);
throw new TooManyRequestsException(`لطفاً ${wait} ثانیه دیگر تلاش کنید`);
}
const sentToMobile = await this.prisma.customerOtp.count({
where: { mobile, createdAt: { gte: since } },
});
if (sentToMobile >= MAX_SENDS_PER_MOBILE) {
throw new TooManyRequestsException(
'تعداد درخواست‌های کد بیش از حد مجاز است. لطفاً بعداً تلاش کنید.',
);
}
if (ip) {
const sentFromIp = await this.prisma.customerOtp.count({
where: { requestIp: ip, createdAt: { gte: since } },
});
if (sentFromIp >= MAX_SENDS_PER_IP) {
throw new TooManyRequestsException(
'تعداد درخواست‌های کد بیش از حد مجاز است. لطفاً بعداً تلاش کنید.',
);
}
}
}
/**
* Hands the code to the SMS gateway (SmsService -> the SMS_PROVIDER driver,
* relay by default per D26).
*
* When no gateway is configured, SmsService does not send and the code is
* logged instead — outside production only (D20).
*
* A failed send DOES throw 503 (owner ruling 2026-10-12). The reference
* ignored Sms::Send's result and so did this, which meant a gateway outage
* returned success and the app sent the person to the code box to wait for a
* message that had never been sent. See the note at the throw: D20 protects
* operations from a dead gateway, but here the SMS is the operation.
*/
private async deliver(mobile: string, code: string) {
// The reference's text (ApiMobileController.php:47) with the brand changed
// to BRAND_NAME. The leading "<#> code is <CODE>" line and the trailing
// "aMo6iFyUQaY" are the Android SMS Retriever app hash — change either and
// autofill stops working on every installed client. Do not "tidy" them.
const smsText =
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن ${brandName()} ${code} می باشد\n` +
`aMo6iFyUQaY`;
// persist:false — the real text carries the live code, and the admin SMS
// grid lists and searches `body` (SEC-11). The redacted audit row below
// takes its place.
const result = await this.sms.send(mobile, smsText, { persist: false });
// Only outside production, and only when no gateway is configured, so a
// deployment can never print live codes into its logs.
//
// The condition is SmsService's own verdict (`sms-not-configured`), not a
// second guess at the config: with D26's default `relay` driver,
// smsConfigured() is true on any machine that has an external IPv4, so
// testing the config here would have called a dev box "configured" and then
// failed the login below when the relay was unreachable. This is the one
// case where a failed send must not be an error — there was no gateway to
// fail, and local sign-in works off this log line.
const devNoGateway =
process.env.NODE_ENV !== 'production' && !result.ok && result.error === 'sms-not-configured';
if (devNoGateway) {
this.logger.log(`[dev] OTP for ${mobile}: ${code}`);
}
// Owner ruling 2026-10-12, same reasoning as the 404 in requestOtp.
//
// SmsService NEVER throws — a dead gateway is a log line (D20) — so a failed
// send used to return success here and the app walked the person to the code
// box to wait for a message that was never sent. From the outside that is
// indistinguishable from a late SMS, which is exactly the confusion being
// removed.
//
// D20 is about not letting a failed SMS break an *operation*: an order must
// still be placed. In a login the SMS IS the operation, so staying quiet
// protects nothing. The code row stays in the table — it costs nothing and
// expires on its own — and the person is told to retry instead of being left
// watching a countdown.
if (!result.ok && !devNoGateway) {
this.logger.error(`OTP SMS to ${mobile} failed: ${result.error ?? 'unknown'}`);
throw new HttpException(
'ارسال پیام کوتاه در حال حاضر ممکن نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// An audit row, NOT the message body.
//
// SmsMessage.body is listed and full-text searchable on the admin panel's
// SMS grid (admin-misc.service.ts:136 allows `body`, :135 searches it), so
// persisting the real text would show every live OTP to any operator
// holding the `sms` rid — and the whole point of hashing codeHash is that
// the code exists nowhere in readable form. The code is redacted here.
await this.prisma.smsMessage.create({
data: {
audience: MessageAudience.SINGLE_NUMBER,
recipients: mobile,
body: 'کد یکبار مصرف ورود (متن پیام ثبت نمی‌شود)',
sentCount: 1,
},
});
}
}
/**
* Customer tokens are signed with their own secret so an admin token can never
* validate as a customer token, or the reverse, even if the `typ` check were
* ever removed (T1).
*/
export function customerJwtSecret(): string {
return (
envOrNull('JWT_CUSTOMER_SECRET') ??
envOrNull('JWT_SECRET') ??
'dev-only-insecure-customer-secret'
);
}