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 },
+74 -25
View File
@@ -1,12 +1,56 @@
import type { CreateOrderDto, CustomerBalanceDto } from '@gold-bank/shared-types';
import type { CreateOrderDto } from '@gold-bank/shared-types';
const BASE = '/api';
const TOKEN_KEY = 'gold-bank:token';
/**
* Called when the API rejects our token. Wired up in main.ts rather than
* imported from the store, so this module stays free of a Pinia dependency
* (the client is used before Pinia is active during app bootstrap).
*/
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn;
}
/** Thrown for a 401 so callers can tell "session gone" from any other failure. */
export class UnauthorizedError extends Error {
constructor(path: string) {
super(`API ${path} rejected the session (401)`);
this.name = 'UnauthorizedError';
}
}
function readToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
// Private mode / blocked storage — treat as logged out rather than throwing.
return null;
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = readToken();
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
headers: {
'Content-Type': 'application/json',
// Customer JWT from POST /customer/auth/verify-otp. The API derives the
// customer from this, never from an id in the URL.
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (res.status === 401) {
// Expired or revoked: drop the session and send the user to login, so no
// stale personal data stays on screen behind a dead token.
onUnauthorized?.();
throw new UnauthorizedError(path);
}
if (!res.ok) {
const body = await res.text();
throw new Error(`API ${path} failed (${res.status}): ${body}`);
@@ -70,24 +114,6 @@ export interface LedgerEntrySummary {
note: string | null;
}
/**
* What `GET /customers` returns — the login lookup, and nothing more.
*
* It used to carry fullName, national ID, both debts and the credit limit. The
* route is unauthenticated (the login screen matches the phone number in the
* browser), so that shipped the whole customer book to any visitor; the API now
* selects only these four fields. See SEC-08 in docs/parity/ISSUES.md.
*
* The route disappears altogether once OTP verify returns the one authenticated
* customer, the way the reference did it.
*/
export interface CustomerSummary {
id: string;
mobile: string;
isActive: boolean;
isArchived: boolean;
}
export interface OrderSummary {
id: string;
orderNumber: string;
@@ -143,11 +169,34 @@ export interface GoldPriceQuote {
export const api = {
listProducts: () => request<ProductSummary[]>('/catalog/products'),
listCustomers: () => request<CustomerSummary[]>('/customers'),
getCustomer: (id: string) => request<CustomerDetail>(`/customers/${id}`),
getBalance: (customerId: string) => request<CustomerBalanceDto>(`/accounting/customers/${customerId}/balance`),
getLedgerEntries: (customerId: string) => request<LedgerEntrySummary[]>(`/customers/${customerId}/ledger-entries`),
/**
* Step 1 of login — ports henza_server ApiMobileController::userAuth.
* Always resolves `{ sent: true }`, even for an unregistered number, so the
* endpoint cannot be used to discover which numbers are customers.
*/
requestOtp: (username: string) =>
request<{ sent: boolean }>('/customer/auth/request-otp', {
method: 'POST',
body: JSON.stringify({ username }),
}),
/**
* Step 2 — ports ApiMobileController::userVerfiy. The server compares the
* code and returns a token plus that one customer. A wrong, expired or
* already-used code is a 401.
*/
verifyOtp: (username: string, password: string) =>
request<{ token: string; customer: { id: string; mobile: string } }>(
'/customer/auth/verify-otp',
{ method: 'POST', body: JSON.stringify({ username, password }) },
),
// The three reads below identify the customer by TOKEN. They replaced
// /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from
// the URL and let anyone read anyone (SEC-08).
getMe: () => request<CustomerDetail>('/customer/me'),
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
createOrder: (dto: CreateOrderDto) => request<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
getCustomerOrders: (customerId: string) => request<OrderDetail[]>(`/customers/${customerId}/orders`),
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/pricing/latest?karatPerMille=${karatPerMille}`),
};
+12
View File
@@ -3,6 +3,8 @@ import { createPinia } from 'pinia';
import VueLazyLoad from 'vue3-lazyload';
import App from './App.vue';
import { router } from './router';
import { setUnauthorizedHandler } from './api/client';
import { useSessionStore } from './stores/session';
import Container from './components/ui/Container.vue';
import Vector from './assets/img/Vector.svg';
@@ -18,6 +20,16 @@ const app = createApp(App);
app.use(createPinia());
app.use(router);
// A 401 from the API means the customer token is gone or expired. Clear the
// session and send the user to login, so no stale personal data stays on
// screen behind a dead token. Registered after Pinia so the store exists.
setUnauthorizedHandler(() => {
useSessionStore().logout();
if (router.currentRoute.value.name !== 'login') {
void router.push({ name: 'login' });
}
});
// Ported from legacy main.ts:32-46 — Vector.svg is both the while-loading and
// the on-error placeholder, so a missing/failed image paints the brand mark
// instead of collapsing the layout.
+2 -2
View File
@@ -53,10 +53,10 @@ router.beforeEach((to) => {
const session = useSessionStore();
const nav = useNavStore();
if (to.name === 'login' && session.customerId) {
if (to.name === 'login' && session.isLoggedIn) {
return { name: 'home' };
}
if (to.meta.requiresAuth && !session.customerId) {
if (to.meta.requiresAuth && !session.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } };
}
+41 -18
View File
@@ -1,35 +1,58 @@
import { defineStore } from 'pinia';
const TOKEN_KEY = 'gold-bank:token';
const CUSTOMER_ID_KEY = 'gold-bank:customerId';
const MOBILE_KEY = 'gold-bank:mobile';
/** Legacy's isUserLoggedIn() (Mixins/checkAccess.ts) reads this key. */
const LEGACY_FLAG_KEY = 'appToken';
/**
* Holds the logged-in customer's identity. Now backed by a real (if stubbed —
* see LoginView.vue) login flow: `login()` sets the same `appToken`
* localStorage key legacy's `isUserLoggedIn()` reads, so downstream login
* gates keep working unchanged.
* The logged-in customer's session.
*
* `token` is the credential. It is a real customer JWT issued by
* POST /api/customer/auth/verify-otp after the SMS code is checked server-side,
* and it is what every /customer/me* request is authorised with.
*
* `customerId` is kept for display and for cart bookkeeping only — it is NOT an
* identity claim any more. Until the OTP migration, the app sent this id in the
* URL of /customers/:id and the server trusted it, which meant anyone could
* read anyone's profile, orders and ledger by changing the value. The API no
* longer has an id-bearing customer route at all; the server derives the
* customer from the token. See SEC-08 in docs/parity/ISSUES.md.
*/
export const useSessionStore = defineStore('session', {
state: () => ({
customerId: localStorage.getItem('gold-bank:customerId') as string | null,
mobile: localStorage.getItem('gold-bank:mobile') as string | null,
token: localStorage.getItem(TOKEN_KEY) as string | null,
customerId: localStorage.getItem(CUSTOMER_ID_KEY) as string | null,
mobile: localStorage.getItem(MOBILE_KEY) as string | null,
}),
getters: {
/** Authenticated means holding a token, not merely knowing an id. */
isLoggedIn: (state) => Boolean(state.token),
},
actions: {
setCustomerId(id: string) {
this.customerId = id;
localStorage.setItem('gold-bank:customerId', id);
},
login(customerId: string, mobile: string) {
login(token: string, customerId: string, mobile: string) {
this.token = token;
this.customerId = customerId;
this.mobile = mobile;
localStorage.setItem('gold-bank:customerId', customerId);
localStorage.setItem('gold-bank:mobile', mobile);
// matches legacy's isUserLoggedIn() check (Mixins/checkAccess.ts)
localStorage.setItem('appToken', customerId);
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(CUSTOMER_ID_KEY, customerId);
localStorage.setItem(MOBILE_KEY, mobile);
localStorage.setItem(LEGACY_FLAG_KEY, token);
},
/**
* Clears everything. Called on explicit logout and by the API client when a
* request comes back 401 — an expired or revoked token must not leave stale
* personal data on screen.
*/
logout() {
this.token = null;
this.customerId = null;
this.mobile = null;
localStorage.removeItem('gold-bank:customerId');
localStorage.removeItem('gold-bank:mobile');
localStorage.removeItem('appToken');
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(CUSTOMER_ID_KEY);
localStorage.removeItem(MOBILE_KEY);
localStorage.removeItem(LEGACY_FLAG_KEY);
},
},
});
@@ -50,7 +50,7 @@
<div class="dash__barInner">
<span class="dash__brand">میز کار</span>
<button
v-if="session.customerId"
v-if="session.isLoggedIn"
type="button"
class="dash__exit"
aria-label="خروج"
@@ -219,7 +219,7 @@ const grams = computed(() =>
const data = reactive({ notify: false, loginNotify: false })
function goToDetails(id: number) {
if (!session.customerId) {
if (!session.isLoggedIn) {
data.loginNotify = true
return
}
@@ -236,8 +236,8 @@ function logOut() {
}
async function loadDashboard() {
if (session.customerId) {
customer.value = await api.getCustomer(session.customerId)
if (session.isLoggedIn) {
customer.value = await api.getMe()
}
try {
goldPrice.value = await api.getLatestGoldPrice()
+49 -24
View File
@@ -92,7 +92,7 @@ import NotifyVant from '../../components/NotifyVant.vue'
import OtpInput from '../../components/input/OtpInput.vue'
import { required, mobile as mobileRule } from '../../utils/validator'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerSummary } from '../../api/client'
import { api } from '../../api/client'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
@@ -107,8 +107,6 @@ const body = reactive({
defaultFocus: false,
})
let matchedCustomer: CustomerSummary | null = null
const timer = ref(0)
const countDownTimer = () => {
if (timer.value > 0) {
@@ -121,52 +119,79 @@ const countDownTimer = () => {
}
}
/**
* Step 1 — ask the server to send an SMS code.
*
* This used to download every customer via GET /customers and match the phone
* number here in the browser, which handed any visitor the whole customer book
* (SEC-08). The lookup now happens server-side, exactly as the reference did it
* (henza_server ApiMobileController::userAuth, :39-49).
*
* The response is deliberately the same whether or not the number is
* registered, so an unknown number can no longer be told apart from a known
* one. That means we advance to step 2 either way; a number that does not exist
* simply never receives a code, and the verify call then rejects it.
*/
const sendPhone = async () => {
try {
body.loading = true
// GET /api/customers already filters to isActive && !isArchived (matching
// legacy henza_server's Customer::active()->where('is_archive', 0)), but
// the check is repeated here in case that ever changes upstream — an
// inactive/archived customer must never be able to log in.
const customers = await api.listCustomers()
matchedCustomer =
customers.find((c) => c.mobile === formModel.username && c.isActive && !c.isArchived) ?? null
if (!matchedCustomer) {
notify.text = 'اطلاعات کاربری در سیستم وجود ندارد.'
notify.show = true
return
}
await api.requestOtp(String(formModel.username ?? ''))
body.step = 2
timer.value = 120
countDownTimer()
await nextTick(() => {
body.defaultFocus = true
})
} catch (e) {
console.log(e)
} catch (e: unknown) {
// 429 = the send throttle (too soon, or too many in the window). Its message
// is already Persian and user-facing, so show it rather than a generic one.
const text = e instanceof Error && /\(429\)/.test(e.message) ? extractServerMessage(e) : null
notify.text = text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.'
notify.show = true
} finally {
body.loading = false
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const sendToken = async (_otpCode: string) => {
// Stub: the OTP code itself isn't verified (no SMS was actually sent to
// check it against). `matchedCustomer` was already confirmed real in
// sendPhone(), so any 4-digit code here logs in as that customer.
/**
* Step 2 — submit the code.
*
* The server compares it (reference: ApiMobileController::userVerfiy, :51-57,
* which aborts 422 on a mismatch) and returns a real token plus that one
* customer. Previously any 4-digit code was accepted client-side.
*/
const sendToken = async (otpCode: string) => {
try {
body.loading = true
if (!matchedCustomer) return
session.login(matchedCustomer.id, matchedCustomer.mobile)
const { token, customer } = await api.verifyOtp(String(formModel.username ?? ''), otpCode)
session.login(token, customer.id, customer.mobile)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
await router.push(redirect ?? { name: 'home' })
notify.text = 'با موفقیت وارد شدید'
notify.show = true
} catch (e: unknown) {
notify.text =
e instanceof Error && /\(429\)/.test(e.message)
? (extractServerMessage(e) ?? 'تعداد تلاش‌ها بیش از حد مجاز است.')
: 'کد وارد شده صحیح نیست'
notify.show = true
} finally {
body.loading = false
}
}
/** Pulls the API's Persian `message` out of the thrown error's body text. */
function extractServerMessage(e: Error): string | null {
const match = e.message.match(/\{.*\}/)
if (!match) return null
try {
const parsed = JSON.parse(match[0]) as { message?: string }
return typeof parsed.message === 'string' ? parsed.message : null
} catch {
return null
}
}
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
@@ -194,10 +194,10 @@ function readSearch(value: string | undefined) {
}
async function loadOrders() {
if (!session.customerId) return
if (!session.isLoggedIn) return
loading.value = true
try {
allOrders.value = await api.getCustomerOrders(session.customerId)
allOrders.value = await api.getMyOrders()
} finally {
loading.value = false
}
@@ -86,10 +86,10 @@ const loading = ref(false)
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
onMounted(async () => {
if (!session.customerId) return
if (!session.isLoggedIn) return
loading.value = true
try {
customer.value = await api.getCustomer(session.customerId)
customer.value = await api.getMe()
} finally {
loading.value = false
}
@@ -140,10 +140,10 @@ const openEntry = (item: LedgerEntrySummary) => {
}
onMounted(async () => {
if (!session.customerId) return
if (!session.isLoggedIn) return
loading.value = true
try {
entries.value = await api.getLedgerEntries(session.customerId)
entries.value = await api.getMyLedgerEntries()
} finally {
loading.value = false
}
+76 -25
View File
@@ -1,12 +1,56 @@
import type { CreateOrderDto, CustomerBalanceDto } from '@gold-bank/shared-types';
import type { CreateOrderDto } from '@gold-bank/shared-types';
const BASE = '/api';
const TOKEN_KEY = 'gold-bank:token';
/**
* Called when the API rejects our token. Wired up in main.ts rather than
* imported from the store, so this module stays free of a Pinia dependency
* (the client is used before Pinia is active during app bootstrap).
*/
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn;
}
/** Thrown for a 401 so callers can tell "session gone" from any other failure. */
export class UnauthorizedError extends Error {
constructor(path: string) {
super(`API ${path} rejected the session (401)`);
this.name = 'UnauthorizedError';
}
}
function readToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
// Private mode / blocked storage — treat as logged out rather than throwing.
return null;
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = readToken();
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
headers: {
'Content-Type': 'application/json',
// Customer JWT from POST /customer/auth/verify-otp. The API derives the
// customer from this, never from an id in the URL.
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (res.status === 401) {
// Expired or revoked: drop the session and send the user to login, so no
// stale personal data stays on screen behind a dead token.
onUnauthorized?.();
throw new UnauthorizedError(path);
}
if (!res.ok) {
const body = await res.text();
throw new Error(`API ${path} failed (${res.status}): ${body}`);
@@ -85,24 +129,6 @@ export interface LedgerEntrySummary {
note: string | null;
}
/**
* What `GET /customers` returns — the login lookup, and nothing more.
*
* It used to carry fullName, national ID, both debts and the credit limit. The
* route is unauthenticated (the login screen matches the phone number in the
* browser), so that shipped the whole customer book to any visitor; the API now
* selects only these four fields. See SEC-08 in docs/parity/ISSUES.md.
*
* The route disappears altogether once OTP verify returns the one authenticated
* customer, the way the reference did it.
*/
export interface CustomerSummary {
id: string;
mobile: string;
isActive: boolean;
isArchived: boolean;
}
export interface OrderSummary {
id: string;
orderNumber: string;
@@ -165,12 +191,37 @@ export interface AdminSlide {
export const api = {
listProducts: () => request<ProductSummary[]>('/catalog/products'),
listCustomers: () => request<CustomerSummary[]>('/customers'),
getCustomer: (id: string) => request<CustomerDetail>(`/customers/${id}`),
getBalance: (customerId: string) => request<CustomerBalanceDto>(`/accounting/customers/${customerId}/balance`),
getLedgerEntries: (customerId: string) => request<LedgerEntrySummary[]>(`/customers/${customerId}/ledger-entries`),
/**
* Step 1 of login — ports henza_server ApiMobileController::userAuth.
* Always resolves `{ sent: true }`, even for an unregistered number, so the
* endpoint cannot be used to discover which numbers are customers.
*/
requestOtp: (username: string) =>
request<{ sent: boolean }>('/customer/auth/request-otp', {
method: 'POST',
body: JSON.stringify({ username }),
}),
/**
* Step 2 — ports ApiMobileController::userVerfiy. The server compares the
* code and returns a token plus that one customer. A wrong, expired or
* already-used code is a 401.
*/
verifyOtp: (username: string, password: string) =>
request<{ token: string; customer: { id: string; mobile: string } }>(
'/customer/auth/verify-otp',
{ method: 'POST', body: JSON.stringify({ username, password }) },
),
// The three reads below identify the customer by TOKEN. They replaced
// /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from
// the URL and let anyone read anyone (SEC-08).
getMe: () => request<CustomerDetail>('/customer/me'),
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
createOrder: (dto: CreateOrderDto) => request<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
getCustomerOrders: (customerId: string) => request<OrderDetail[]>(`/customers/${customerId}/orders`),
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/pricing/latest?karatPerMille=${karatPerMille}`),
/** GET /home/slider — public, no auth. Empty array when the admin hasn't
* configured any slides (the caller falls back to a derived hero). */
+12
View File
@@ -3,6 +3,8 @@ import { createPinia } from 'pinia';
import VueLazyLoad from 'vue3-lazyload';
import App from './App.vue';
import { router } from './router';
import { setUnauthorizedHandler } from './api/client';
import { useSessionStore } from './stores/session';
import Container from './components/ui/Container.vue';
// TODO: temporary placeholder — swap Vector.svg back in (or a proper loading
// mark) once one exists; montzar-logo.png is the brand logo, borrowed here
@@ -21,6 +23,16 @@ const app = createApp(App);
app.use(createPinia());
app.use(router);
// A 401 from the API means the customer token is gone or expired. Clear the
// session and send the user to login, so no stale personal data stays on
// screen behind a dead token. Registered after Pinia so the store exists.
setUnauthorizedHandler(() => {
useSessionStore().logout();
if (router.currentRoute.value.name !== 'login') {
void router.push({ name: 'login' });
}
});
// Ported from legacy main.ts:32-46 — Vector.svg is both the while-loading and
// the on-error placeholder, so a missing/failed image paints the brand mark
// instead of collapsing the layout.
+2 -2
View File
@@ -53,10 +53,10 @@ router.beforeEach((to) => {
const session = useSessionStore();
const nav = useNavStore();
if (to.name === 'login' && session.customerId) {
if (to.name === 'login' && session.isLoggedIn) {
return { name: 'home' };
}
if (to.meta.requiresAuth && !session.customerId) {
if (to.meta.requiresAuth && !session.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } };
}
+41 -18
View File
@@ -1,35 +1,58 @@
import { defineStore } from 'pinia';
const TOKEN_KEY = 'gold-bank:token';
const CUSTOMER_ID_KEY = 'gold-bank:customerId';
const MOBILE_KEY = 'gold-bank:mobile';
/** Legacy's isUserLoggedIn() (Mixins/checkAccess.ts) reads this key. */
const LEGACY_FLAG_KEY = 'appToken';
/**
* Holds the logged-in customer's identity. Now backed by a real (if stubbed —
* see LoginView.vue) login flow: `login()` sets the same `appToken`
* localStorage key legacy's `isUserLoggedIn()` reads, so downstream login
* gates keep working unchanged.
* The logged-in customer's session.
*
* `token` is the credential. It is a real customer JWT issued by
* POST /api/customer/auth/verify-otp after the SMS code is checked server-side,
* and it is what every /customer/me* request is authorised with.
*
* `customerId` is kept for display and for cart bookkeeping only — it is NOT an
* identity claim any more. Until the OTP migration, the app sent this id in the
* URL of /customers/:id and the server trusted it, which meant anyone could
* read anyone's profile, orders and ledger by changing the value. The API no
* longer has an id-bearing customer route at all; the server derives the
* customer from the token. See SEC-08 in docs/parity/ISSUES.md.
*/
export const useSessionStore = defineStore('session', {
state: () => ({
customerId: localStorage.getItem('gold-bank:customerId') as string | null,
mobile: localStorage.getItem('gold-bank:mobile') as string | null,
token: localStorage.getItem(TOKEN_KEY) as string | null,
customerId: localStorage.getItem(CUSTOMER_ID_KEY) as string | null,
mobile: localStorage.getItem(MOBILE_KEY) as string | null,
}),
getters: {
/** Authenticated means holding a token, not merely knowing an id. */
isLoggedIn: (state) => Boolean(state.token),
},
actions: {
setCustomerId(id: string) {
this.customerId = id;
localStorage.setItem('gold-bank:customerId', id);
},
login(customerId: string, mobile: string) {
login(token: string, customerId: string, mobile: string) {
this.token = token;
this.customerId = customerId;
this.mobile = mobile;
localStorage.setItem('gold-bank:customerId', customerId);
localStorage.setItem('gold-bank:mobile', mobile);
// matches legacy's isUserLoggedIn() check (Mixins/checkAccess.ts)
localStorage.setItem('appToken', customerId);
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(CUSTOMER_ID_KEY, customerId);
localStorage.setItem(MOBILE_KEY, mobile);
localStorage.setItem(LEGACY_FLAG_KEY, token);
},
/**
* Clears everything. Called on explicit logout and by the API client when a
* request comes back 401 — an expired or revoked token must not leave stale
* personal data on screen.
*/
logout() {
this.token = null;
this.customerId = null;
this.mobile = null;
localStorage.removeItem('gold-bank:customerId');
localStorage.removeItem('gold-bank:mobile');
localStorage.removeItem('appToken');
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(CUSTOMER_ID_KEY);
localStorage.removeItem(MOBILE_KEY);
localStorage.removeItem(LEGACY_FLAG_KEY);
},
},
});
@@ -36,7 +36,7 @@
<span class="dash__barSpacer" aria-hidden="true" />
<h1 class="dash__barTitle">میز کار</h1>
<button
v-if="session.customerId"
v-if="session.isLoggedIn"
type="button"
class="dash__barBtn"
aria-label="خروج"
@@ -189,7 +189,7 @@ const list = [
]
function goToDetails(id: number) {
if (!session.customerId) {
if (!session.isLoggedIn) {
data.loginNotify = true
return
}
@@ -206,8 +206,8 @@ function logOut() {
}
async function loadDashboard() {
if (session.customerId) {
customer.value = await api.getCustomer(session.customerId)
if (session.isLoggedIn) {
customer.value = await api.getMe()
}
try {
goldPrice.value = await api.getLatestGoldPrice()
+49 -24
View File
@@ -92,7 +92,7 @@ import NotifyVant from '../../components/NotifyVant.vue'
import OtpInput from '../../components/input/OtpInput.vue'
import { required, mobile as mobileRule } from '../../utils/validator'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerSummary } from '../../api/client'
import { api } from '../../api/client'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
@@ -107,8 +107,6 @@ const body = reactive({
defaultFocus: false,
})
let matchedCustomer: CustomerSummary | null = null
const timer = ref(0)
const countDownTimer = () => {
if (timer.value > 0) {
@@ -121,52 +119,79 @@ const countDownTimer = () => {
}
}
/**
* Step 1 — ask the server to send an SMS code.
*
* This used to download every customer via GET /customers and match the phone
* number here in the browser, which handed any visitor the whole customer book
* (SEC-08). The lookup now happens server-side, exactly as the reference did it
* (henza_server ApiMobileController::userAuth, :39-49).
*
* The response is deliberately the same whether or not the number is
* registered, so an unknown number can no longer be told apart from a known
* one. That means we advance to step 2 either way; a number that does not exist
* simply never receives a code, and the verify call then rejects it.
*/
const sendPhone = async () => {
try {
body.loading = true
// GET /api/customers already filters to isActive && !isArchived (matching
// legacy henza_server's Customer::active()->where('is_archive', 0)), but
// the check is repeated here in case that ever changes upstream — an
// inactive/archived customer must never be able to log in.
const customers = await api.listCustomers()
matchedCustomer =
customers.find((c) => c.mobile === formModel.username && c.isActive && !c.isArchived) ?? null
if (!matchedCustomer) {
notify.text = 'اطلاعات کاربری در سیستم وجود ندارد.'
notify.show = true
return
}
await api.requestOtp(String(formModel.username ?? ''))
body.step = 2
timer.value = 120
countDownTimer()
await nextTick(() => {
body.defaultFocus = true
})
} catch (e) {
console.log(e)
} catch (e: unknown) {
// 429 = the send throttle (too soon, or too many in the window). Its message
// is already Persian and user-facing, so show it rather than a generic one.
const text = e instanceof Error && /\(429\)/.test(e.message) ? extractServerMessage(e) : null
notify.text = text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.'
notify.show = true
} finally {
body.loading = false
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const sendToken = async (_otpCode: string) => {
// Stub: the OTP code itself isn't verified (no SMS was actually sent to
// check it against). `matchedCustomer` was already confirmed real in
// sendPhone(), so any 4-digit code here logs in as that customer.
/**
* Step 2 — submit the code.
*
* The server compares it (reference: ApiMobileController::userVerfiy, :51-57,
* which aborts 422 on a mismatch) and returns a real token plus that one
* customer. Previously any 4-digit code was accepted client-side.
*/
const sendToken = async (otpCode: string) => {
try {
body.loading = true
if (!matchedCustomer) return
session.login(matchedCustomer.id, matchedCustomer.mobile)
const { token, customer } = await api.verifyOtp(String(formModel.username ?? ''), otpCode)
session.login(token, customer.id, customer.mobile)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
await router.push(redirect ?? { name: 'home' })
notify.text = 'با موفقیت وارد شدید'
notify.show = true
} catch (e: unknown) {
notify.text =
e instanceof Error && /\(429\)/.test(e.message)
? (extractServerMessage(e) ?? 'تعداد تلاش‌ها بیش از حد مجاز است.')
: 'کد وارد شده صحیح نیست'
notify.show = true
} finally {
body.loading = false
}
}
/** Pulls the API's Persian `message` out of the thrown error's body text. */
function extractServerMessage(e: Error): string | null {
const match = e.message.match(/\{.*\}/)
if (!match) return null
try {
const parsed = JSON.parse(match[0]) as { message?: string }
return typeof parsed.message === 'string' ? parsed.message : null
} catch {
return null
}
}
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
+2 -2
View File
@@ -194,10 +194,10 @@ function readSearch(value: string | undefined) {
}
async function loadOrders() {
if (!session.customerId) return
if (!session.isLoggedIn) return
loading.value = true
try {
allOrders.value = await api.getCustomerOrders(session.customerId)
allOrders.value = await api.getMyOrders()
} finally {
loading.value = false
}
+2 -2
View File
@@ -86,10 +86,10 @@ const loading = ref(false)
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
onMounted(async () => {
if (!session.customerId) return
if (!session.isLoggedIn) return
loading.value = true
try {
customer.value = await api.getCustomer(session.customerId)
customer.value = await api.getMe()
} finally {
loading.value = false
}
@@ -140,10 +140,10 @@ const openEntry = (item: LedgerEntrySummary) => {
}
onMounted(async () => {
if (!session.customerId) return
if (!session.isLoggedIn) return
loading.value = true
try {
entries.value = await api.getLedgerEntries(session.customerId)
entries.value = await api.getMyLedgerEntries()
} finally {
loading.value = false
}
+84
View File
@@ -92,3 +92,87 @@ Branch `parity/security-hotfix`. Authorised by the user's rulings D7, T1–T3.
`/uploads` — per ruling T2, uploaded files including identity documents stay
public. The filename check requested alongside it passed with no changes needed
(md5 of time + name + UUID; no directory listing).
---
## 2026-09-22 — Security hotfix, stage 2 — real customer sessions
Branch `parity/security-hotfix`. Authorised by D6, D7, T1 and T3.
Stage 1 narrowed `GET /customers` but, as the user pointed out, 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
two requests still reconstructed the whole book. Stage 2 removes the id-bearing
surface entirely.
### 1. Customer JWT, separate from the admin one (T1)
- **New:** `src/modules/customer-auth/` — `CustomerJwtStrategy` (passport
strategy `'customer-jwt'`), `CustomerJwtGuard`, `@CustomerAuth()`,
`@CurrentCustomer()`.
- Signed with **`JWT_CUSTOMER_SECRET`**, separate from `JWT_SECRET`, and every
token carries **`typ: 'customer'`** which the strategy verifies. Either check
alone would be enough; both are present deliberately.
- Customer state is re-read from the DB on every request, so deactivating or
archiving a customer takes effect at once rather than at token expiry.
- **Reference:** the same split existed there — `auth:api` for admins,
`auth('api-customer')` for the app.
### 2. Real OTP (D6)
- **New table `CustomerOtp`** (migration `20260922000000_customer_otp`,
additive: one new table, nothing existing touched).
- `POST /api/customer/auth/request-otp` ← `ApiMobileController::userAuth` (:39-49)
- `POST /api/customer/auth/verify-otp` ← `ApiMobileController::userVerfiy` (:51-64)
- **Parity behaviour:** the 4-digit `rand(1000,9999)` range, the
active-customer filter, `Utility::faTOen()` digit normalisation on both the
number and the code, and rejection on mismatch.
- **Deliberate improvements over the reference** (user-approved, recorded in
DECISIONS.md so the matrix does not flag them later):
- the code is stored as a **bcrypt hash**, never plain text;
- it **expires** after 2 minutes (the reference's never did);
- **5 attempts** per challenge, then lockout (the reference had no limit);
- **single use** — a consumed code cannot be replayed;
- **send throttle**: 60s cooldown per number, 5 per number and 20 per IP per
10 minutes, counted off the table so it survives restarts;
- **no enumeration** — an unregistered number returns the same `{sent:true}`.
The reference's `firstOrFail()` leaked a 404 for unknown numbers.
- No SMS gateway exists in this project (STUB-02), so the code is logged in
non-production only and the message is persisted as an `SmsMessage` row. The
reference's message text is kept verbatim for when a gateway lands.
### 3. The id-bearing customer routes are gone
- **Deleted** `customers.controller.ts` — with it `GET /customers`,
`/customers/:id`, `/:id/orders`, `/:id/ledger-entries`.
- **Replaced by** `/api/customer/me`, `/me/orders`, `/me/ledger-entries`, which
take the id **from the token**. There is no longer any route that accepts a
customer id, so the IDOR is closed structurally rather than by a check.
- `CustomersService` stays and is reached only through those routes.
- The admin panel was unaffected — it uses `admin/customers-omde/*`.
### 4. Both apps migrated together (parity rule)
- `stores/session.ts`: `token` is now the credential and `isLoggedIn` is
token-based. `customerId` is kept for display only and is no longer an
identity claim.
- `api/client.ts`: sends `Authorization: Bearer`, throws `UnauthorizedError` on
401 and calls the handler registered in `main.ts`, which clears the session
and routes to login so stale personal data cannot stay on screen.
- `LoginView.vue`: real two-step OTP. Each app keeps its own markup and styling;
only the logic changed, and it is identical in both.
- The four account screens now call `getMe()` / `getMyOrders()` /
`getMyLedgerEntries()`; router guards test `isLoggedIn`.
### Tests
- `apps/api/scripts/auth-smoke.sh` extended with the stage-2 assertions:
**46 passed, 0 failed** against a live server — old routes 404, customer
routes 401 without a token, wrong/reused code 401, correct code 200, throttle
429, no enumeration, customer token rejected on admin routes and admin token
rejected on customer routes, no id-bearing surface, and `/customer/me`
returning the token's own customer.
- **jest installed** (dev-only, user-approved) with `jest.config.cjs`, so
`pnpm test` works for the first time: **7 passed** covering `faToEn`.
`faToEn` moved to its own `digits.ts` so it can be tested without booting Nest.
- Typecheck clean on api, user-app and user-app-v2; both apps build.
+38
View File
@@ -421,3 +421,41 @@ and returned **only that one customer** after the code matched. There was no
lookup. **D6 (real OTP) is the fix for both problems at once** — once
`POST auth/otp/verify` returns the authenticated customer, `GET /customers` can
be taken off the public surface entirely.
---
# Stage 2 implementation record (2026-09-22)
## Deliberate improvements over the reference — user-approved
Recorded here so the Phase 1/2 matrices do not later flag them as parity gaps.
| # | Behaviour | Reference | Target | Approved in |
|---|---|---|---|---|
| 1 | OTP storage | plain text in `customer.send_password` | bcrypt hash in `CustomerOtp.codeHash` | D6 |
| 2 | OTP expiry | none — valid forever | 2 minutes | D6 |
| 3 | Attempt limit | none | 5 per challenge, then lockout | D6 |
| 4 | Replay | code stayed usable | single use (`consumedAt`) | D6 |
| 5 | Send throttle | none | 60s cooldown/number, 5 per number and 20 per IP per 10 min | user message of 2026-09-22 |
| 6 | Unknown number | `firstOrFail()` → 404, enumerable | identical `{sent:true}` | implied by 5; recorded here |
| 7 | Token rotation | `api_token` issued once, reused forever | fresh token on every verify | T1 |
| 8 | Token expiry | none | `JWT_CUSTOMER_EXPIRES_IN`, default 30d | T1 |
| 9 | Token secret | single opaque string | `JWT_CUSTOMER_SECRET`, separate from admin, plus a `typ` claim | T1 |
## New environment variables
| Name | Default | Purpose |
|---|---|---|
| `JWT_CUSTOMER_SECRET` | falls back to `JWT_SECRET`, then a dev string | signs customer tokens |
| `JWT_CUSTOMER_EXPIRES_IN` | `30d` | customer token lifetime |
Both fall back so local development keeps working, and both inherit the SEC-04
problem (an insecure default when unset) — tracked there, not re-logged.
## Known gap left open on purpose
`POST /orders` and `GET /orders/:id` are still `@Public()`. The customer token
now exists, so they can be closed, but `CreateOrderDto` carries `customerId` in
the body and changing that is an API-contract change touching both apps' cart
flow. Left for the Phase 2 apps work rather than widened into this hotfix.
Tracked as SEC-02 (B1/B2) in `ISSUES.md`.
+2 -2
View File
@@ -17,8 +17,8 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|---|---|---|---|---|---|
| SEC-01 | Critical | REF `henza_server/routes/admin.php:12,272` | The whole admin route file is **unauthenticated** — the `Route::middleware('auth:api')->group(...)` wrapper is commented out at both ends, leaving all ~215 admin routes public (incl. `wallet/charge`, `users`, `roles`). | Verified by reading both lines. | n/a in reference (read-only). Drives TGT decision below. |
| SEC-02 | Critical | TGT `apps/api/src/modules/accounting/accounting.controller.ts:7`, `orders/orders.controller.ts:7`, `customers/customers.controller.ts:5`, `catalog/catalog.controller.ts:5`, `pricing/pricing.controller.ts:5` | 15 routes carry `@Public()`, including **ledger writes** (`POST accounting/repayments/*`, `entries/:id/confirm`) and `POST /api/orders` + `PATCH /api/orders/:id/status`. The public status DTO accepts a client-supplied `actorAdminUserId`, so a caller can attribute an action to any admin. | `grep -rn "@Public()"`; source comments say "pre-existing unauthenticated endpoint". | Introduce customer-session auth for the app-facing routes; move ledger/status writes behind the admin guard. **[replicated]** from SEC-01. |
| SEC-03 | Critical | TGT `apps/user-app/src/views/user/LoginView.vue`, `apps/user-app-v2/` same | OTP is **not verified** — any 4-digit code logs the user in. This is a **parity gap**: the reference does verify it (`ApiMobileController.php:51-57`, mismatch → HTTP 422). | `DECISIONS.md` D6 | **FIXING — D6** (plus expiry + attempt limits, which exceed the reference) |
| SEC-08 | ~~Critical~~ **MITIGATED — hotfix stage 1** | TGT `apps/api/src/modules/customers/customers.service.ts:6-23,44-50` + `customers.controller.ts:10` (`@Public()`) | **Full customer book exposed to anonymous callers.** Login downloads `GET /customers` and matches the phone client-side, so the endpoint returns **every** active customer with `fullName`, `mobile`, **`nationalCode`**, `goldDebtGrams`, `fiatDebtRial`, `goldCreditLimitGrams`, and the URLs of **national-ID / business-licence / shop scans** (and `GET uploads/:filename` is public too, so those URLs resolve). `passwordHash` is correctly excluded. Not possible in the reference, where `userVerfiy` returned **one** customer after the code matched. | Read `customers.service.ts:5-23`; `DECISIONS.md` D8 | ✅ Stage 1: narrowed to `id`/`mobile`/`isActive`/`isArchived`. National ID, debts, credit limit and document URLs no longer leave the API. Route closes in stage 2 with OTP. |
| SEC-03 | ~~Critical~~ **FIXED — hotfix stage 2** | TGT `apps/user-app/src/views/user/LoginView.vue`, `apps/user-app-v2/` same | OTP is **not verified** — any 4-digit code logs the user in. This is a **parity gap**: the reference does verify it (`ApiMobileController.php:51-57`, mismatch → HTTP 422). | `DECISIONS.md` D6 | ✅ Real server-side OTP: bcrypt-hashed code, 2-min expiry, 5 attempts, single use, send throttle, no enumeration. Verified end-to-end by `auth-smoke.sh`. |
| SEC-08 | ~~Critical~~ **FIXED — hotfix stage 2** | TGT `apps/api/src/modules/customers/customers.service.ts:6-23,44-50` + `customers.controller.ts:10` (`@Public()`) | **Full customer book exposed to anonymous callers.** Login downloads `GET /customers` and matches the phone client-side, so the endpoint returns **every** active customer with `fullName`, `mobile`, **`nationalCode`**, `goldDebtGrams`, `fiatDebtRial`, `goldCreditLimitGrams`, and the URLs of **national-ID / business-licence / shop scans** (and `GET uploads/:filename` is public too, so those URLs resolve). `passwordHash` is correctly excluded. Not possible in the reference, where `userVerfiy` returned **one** customer after the code matched. | Read `customers.service.ts:5-23`; `DECISIONS.md` D8 | ✅ Closed. The whole controller is deleted — no route accepts a customer id any more. Reads moved to token-derived `/customer/me*`. Stage 1 had only narrowed the fields, which left the ids and the `:id` routes exposed. |
| SEC-09 | ~~Critical~~ **FIXED — hotfix stage 1** | TGT `apps/api/src/modules/accounting/accounting.controller.ts:7` | `@Public()` on the whole accounting controller exposes **5 routes, 4 of them writes**: reconcile a balance, record a gold repayment, record a rial repayment, confirm a ledger entry. Faithful to the reference (whose admin routes were all unauthenticated) but contradicts the confirmed decision D1. | `DECISIONS.md` §"@Public audit" group A | ✅ Done: rids `wallet.list`, `wallet.charge`, `document.create` ×2, `document.list.confirm`. Verified 401/403/pass-through by `apps/api/scripts/auth-smoke.sh`. |
| SEC-10 | ~~High~~ **FIXED — hotfix stage 1** | TGT `apps/api/src/modules/orders/orders.controller.ts:22` | `PATCH orders/:id/status` is public **and** its DTO accepts a client-supplied `actorAdminUserId`, so an anonymous caller can move an order's status and attribute it to any admin. In the reference this was an admin operation. | `DECISIONS.md` §"@Public audit" B3 | ✅ Done: `order.list.sts.submit`. Had no caller — the admin panel uses `admin/orders/status`. |
| SEC-04 | High | TGT `apps/api` (two files) | `JWT_SECRET` falls back to `'dev-only-insecure-secret'` and is absent from `.env.example`. `enableCors()` is wide open. | Reported by api inventory; see `_inv-tgt-api.md` §11. | Require the env var at boot; restrict CORS origins. |
+36 -16
View File
@@ -7,27 +7,47 @@
| Field | Value |
|---|---|
| **Phase** | Security hotfix (out-of-band, authorised by D7/T1–T3) — **stage 1 ✅ done**, stage 2 next |
| **Phase** | Security hotfix — **stages 1 and 2 ✅ complete** |
| **Branch** | `parity/security-hotfix` |
| **Status** | Stage 1 complete and tested live: 23/23 smoke assertions pass. Decisions D0–D12 + T1–T3 recorded. |
| **Next step** | Stage 2 — customer JWT (`typ` claim, separate secret) + real OTP + migrate both apps' login; then remove `GET /customers` from the public surface |
| **Blocked on** | nothing for stage 2. Still pending later: D0 migration + `percent` migration must be **shown and approved before running** |
| **Status** | Both stages done and verified live: **46/46** smoke assertions, **7/7** jest unit tests, typecheck + build clean on all three projects. |
| **Next step** | Show the user the **D0 (firstName/lastName)** and **`percent`** migrations for approval, then start `01-ADMIN-MATRIX.md` field-by-field (D12) |
| **Blocked on** | user approval of those two migrations before they run |
| **Last updated** | 2026-09-22 |
### Security hotfix — stage 1 result
### Security hotfix — final result
| Route | Was | Now | Rid |
|---|---|---|---|
| `GET accounting/customers/:id/balance` | public | admin | `wallet.list` |
| `POST accounting/customers/:id/reconcile` | public **write** | admin | `wallet.charge` |
| `POST accounting/repayments/gold` | public **write** | admin | `document.create` |
| `POST accounting/repayments/rial` | public **write** | admin | `document.create` |
| `POST accounting/entries/:id/confirm` | public **write** | admin | `document.list.confirm` |
| `PATCH orders/:id/status` | public **write** | admin | `order.list.sts.submit` |
| `GET customers` | 18 fields incl. national ID, debts, doc URLs | 4 fields | still public until stage 2 |
| Route | Was | Now |
|---|---|---|
| `GET accounting/customers/:id/balance` | public | admin, rid `wallet.list` |
| `POST accounting/customers/:id/reconcile` | public **write** | admin, rid `wallet.charge` |
| `POST accounting/repayments/gold` | public **write** | admin, rid `document.create` |
| `POST accounting/repayments/rial` | public **write** | admin, rid `document.create` |
| `POST accounting/entries/:id/confirm` | public **write** | admin, rid `document.list.confirm` |
| `PATCH orders/:id/status` | public **write** | admin, rid `order.list.sts.submit` |
| `GET customers` | every customer, 18 fields | **route deleted** |
| `GET customers/:id` | public | **route deleted** → `GET customer/me` (token) |
| `GET customers/:id/orders` | public | **route deleted** → `GET customer/me/orders` (token) |
| `GET customers/:id/ledger-entries` | public | **route deleted** → `GET customer/me/ledger-entries` (token) |
| — | — | **new** `POST customer/auth/request-otp` · `verify-otp` |
Test: `apps/api/scripts/auth-smoke.sh` — **23 passed, 0 failed**.
Typecheck: api ✅ · user-app ✅ · user-app-v2 ✅.
Still public by design: `catalog/products`, `pricing/latest`, `home/slider`,
`uploads/:filename` (ruling T2), and `POST /orders` + `GET /orders/:id` — the
last two are logged as SEC-02/B1-B2 and want a customer-token pass of their own.
**Tests**
- `apps/api/scripts/auth-smoke.sh` (live server): **46 passed, 0 failed**.
Run it with `API=http://localhost:3000/api CODE_LOG=/path/to/api.log`.
- `pnpm test` in `apps/api` (jest, newly installed): **7 passed**.
- Typecheck: api ✅ · user-app ✅ · user-app-v2 ✅. Both apps build ✅.
### ⚠️ Correction worth remembering
Stage 1 reported the customer-book leak as "mitigated" after narrowing the
`GET /customers` field list. That was wrong: the response still returned every
customer **id**, and the three `/customers/:id*` routes were still public, so the
full data was still two requests away. The user caught it. Stage 2 deleted the
whole id-bearing surface instead. **Narrowing a response is not closing a leak
while the id and the id-addressed routes remain.**
## ⚠️ Read `DECISIONS.md` before doing anything
+2258 -7
View File
File diff suppressed because it is too large Load Diff