Files
tehran_tala/apps/api/src/modules/customer-auth/customer-auth.service.ts
T

521 lines
20 KiB
TypeScript
Raw Normal View History

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 { envOr, envOrNull } from '../../common/env';
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 {
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;
}
@Injectable()
export class CustomerAuthService {
private readonly logger = new Logger(CustomerAuthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly sms: SmsService,
) {}
/**
* 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.
*/
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,
// Customer.nationalCode is required and unique. The expert's own code
// when they have one, else the mobile — the same fallback
// admin-customers.service.ts uses when a form omits it.
nationalCode: admin.salesExpertProfile.nationalCode || admin.mobile,
// No app password: this account is reached only through the expert's
// AdminUser session, never by a password login of its own.
passwordHash: '',
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})`,
);
return {
token: await this.issueExpertToken(expert.adminUserId, mobile),
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),
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.
const expert = await this.resolveExpertPrincipal(mobile);
if (expert) {
return {
token: await this.issueExpertToken(expert.adminUserId, mobile),
customer: { id: expert.tradingCustomerId, mobile },
};
}
return {
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile),
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
};
}
/**
* 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 issueExpertToken(adminUserId: string, mobile: string): Promise<string> {
const payload: CustomerJwtPayload = {
sub: adminUserId,
mobile,
typ: EXPERT_TOKEN_TYPE,
};
return this.jwt.signAsync(payload, {
secret: customerJwtSecret(),
expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`,
});
}
/**
* 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).
*/
private issueToken(customerId: string, mobile: string): Promise<string> {
const payload: CustomerJwtPayload = {
sub: customerId,
mobile,
typ: CUSTOMER_TOKEN_TYPE,
};
return 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`,
});
}
/**
* 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 -> Trez, per Sms.php:57-82).
*
* When no credentials are configured, SmsService does not send and the code
* is logged instead — development only; production refuses to boot without
* credentials (assertSmsConfigured in main.ts).
*
* 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) {
// BYTE-IDENTICAL to the reference (ApiMobileController.php:47). 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" this string.
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'
);
}