parity(api,apps): security hotfix stage 2 - real customer sessions, close IDOR

Stage 1 narrowed GET /customers but did not close the leak: the response still
carried every customer id, and /customers/:id, /:id/orders and
/:id/ledger-entries were still public, so the full customer book was still two
requests away. The user caught this. Stage 2 deletes the id-bearing surface.

Customer JWT, separate from the admin one (T1):
  - new src/modules/customer-auth/ with its own passport strategy,
    CustomerJwtGuard, @CustomerAuth() and @CurrentCustomer()
  - signed with JWT_CUSTOMER_SECRET, and every token carries typ:'customer'
    which the strategy verifies; both checks are present deliberately
  - customer state re-read per request, so deactivating takes effect at once
  - mirrors the reference's own split: auth:api vs auth('api-customer')

Real OTP (D6), porting ApiMobileController::userAuth/userVerfiy (:39-64):
  - new CustomerOtp table, additive migration, nothing existing touched
  - parity behaviour: rand(1000,9999), active-customer filter, faTOen digit
    normalisation on both number and code, rejection on mismatch
  - approved improvements over the reference: bcrypt-hashed code, 2-minute
    expiry, 5 attempts then lockout, single use, send throttle (60s per
    number, 5/number and 20/IP per 10 min), and no number enumeration

Routes removed, replaced by token-derived equivalents:
  GET customers, customers/:id, /:id/orders, /:id/ledger-entries
    -> GET customer/me, customer/me/orders, customer/me/ledger-entries
No route accepts a customer id any more, so the IDOR is closed structurally
rather than by a check. The admin panel was unaffected (admin/customers-omde/*).

Both apps migrated in the same commit per the v1/v2 parity rule: token-based
session store, Bearer header, 401 -> clear session and route to login, real
two-step OTP login. Each app keeps its own markup and styling; only logic moved.

Tests: auth-smoke.sh extended with the stage-2 assertions, 46 passed 0 failed
against a live server. jest installed dev-only (user-approved) so pnpm test
works for the first time: 7 passed covering faToEn, which moved to its own
digits.ts to be testable without booting Nest. Typecheck clean on api,
user-app and user-app-v2; both apps build.

[ref: henza_server ApiMobileController.php:39-78]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-22 01:20:00 +03:30
co-authored by Claude Opus 5
parent fdf833565f
commit 4efe543d62
42 changed files with 3552 additions and 277 deletions
@@ -0,0 +1,63 @@
import { Body, Controller, Get, HttpCode, Ip, Post } from '@nestjs/common';
import { CustomerAuthService } from './customer-auth.service';
import { CustomersService } from '../customers/customers.service';
import { RequestOtpDto } from './dto/request-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { Public } from '../auth/guards';
import { CurrentCustomer, CustomerAuth } from './customer.guards';
import type { AuthenticatedCustomer } from './customer-jwt.strategy';
/**
* The customer-facing session.
*
* Replaces the login that downloaded every customer and matched the phone
* number in the browser (SEC-08). Mirrors the reference's two-step SMS flow:
*
* POST customer/auth/request-otp <- ApiMobileController::userAuth (:39-49)
* POST customer/auth/verify-otp <- ApiMobileController::userVerfiy (:51-64)
*
* Everything below verify is token-derived: `/me`, `/me/orders` and
* `/me/ledger-entries` read the customer id from the JWT, never from the URL,
* so one customer cannot fetch another's data.
*/
@Controller('customer')
export class CustomerAuthController {
constructor(
private readonly auth: CustomerAuthService,
private readonly customers: CustomersService,
) {}
/** Step 1. Always answers `{ sent: true }` — it never reveals whether the number is registered. */
@Public()
@Post('auth/request-otp')
@HttpCode(200)
requestOtp(@Body() dto: RequestOtpDto, @Ip() ip: string) {
return this.auth.requestOtp(dto.username, ip);
}
/** Step 2. Returns the token and that one customer, as the reference did. */
@Public()
@Post('auth/verify-otp')
@HttpCode(200)
verifyOtp(@Body() dto: VerifyOtpDto, @Ip() ip: string) {
return this.auth.verifyOtp(dto.username, dto.password, ip);
}
@CustomerAuth()
@Get('me')
me(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.getOrThrow(me.id);
}
@CustomerAuth()
@Get('me/orders')
myOrders(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.getOrders(me.id);
}
@CustomerAuth()
@Get('me/ledger-entries')
myLedgerEntries(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.getLedgerEntries(me.id);
}
}
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PrismaModule } from '../../prisma/prisma.module';
import { CustomersModule } from '../customers/customers.module';
import { CustomerAuthController } from './customer-auth.controller';
import { CustomerAuthService } from './customer-auth.service';
import { CustomerJwtStrategy } from './customer-jwt.strategy';
/**
* Customer session, deliberately separate from AuthModule (admins).
*
* JwtModule is registered without a secret here: the customer secret is passed
* per-call in CustomerAuthService.verifyOtp, and CustomerJwtStrategy reads it
* directly, so an admin secret can never sign a customer token by accident.
*
* No APP_GUARD is registered — customer routes opt in with @CustomerAuth().
*/
@Module({
imports: [PrismaModule, PassportModule, JwtModule.register({}), CustomersModule],
controllers: [CustomerAuthController],
providers: [CustomerAuthService, CustomerJwtStrategy],
exports: [CustomerAuthService],
})
export class CustomerAuthModule {}
@@ -0,0 +1,248 @@
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';
/** 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,
) {}
/**
* 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(
'تعداد درخواست‌های کد بیش از حد مجاز است. لطفاً بعداً تلاش کنید.',
);
}
}
}
/**
* Hands the code to the SMS gateway.
*
* No gateway is wired up in this project yet — SmsMessage is persistence only
* (logged as STUB-02 in docs/parity/ISSUES.md), exactly as the reference's
* Sms::Send would have been the integration point. Until one is connected the
* code is logged, and only outside production, so a deployment cannot print
* live codes into its logs.
*
* The reference's message text is kept verbatim for when the gateway lands
* (ApiMobileController.php:47).
*/
private async deliver(mobile: string, code: string) {
const body =
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد ${code} می باشد\n` +
`aMo6iFyUQaY`;
if (process.env.NODE_ENV !== 'production') {
this.logger.log(`[dev] OTP for ${mobile}: ${code}`);
}
// Recorded the same way the admin panel's own SMS sends are.
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 (
process.env.JWT_CUSTOMER_SECRET ??
process.env.JWT_SECRET ??
'dev-only-insecure-customer-secret'
);
}
@@ -0,0 +1,61 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';
import { customerJwtSecret } from './customer-auth.service';
/**
* Marks a token as a customer token. Checked on every customer route, so an
* admin token — even one signed with the same secret by misconfiguration —
* cannot be used to read customer data (T1).
*/
export const CUSTOMER_TOKEN_TYPE = 'customer' as const;
export interface CustomerJwtPayload {
sub: string;
mobile: string;
typ: typeof CUSTOMER_TOKEN_TYPE;
}
/** What `@CurrentCustomer()` hands a handler. */
export interface AuthenticatedCustomer {
id: string;
mobile: string;
}
/**
* The customer principal, kept entirely separate from JwtStrategy (which reads
* AdminUser). The reference had the same split: `auth:api` for admins and
* `auth('api-customer')` for the app.
*/
@Injectable()
export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jwt') {
constructor(private readonly prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: customerJwtSecret(),
});
}
async validate(payload: CustomerJwtPayload): Promise<AuthenticatedCustomer> {
// A token that is not explicitly a customer token is refused, whatever else
// it carries.
if (payload?.typ !== CUSTOMER_TOKEN_TYPE) {
throw new UnauthorizedException();
}
// Re-read on every request, so deactivating or archiving a customer takes
// effect immediately instead of at token expiry.
const customer = await this.prisma.customer.findUnique({
where: { id: payload.sub },
select: { id: true, mobile: true, isActive: true, isArchived: true },
});
if (!customer || !customer.isActive || customer.isArchived) {
throw new UnauthorizedException();
}
return { id: customer.id, mobile: customer.mobile };
}
}
@@ -0,0 +1,33 @@
import { ExecutionContext, Injectable, createParamDecorator } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Public } from '../auth/guards';
import { applyDecorators, UseGuards } from '@nestjs/common';
import type { AuthenticatedCustomer } from './customer-jwt.strategy';
/** Passport guard for the 'customer-jwt' strategy. */
@Injectable()
export class CustomerJwtGuard extends AuthGuard('customer-jwt') {}
/**
* Put on every customer route.
*
* Two decorators, because the globally-registered admin JwtAuthGuard runs
* first on every route in the app: `@Public()` takes the route out of the admin
* guard's hands, and CustomerJwtGuard then requires a valid customer token. The
* route is therefore NOT public — swapping the order or dropping either half
* would either lock customers out or leave the route open.
*/
export const CustomerAuth = () => applyDecorators(Public(), UseGuards(CustomerJwtGuard));
/**
* The authenticated customer: `@CurrentCustomer() me: AuthenticatedCustomer`.
*
* Every customer route derives its subject from this, never from an id in the
* URL or body. That is what closes the IDOR the old /customers/:id routes had,
* and it matches the reference, which read `auth()->id()`
* (henza_server ApiMobileController.php:533).
*/
export const CurrentCustomer = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthenticatedCustomer =>
ctx.switchToHttp().getRequest().user,
);
@@ -0,0 +1,39 @@
import { faToEn } from './digits';
/**
* Digit normalisation, ported from the reference's Utility::faTOen(), which
* every mobile number and every submitted OTP code passes through before it is
* compared (henza_server ApiMobileController.php:42,56).
*
* Without it a user typing ۱۲۳۴ on a Persian keypad could never match a stored
* 1234, and the phone lookup would miss a registered customer entirely.
*/
describe('faToEn', () => {
it('converts Persian (Extended Arabic-Indic) digits', () => {
expect(faToEn('۰۹۱۲۸۰۸۱۳۹۲')).toBe('09128081392');
});
it('converts Arabic-Indic digits', () => {
expect(faToEn('٠٩١٢٨٠٨١٣٩٢')).toBe('09128081392');
});
it('leaves ASCII digits untouched', () => {
expect(faToEn('09128081392')).toBe('09128081392');
});
it('handles a mixed-script number', () => {
expect(faToEn('۰۹12۸0۸1۳۹۲')).toBe('09128081392');
});
it('trims surrounding whitespace, as a pasted number often carries', () => {
expect(faToEn(' ۰۹۱۲۳۴۵۶۷۸۹ ')).toBe('09123456789');
});
it('normalises a 4-digit OTP code the same way', () => {
expect(faToEn('۷۳۵۷')).toBe('7357');
});
it('is a no-op on an empty string', () => {
expect(faToEn('')).toBe('');
});
});
@@ -0,0 +1,18 @@
/**
* Persian/Arabic-Indic digits to ASCII.
*
* Port of the reference's `Utility::faTOen()`, which every mobile number and
* every submitted OTP code passes through before being compared
* (henza_server ApiMobileController.php:42,56). The app's numeric keypad emits
* Persian digits, so without this a user typing ۱۲۳۴ would never match a stored
* 1234 and the phone lookup would miss a registered customer entirely.
*
* Kept in its own module, free of Nest and Prisma imports, so it can be unit
* tested without booting the DI container.
*/
export function faToEn(input: string): string {
return input
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
.trim();
}
@@ -0,0 +1,9 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
/** Body of POST customer/auth/request-otp. Legacy called this field `username`. */
export class RequestOtpDto {
@IsString()
@IsNotEmpty({ message: 'شماره موبایل الزامی است' })
@MaxLength(20)
username!: string;
}
@@ -0,0 +1,19 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
/**
* Body of POST customer/auth/verify-otp.
*
* Field names match the reference's userVerfiy, which took `username` and
* `password` (henza_server ApiMobileController.php:53).
*/
export class VerifyOtpDto {
@IsString()
@IsNotEmpty({ message: 'شماره موبایل الزامی است' })
@MaxLength(20)
username!: string;
@IsString()
@IsNotEmpty({ message: 'کد الزامی است' })
@MaxLength(10)
password!: string;
}