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:
co-authored by
Claude Opus 5
parent
fdf833565f
commit
4efe543d62
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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 $$;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,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 },
|
||||
|
||||
@@ -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}`),
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
+2258
-7
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user