parity(api,apps): close SEC-02 - orders take the customer from the token
The user declined to defer this to Phase 2, correctly: a public POST /orders with customerId in the body let anyone place an order on any customer's account, and an order lands on that customer's gold debt and ledger. POST /orders is now behind CustomerAuth. OrdersService.createOrder takes the customer id as a separate argument that the controller fills from the token; the body's customerId is ignored and a mismatch is logged. The field stays optional in the DTO and in shared-types only so both apps keep validating while they still send it -- they drop it in Phase 2. The admin builder is unaffected: it legitimately places orders for a chosen customer and passes that id explicitly, behind the admin guard. GET /orders/:id is scoped to the owner. A foreign or nonexistent id both return 404, never 403, so ids stay unenumerable. Operators read through the new GET /orders/admin/:id behind rid order.list, declared before the :id route because Nest matches in declaration order. Both apps' CartView now gates on session.isLoggedIn rather than a remembered customerId, which is no longer a credential. OTP audit (requested): no master code, no bypass, no hardcoded code anywhere; one code path issues a token and only after bcrypt.compare. No SMS provider is wired up -- in development the code is logged once, guarded by NODE_ENV !== 'production'. Fixed while auditing (SEC-11): deliver() persisted the real message text, code included, into SmsMessage.body, which the admin SMS grid lists and full-text searches. Any operator with the sms rid could read live codes, defeating the hashing of codeHash. The stored row is now a redacted audit line. Also logged MON-08: wagePercent/discountPercent/flatRialCharge are still taken from the client on order creation. The reference is worse -- it takes rash_weight from the client too -- so this is already a partial improvement; logged rather than changed, per D4. Tests: auth-smoke.sh now 53 passed, 0 failed, including a spoofed body customerId being ignored in favour of the token. Typecheck clean on api, user-app, user-app-v2 and shared-types. [ref: henza_server ApiMobileController.php:533,536-545,615-632] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4efe543d62
commit
024e3dd045
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
@@ -17,6 +17,8 @@ interface ComputedItem {
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
private readonly logger = new Logger(OrdersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalog: CatalogService,
|
||||
@@ -29,9 +31,24 @@ export class OrdersService {
|
||||
* mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here —
|
||||
* only once the order reaches DELIVERED_TO_AGENT (see updateStatus).
|
||||
*/
|
||||
async createOrder(dto: CreateOrderDto) {
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: dto.customerId } });
|
||||
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
|
||||
/**
|
||||
* @param customerId taken from the caller's token by the controller, NEVER
|
||||
* from the request body. The body's `customerId` is ignored; see the note
|
||||
* on CreateOrderDto.
|
||||
*/
|
||||
async createOrder(dto: CreateOrderDto, customerId: string) {
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
||||
|
||||
// The apps still send customerId in the body (removed from them in Phase 2).
|
||||
// It is not used for anything; a mismatch means either a stale client or an
|
||||
// attempt to order on someone else's account, and both are worth seeing.
|
||||
if (dto.customerId && dto.customerId !== customerId) {
|
||||
this.logger.warn(
|
||||
`Order body carried customerId=${dto.customerId} but the token is ${customerId}; ` +
|
||||
`the token wins.`,
|
||||
);
|
||||
}
|
||||
|
||||
const computedItems: ComputedItem[] = await Promise.all(
|
||||
dto.items.map(async (item) => {
|
||||
@@ -78,7 +95,7 @@ export class OrdersService {
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
orderNumber: this.generateOrderNumber(),
|
||||
customerId: dto.customerId,
|
||||
customerId,
|
||||
branchId: dto.branchId,
|
||||
channel: dto.channel ?? OrderChannel.RETAIL,
|
||||
status: initialStatus,
|
||||
@@ -138,6 +155,7 @@ export class OrdersService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Unscoped read. Only reachable from the admin-guarded route. */
|
||||
getById(orderId: string) {
|
||||
return this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
@@ -145,6 +163,22 @@ export class OrdersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer reading one of their own orders.
|
||||
*
|
||||
* Someone else's order is reported as 404, not 403: a 403 would confirm the
|
||||
* id exists, which is the same enumeration leak the id-addressed customer
|
||||
* routes had.
|
||||
*/
|
||||
async getOwnedById(orderId: string, customerId: string) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, customerId },
|
||||
include: { items: true, statusEvents: true, ledgerEntries: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
return order;
|
||||
}
|
||||
|
||||
private generateOrderNumber(): string {
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
return `ORD-${Date.now()}-${random}`;
|
||||
|
||||
Reference in New Issue
Block a user