2026-09-22 01:20:00 +03:30
|
|
|
import { HttpException, HttpStatus, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
|
|
|
|
import { JwtService } from '@nestjs/jwt';
|
|
|
|
|
import { MessageAudience } from '@prisma/client';
|
|
|
|
|
import * as bcrypt from 'bcryptjs';
|
|
|
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
|
|
|
import { CUSTOMER_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
|
|
|
|
import { faToEn } from './digits';
|
2026-09-22 08:08:50 +03:30
|
|
|
import { SmsService, smsConfigured } from '../sms/sms.service';
|
2026-09-22 01:20:00 +03:30
|
|
|
|
|
|
|
|
/** 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;
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class CustomerAuthService {
|
|
|
|
|
private readonly logger = new Logger(CustomerAuthService.name);
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
private readonly jwt: JwtService,
|
2026-09-22 08:08:50 +03:30
|
|
|
private readonly sms: SmsService,
|
2026-09-22 01:20:00 +03:30
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
|
|
|
|
|
// Legacy used Customer::active(), and the archived filter matches the
|
|
|
|
|
// customer-list lookup the app login previously relied on.
|
|
|
|
|
const customer = 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 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// One message for every failure mode below, so a caller cannot tell "no
|
|
|
|
|
// code was sent" from "wrong code" from "expired".
|
|
|
|
|
const reject = () => new UnauthorizedException('کد وارد شده صحیح نیست');
|
|
|
|
|
|
|
|
|
|
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() },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const payload: CustomerJwtPayload = {
|
|
|
|
|
sub: challenge.customer.id,
|
|
|
|
|
mobile: challenge.customer.mobile,
|
|
|
|
|
typ: CUSTOMER_TOKEN_TYPE,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Reference reused api_token forever once set (:60-62); a fresh token is
|
|
|
|
|
// issued on every verify here (T1).
|
|
|
|
|
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: (process.env.JWT_CUSTOMER_EXPIRES_IN ?? '30d') as `${number}d`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
token,
|
|
|
|
|
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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(
|
|
|
|
|
'تعداد درخواستهای کد بیش از حد مجاز است. لطفاً بعداً تلاش کنید.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-22 08:08:50 +03:30
|
|
|
* Hands the code to the SMS gateway (SmsService -> Trez, per Sms.php:57-82).
|
2026-09-22 01:20:00 +03:30
|
|
|
*
|
2026-09-22 08:08:50 +03:30
|
|
|
* 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).
|
2026-09-22 01:20:00 +03:30
|
|
|
*
|
2026-09-22 08:08:50 +03:30
|
|
|
* 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.
|
2026-09-22 01:20:00 +03:30
|
|
|
*/
|
|
|
|
|
private async deliver(mobile: string, code: string) {
|
2026-09-22 08:08:50 +03:30
|
|
|
// 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.
|
2026-09-22 07:09:33 +03:30
|
|
|
const smsText =
|
2026-09-22 01:20:00 +03:30
|
|
|
`<#> code is ${code}\n` +
|
|
|
|
|
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد ${code} می باشد\n` +
|
|
|
|
|
`aMo6iFyUQaY`;
|
|
|
|
|
|
2026-09-22 08:08:50 +03:30
|
|
|
// 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()) {
|
2026-09-22 01:20:00 +03:30
|
|
|
this.logger.log(`[dev] OTP for ${mobile}: ${code}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 07:09:33 +03:30
|
|
|
// 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.
|
2026-09-22 01:20:00 +03:30
|
|
|
await this.prisma.smsMessage.create({
|
|
|
|
|
data: {
|
|
|
|
|
audience: MessageAudience.SINGLE_NUMBER,
|
|
|
|
|
recipients: mobile,
|
2026-09-22 07:09:33 +03:30
|
|
|
body: 'کد یکبار مصرف ورود (متن پیام ثبت نمیشود)',
|
2026-09-22 01:20:00 +03:30
|
|
|
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 (
|
|
|
|
|
process.env.JWT_CUSTOMER_SECRET ??
|
|
|
|
|
process.env.JWT_SECRET ??
|
|
|
|
|
'dev-only-insecure-customer-secret'
|
|
|
|
|
);
|
|
|
|
|
}
|