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
+7
View File
@@ -4,3 +4,10 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/gold_bank?schema=pub
# ports). NestJS/Prisma only load .env from this app's own directory, so this
# value is kept in sync manually rather than read from the root file.
PORT=3000
# Customer app sessions (security hotfix stage 2).
# Separate from JWT_SECRET so an admin token can never validate as a customer
# token. Falls back to JWT_SECRET, then to a dev-only string, when unset.
JWT_CUSTOMER_SECRET=
# Customer token lifetime. The reference issued a token that never expired.
JWT_CUSTOMER_EXPIRES_IN=30d
+22
View File
@@ -0,0 +1,22 @@
/**
* Jest for apps/api.
*
* `pnpm test` existed in package.json from the start but jest was never a
* dependency, so the script always failed. Added during security-hotfix stage 2
* with the user's approval, dev-only.
*
* Unit tests live next to the code as *.spec.ts. The live-server integration
* checks stay in scripts/auth-smoke.sh — they need a running API and a seeded
* database, which is a different thing from a unit run.
*/
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
moduleFileExtensions: ['js', 'json', 'ts'],
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: '<rootDir>/../tsconfig.json' }],
},
collectCoverageFrom: ['**/*.ts', '!**/*.spec.ts', '!**/*.module.ts'],
};
+6
View File
@@ -34,12 +34,18 @@
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/testing": "^10.4.22",
"@types/bcryptjs": "^3.0.0",
"@types/express": "^4.17.21",
"@types/jest": "^30.0.0",
"@types/node": "^20.14.0",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.2.1",
"esbuild": "^0.25.0",
"jest": "^30.5.2",
"prisma": "^5.19.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.12",
"ts-node": "^10.9.2",
"typescript": "^5.5.0"
}
@@ -0,0 +1,40 @@
-- Customer OTP login challenges.
--
-- Additive only: creates one new table and its indexes. No existing table,
-- column or row is touched.
--
-- Ports henza_server's SMS login (ApiMobileController.php:39-78). The reference
-- kept the code in plain text on the customer row with no expiry and no attempt
-- limit; this stores a bcrypt hash and adds both, per docs/parity/DECISIONS.md
-- D6 / T1.
CREATE TABLE IF NOT EXISTS "CustomerOtp" (
"id" TEXT NOT NULL,
"mobile" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"consumedAt" TIMESTAMP(3),
"requestIp" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerOtp_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "CustomerOtp_mobile_createdAt_idx"
ON "CustomerOtp"("mobile", "createdAt");
CREATE INDEX IF NOT EXISTS "CustomerOtp_customerId_idx"
ON "CustomerOtp"("customerId");
CREATE INDEX IF NOT EXISTS "CustomerOtp_expiresAt_idx"
ON "CustomerOtp"("expiresAt");
DO $$
BEGIN
ALTER TABLE "CustomerOtp"
ADD CONSTRAINT "CustomerOtp_customerId_fkey"
FOREIGN KEY ("customerId") REFERENCES "Customer"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
+41
View File
@@ -233,6 +233,7 @@ model Customer {
promotions Promotion[]
notifications NotificationRecipient[]
activityLogs CustomerActivityLog[]
otpChallenges CustomerOtp[]
@@index([referredByAgentId])
}
@@ -909,3 +910,43 @@ model UploadedFile {
@@index([purpose])
}
/// One OTP login challenge for a customer.
///
/// Ports henza_server's flow (ApiMobileController.php:39-78), where userAuth
/// generated rand(1000,9999), stored it on the customer row as `send_password`
/// and sent it by SMS, and userVerfiy compared the submitted code against it
/// (mismatch -> HTTP 422).
///
/// Three deliberate improvements over the reference, approved by the user
/// (docs/parity/DECISIONS.md D6 and T1):
/// 1. the code is stored as a bcrypt hash, never in plain text — the
/// reference kept it readable in a customer column;
/// 2. it expires (`expiresAt`) — the reference's code stayed valid forever;
/// 3. attempts are counted and the challenge locks out — the reference had
/// no limit at all.
/// The parity behaviour itself is the code comparison and the rejection.
model CustomerOtp {
id String @id @default(cuid())
/// Normalised to English digits before storing, as the reference does with
/// Utility::faTOen() on both send and verify.
mobile String
customerId String
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// bcrypt hash of the 4-digit code. Never the code itself.
codeHash String
expiresAt DateTime
/// Failed verify attempts against THIS challenge.
attempts Int @default(0)
/// Set when the code is accepted, so one challenge cannot be reused.
consumedAt DateTime?
/// Kept for the per-IP send throttle.
requestIp String?
createdAt DateTime @default(now())
@@index([mobile, createdAt])
@@index([customerId])
@@index([expiresAt])
}
+102 -6
View File
@@ -80,17 +80,113 @@ for row in "${CLOSED[@]}"; do
done
echo "Public routes must stay reachable:"
for p in "catalog/products" "pricing/latest?karatPerMille=750" "home/slider" "customers"; do
for p in "catalog/products" "pricing/latest?karatPerMille=750" "home/slider"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p")
[ "$c" = 200 ] && ok "GET $p -> 200" || bad "GET $p -> $c (want 200)"
done
echo "GET /customers must expose only the login-lookup fields:"
keys=$(curl -s "$API/customers" | sed -n 's/.*\({[^}]*}\).*/\1/p' | head -1 |
grep -oE '"[a-zA-Z]+":' | tr -d '":' | sort -u | tr '\n' ' ')
want="id isActive isArchived mobile "
[ "$keys" = "$want" ] && ok "fields = $keys" || bad "fields = [$keys] want [$want]"
# ---------------------------------------------------------------- stage 2 ---
# Customer session: real OTP, customer-scoped JWT, no id-bearing routes left.
echo "Old id-bearing customer routes must be gone entirely:"
for p in "customers" "customers/customer-dev-test" "customers/customer-dev-test/orders" "customers/customer-dev-test/ledger-entries"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p")
[ "$c" = 404 ] && ok "GET $p -> 404" || bad "GET $p -> $c (want 404)"
done
echo "Customer routes without a token must be 401:"
for p in "customer/me" "customer/me/orders" "customer/me/ledger-entries"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p")
[ "$c" = 401 ] && ok "GET $p -> 401" || bad "GET $p -> $c (want 401)"
done
# The dev seed's customer. Needs the API log to read the code back, since no SMS
# gateway is wired up — see CustomerAuthService.deliver.
CUST_PHONE=09128081392
otp_request() {
curl -s -o /dev/null -w '%{http_code}' -X POST "$API/customer/auth/request-otp" \
-H 'Content-Type: application/json' -d "{\"username\":\"$1\"}"
}
# Writes the JSON body to $BODY_FILE and prints the status code. The body goes
# to a file because this is called inside $(...), where a plain assignment would
# be lost along with the subshell.
BODY_FILE=$(mktemp)
trap 'rm -f "$BODY_FILE"' EXIT
otp_verify() { # mobile code -> prints http code; body lands in $BODY_FILE
curl -s -o "$BODY_FILE" -w '%{http_code}' -X POST "$API/customer/auth/verify-otp" \
-H 'Content-Type: application/json' -d "{\"username\":\"$1\",\"password\":\"$2\"}"
}
echo "OTP request:"
c=$(otp_request "$CUST_PHONE")
[ "$c" = 200 ] && ok "request-otp -> 200" || bad "request-otp -> $c (want 200)"
c=$(otp_request 09999999999)
[ "$c" = 200 ] && ok "unknown number answers 200 too (no enumeration)" \
|| bad "unknown number -> $c (want 200, identical to a known one)"
echo "OTP send throttle:"
c=$(otp_request "$CUST_PHONE")
[ "$c" = 429 ] && ok "immediate resend -> 429" || bad "immediate resend -> $c (want 429)"
echo "OTP verify:"
c=$(otp_verify "$CUST_PHONE" 0000)
[ "$c" = 401 ] && ok "wrong code -> 401" || bad "wrong code -> $c (want 401)"
# The real code only reachable from the server log; skip the rest when the
# caller did not point CODE_LOG at it.
if [ -n "${CODE_LOG:-}" ] && [ -f "$CODE_LOG" ]; then
CODE=$(grep -o "OTP for $CUST_PHONE: [0-9]*" "$CODE_LOG" | tail -1 | grep -o '[0-9]*$')
if [ -n "$CODE" ]; then
c=$(otp_verify "$CUST_PHONE" "$CODE")
if [ "$c" = 200 ]; then
ok "correct code -> 200"
CTOK=$(sed -n 's/.*"token":"\([^"]*\)".*/\1/p' "$BODY_FILE")
c=$(otp_verify "$CUST_PHONE" "$CODE")
[ "$c" = 401 ] && ok "same code reused -> 401 (single use)" \
|| bad "same code reused -> $c (want 401)"
echo "Customer token works on customer routes:"
for p in "customer/me" "customer/me/orders" "customer/me/ledger-entries"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $CTOK")
[ "$c" = 200 ] && ok "GET $p -> 200" || bad "GET $p -> $c (want 200)"
done
echo "Customer token must NOT work on admin routes:"
for p in "admin/auth/me" "admin/customers-omde/list" "admin/dashboard/overview"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $CTOK")
[ "$c" = 401 ] && ok "GET $p -> 401" || bad "GET $p -> $c (want 401)"
done
echo "Admin token must NOT work on customer routes:"
for p in "customer/me" "customer/me/orders"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $SUPER")
[ "$c" = 401 ] && ok "GET $p -> 401" || bad "GET $p -> $c (want 401)"
done
echo "No IDOR surface: /me* takes no id, and guessing one 404s:"
for p in "customer/me/customer-6" "customer/customer-6" "customers/customer-6"; do
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $CTOK")
[ "$c" = 404 ] && ok "GET $p -> 404" || bad "GET $p -> $c (want 404)"
done
echo "/customer/me returns the token's own customer:"
who=$(curl -s "$API/customer/me" -H "Authorization: Bearer $CTOK" |
sed -n 's/.*"mobile":"\([^"]*\)".*/\1/p')
[ "$who" = "$CUST_PHONE" ] && ok "mobile = $who" \
|| bad "mobile = $who (want $CUST_PHONE)"
else
bad "correct code -> $c (want 200)"
fi
else
printf ' \033[33mSKIP\033[0m no OTP line in %s yet\n' "$CODE_LOG"
fi
else
printf ' \033[33mSKIP\033[0m set CODE_LOG=/path/to/api.log to test the full OTP round trip\n'
fi
echo
echo "passed: $pass failed: $fail"
[ "$fail" -eq 0 ]
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { CustomersModule } from './modules/customers/customers.module';
import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module';
import { OrdersModule } from './modules/orders/orders.module';
import { AccountingModule } from './modules/accounting/accounting.module';
import { PricingModule } from './modules/pricing/pricing.module';
@@ -48,6 +49,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
AdminDashboardModule,
CatalogModule,
CustomersModule,
CustomerAuthModule,
SalesAgentModule,
OrdersModule,
AccountingModule,
@@ -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;
}
@@ -1,49 +0,0 @@
import { Controller, Get, Param } from '@nestjs/common';
import { CustomersService } from './customers.service';
import { Public } from '../auth/guards';
/**
* Customer-facing reads.
*
* Still `@Public()` because the target has no customer session yet — every one
* of these is called with `session.customerId` straight from the browser
* (DashboardView / ProfileView / OrdersView / TransactionsView), so any caller
* who guesses or harvests an id can read that customer's data. That is a
* textbook IDOR and it is what stage 2 of the security hotfix closes, by
* deriving the customer from a `customer-jwt` token instead of the URL
* (decision T1 in docs/parity/DECISIONS.md). The reference did exactly that
* via its `api-customer` guard.
*
* What stage 1 already fixed: `list()` no longer returns the full customer
* record — see LOGIN_LOOKUP_SELECT in customers.service.ts (SEC-08).
*/
@Public() // pre-existing unauthenticated endpoint; closed in hotfix stage 2
@Controller('customers')
export class CustomersController {
constructor(private readonly customers: CustomersService) {}
/**
* Login lookup only. Returns id/mobile/isActive/isArchived and nothing else;
* both apps' LoginView downloads this and matches the phone in the browser.
* Removed entirely in stage 2 once OTP verify returns the one customer.
*/
@Get()
list() {
return this.customers.list();
}
@Get(':id')
get(@Param('id') id: string) {
return this.customers.getOrThrow(id);
}
@Get(':id/orders')
getOrders(@Param('id') id: string) {
return this.customers.getOrders(id);
}
@Get(':id/ledger-entries')
getLedgerEntries(@Param('id') id: string) {
return this.customers.getLedgerEntries(id);
}
}
@@ -1,9 +1,21 @@
import { Module } from '@nestjs/common';
import { CustomersService } from './customers.service';
import { CustomersController } from './customers.controller';
/**
* Customer reads, exposed only through CustomerAuthController's token-derived
* /customer/me* routes.
*
* CustomersController used to live here and served GET /customers,
* /customers/:id, /customers/:id/orders and /customers/:id/ledger-entries, all
* unauthenticated. The list handed any visitor every customer's national code,
* debts, credit limit and identity-document URLs, and the :id routes let anyone
* holding an id read that customer's profile, orders and ledger — so narrowing
* the list alone did not close it. The whole controller is gone; the service
* stays and is now only reachable behind a customer token.
*
* See SEC-08 in docs/parity/ISSUES.md.
*/
@Module({
controllers: [CustomersController],
providers: [CustomersService],
exports: [CustomersService],
})
@@ -24,30 +24,15 @@ const PUBLIC_CUSTOMER_SELECT = {
} satisfies Prisma.CustomerSelect;
/**
* The only fields `GET /customers` may expose.
* Every method here is reached only through CustomerAuthController's
* token-derived /customer/me* routes, which pass the id from the JWT.
*
* That route is unauthenticated because both user apps' login screens download
* the list and match the phone number in the browser. With the full select
* above, an anonymous caller got the entire customer book: every national ID
* number, every debt and credit limit, and the URLs of every identity-document
* scan. Logged as SEC-08 in docs/parity/ISSUES.md.
*
* The reference never allowed this — its login was
* POST userAuth (SMS) -> POST userVerfiy, and the server returned exactly ONE
* customer after the code matched (henza_server ApiMobileController.php:39-78).
*
* Stage 2 of the security hotfix restores that flow and removes this route from
* the public surface entirely. Until then these four fields are what the login
* screens actually read (LoginView.vue:131-133 matches on mobile/isActive/
* isArchived, then calls session.login(id, mobile)) — nothing more.
* There used to be a `list()` returning every active customer, because the two
* apps' login screens downloaded it and matched the phone number in the
* browser. It is gone: the login lookup now happens server-side inside
* CustomerAuthService.requestOtp, which is what the reference did
* (henza_server ApiMobileController.php:39-49). See SEC-08.
*/
const LOGIN_LOOKUP_SELECT = {
id: true,
mobile: true,
isActive: true,
isArchived: true,
} satisfies Prisma.CustomerSelect;
@Injectable()
export class CustomersService {
constructor(private readonly prisma: PrismaService) {}
@@ -61,21 +46,6 @@ export class CustomersService {
return customer;
}
// Ported from legacy henza_server's WebApp\AuthController::userAuth() /
// ApiMobileController::userAuth(), both of which query
// Customer::active()->where('is_archive', 0) — an inactive or archived
// customer cannot authenticate in the customer-facing app. This endpoint is
// that same lookup surface for the user-app login flow (client-side match
// on `mobile`), so it must apply the same two filters, or a
// deactivated/archived customer would still be able to log in.
list() {
return this.prisma.customer.findMany({
where: { isActive: true, isArchived: false },
select: LOGIN_LOOKUP_SELECT,
orderBy: { createdAt: 'desc' },
});
}
getOrders(customerId: string) {
return this.prisma.order.findMany({
where: { customerId },