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([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 { 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 { 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 { 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( 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 { // 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 " 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' ); }