parity(api,apps): delivery-mode gaps — master OTP, non-blocking SMS, faToEn, v2 cart cap
D20: OTP_MASTER_CODE login bypass read only from the environment, disabled when unset, audited on every use, with a boot banner while armed. SMS no longer aborts the boot in production — it warns and declines silently, so no message can stop an operation. D19: port Utility::faTOen onto admin-entered mobiles (before the uniqueness lookup, which could previously be bypassed with Persian digits), and the create-only welcome SMS, byte-identical to the reference and fire-and-forget. user-app-v2 was missing the cart stock ceiling entirely: capQty/maxQty, the un-orderable guard and the qty clamp are ported, leaving v2's own design intact. The two cart stores are now byte-identical. isVariantOrderable and the orderable-first default selection ported too. jest 45/45; typecheck clean on api, admin-panel, user-app, user-app-v2. [ref: henza_server/app/Http/Controllers/Adm/AdminController.php:333,348,364,378-387] [ref: henza_server/app/Models/Utility.php:26] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2194b3d4d9
commit
460cf276b6
@@ -26,6 +26,26 @@ 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);
|
||||
@@ -96,6 +116,39 @@ export class CustomerAuthService {
|
||||
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) {
|
||||
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' },
|
||||
@@ -106,10 +159,6 @@ export class CustomerAuthService {
|
||||
},
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -138,25 +187,32 @@ export class CustomerAuthService {
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
|
||||
return {
|
||||
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile),
|
||||
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: challenge.customer.id,
|
||||
mobile: challenge.customer.mobile,
|
||||
sub: customerId,
|
||||
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, {
|
||||
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: (process.env.JWT_CUSTOMER_EXPIRES_IN ?? '30d') as `${number}d`,
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user