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
@@ -2,40 +2,59 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { OrdersService } from './orders.service';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
||||
import { Public, RequirePermission } from '../auth/guards';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
|
||||
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
|
||||
|
||||
/**
|
||||
* Customer-facing order routes.
|
||||
* Order routes.
|
||||
*
|
||||
* POST / and GET /:id stay `@Public()` for now: the target has no customer
|
||||
* session yet, so the customer id travels in the request body. The reference
|
||||
* derived it from the `api-customer` guard instead
|
||||
* (henza_server ApiMobileController.php:533 reads `auth()->id()`), which is what
|
||||
* stage 2 of the security hotfix restores. Tracked as SEC-02 / B1-B2 in
|
||||
* docs/parity/ISSUES.md.
|
||||
*
|
||||
* PATCH /:id/status was public too, which was worse in kind: it let an
|
||||
* anonymous caller move any order's status AND attribute the change to any
|
||||
* admin via the DTO's actorAdminUserId. It is an admin operation in the
|
||||
* reference, it had no caller in this repo (the admin panel posts to
|
||||
* admin/orders/status instead), so it is now behind the order-status rid.
|
||||
* All three were `@Public()` until the security hotfix. The reference's
|
||||
* customer-facing order endpoints sat behind its `api-customer` guard and read
|
||||
* the customer from the token (`auth()->id()`,
|
||||
* henza_server ApiMobileController.php:533); the target had no customer session
|
||||
* at the time, so the id travelled in the body and the server trusted it.
|
||||
* That meant an anonymous caller could place an order on anyone's account —
|
||||
* and an order lands on that customer's gold debt and ledger. Now that the
|
||||
* customer token exists, the reference's arrangement is restored.
|
||||
*/
|
||||
@Controller('orders')
|
||||
export class OrdersController {
|
||||
constructor(private readonly orders: OrdersService) {}
|
||||
|
||||
@Public()
|
||||
/** The customer comes from the token; the body's `customerId` is ignored. */
|
||||
@CustomerAuth()
|
||||
@Post()
|
||||
create(@Body() dto: CreateOrderDto) {
|
||||
return this.orders.createOrder(dto);
|
||||
create(@Body() dto: CreateOrderDto, @CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.orders.createOrder(dto, me.id);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
/**
|
||||
* Unscoped read for operators.
|
||||
*
|
||||
* Declared BEFORE the `:id` route below: Nest matches in declaration order,
|
||||
* so a literal segment that could also be read as a parameter has to come
|
||||
* first or it is never reached.
|
||||
*/
|
||||
@Get('admin/:id')
|
||||
@RequirePermission('order.list')
|
||||
getAsAdmin(@Param('id') id: string) {
|
||||
return this.orders.getById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer reading one of their own orders. Scoped by token; someone
|
||||
* else's order is a 404 rather than a 403, so ids stay unenumerable.
|
||||
*
|
||||
* Mounted under /orders/:id (not /customer/me/orders/:id) so the existing
|
||||
* client paths keep working; it is the scoping that matters, not the shape.
|
||||
*/
|
||||
@CustomerAuth()
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string, @CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.orders.getOwnedById(id, me.id);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePermission('order.list.sts.submit')
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
|
||||
|
||||
Reference in New Issue
Block a user