The SMS texts were ported verbatim from henza and still carried its brand: the login code, the welcome message (with the app.henzagold.com link), the two complete-profile messages and the financial-document confirm/reject. All now say منتزر, and the welcome link is https://bnk.montzar.ir. The login code keeps its first line ("<#> code is <CODE>") and the trailing SMS Retriever hash unchanged, so Android autofill still works. «اپلیکشن» is corrected to «اپلیکیشن» in the same line. Tests: sms.service.spec OTP template. All 263 API tests pass. Co-authored-by: Cursor <cursoragent@cursor.com>
781 lines
30 KiB
TypeScript
781 lines
30 KiB
TypeScript
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
|
import { HttpException, HttpStatus, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
|
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 { 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, smsConfigured } from '../sms/sms.service';
|
|
|
|
/** 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;
|
|
|
|
/**
|
|
* 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, the opaque `true`
|
|
* response (it never reveals whether the number exists).
|
|
* 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 },
|
|
}));
|
|
|
|
// Deliberately opaque: the reference used firstOrFail() and leaked a 404
|
|
// for unknown numbers, which let anyone enumerate registered customers.
|
|
// Recorded as a deviation in docs/parity/DECISIONS.md.
|
|
if (!customer) {
|
|
this.logger.warn(`OTP requested for unknown or inactive mobile ${mobile}`);
|
|
return { sent: true };
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
this.logger.warn(
|
|
`Sales expert ${admin.id} has a trading account that is inactive or archived; refusing to create another.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (!admin.salesExpertProfile) {
|
|
// Every SALES_EXPERT gets a profile on create and on role change (D17),
|
|
// so this means something bypassed both paths.
|
|
this.logger.warn(`Sales expert ${admin.id} has no SalesExpertProfile; cannot sign in to the app.`);
|
|
return null;
|
|
}
|
|
|
|
// 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.
|
|
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();
|
|
}
|
|
|
|
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);
|
|
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 NOT throw. The reference ignored Sms::Send's result, so
|
|
* a gateway outage never blocked the flow that triggered it; the customer
|
|
* simply retries, and the throttle in assertSendAllowed still applies.
|
|
*/
|
|
private async deliver(mobile: string, code: string) {
|
|
// The reference's text (ApiMobileController.php:47) with the brand changed
|
|
// to منتزر. 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` +
|
|
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر ${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.
|
|
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.
|
|
if (process.env.NODE_ENV !== 'production' && !smsConfigured()) {
|
|
this.logger.log(`[dev] OTP for ${mobile}: ${code}`);
|
|
}
|
|
|
|
// 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'
|
|
);
|
|
}
|