Files
tehran_taj/docs/parity/CHANGELOG.md
T
s.esmaeiliandClaude Opus 5 4efe543d62 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>
2026-09-22 01:20:00 +03:30

179 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CHANGELOG — parity changes
Every change made to `gold-banking-monorepo` during the parity project, with the
reason and the reference path that justifies it.
Format:
```
## <date> — <phase> — <group title>
- **What:** <the change>
- **Why:** <the parity gap it closes>
- **Reference:** <henza_* path:line>
- **Target:** <gold-banking-monorepo path:line>
- **Matrix row:** <id in 01-ADMIN-MATRIX.md / 02-APPS-MATRIX.md>
- **Commit:** <sha>
```
---
## 2026-09-21 — Phase 0 — Setup
- **What:** Created branch `parity/phase-0` and `docs/parity/` scaffolding
(`PROGRESS.md`, `ISSUES.md`, `CHANGELOG.md`).
- **Why:** Persistent state required by `CLAUDE.md` §6.
- **Reference:** n/a
- **Target:** `docs/parity/`
- **Matrix row:** n/a
_No source code changed in Phase 0 (read-only phase)._
---
## 2026-09-22 — Security hotfix, stage 1 — close the unauthenticated write paths
Branch `parity/security-hotfix`. Authorised by the user's rulings D7, T1–T3.
### 1. Ledger routes are admin-only (SEC-09)
- **What:** removed the controller-level `@Public()` from `AccountingController`
and gave each route a rid: `wallet.list` (balance), `wallet.charge`
(reconcile), `document.create` (both repayments), `document.list.confirm`
(confirm entry).
- **Why:** four of the five routes WRITE to the ledger and were reachable with no
token at all. Faithful to the reference, whose entire admin route file is
unauthenticated, but contrary to confirmed decision D1.
- **Reference:** `henza_server/routes/admin.php:12,272` (the `auth:api` wrapper is
commented out at both ends)
- **Target:** `apps/api/src/modules/accounting/accounting.controller.ts`
- **Breakage check:** `api.getBalance()` is the only app-side consumer and it has
**zero call sites** in either user app (dead code, already logged as DEAD-01/02).
### 2. Order status change is admin-only (SEC-10)
- **What:** moved `@Public()` from the class to the two customer-facing handlers,
leaving `PATCH orders/:id/status` behind `order.list.sts.submit`.
- **Why:** it let an anonymous caller move any order's status **and** attribute
the change to any admin through the DTO's `actorAdminUserId`.
- **Reference:** an admin operation; the admin panel posts to `admin/orders/status`
(`apps/admin-panel/src/api/endpoints.ts:42,48`), so this route had no caller.
- **Target:** `apps/api/src/modules/orders/orders.controller.ts`
- **Still public:** `POST /orders` and `GET /orders/:id`, because the target has
no customer session yet — closed in stage 2.
### 3. `GET /customers` no longer leaks the customer book (SEC-08)
- **What:** added `LOGIN_LOOKUP_SELECT` (`id`, `mobile`, `isActive`,
`isArchived`) and pointed `list()` at it. Removed `fullName`, `nationalCode`,
`goldDebtGrams`, `fiatDebtRial`, `goldCreditLimitGrams`, `verificationStep`,
`cityId`, `referredByAgentId`, `nationalIdImageUrl`, `businessLicenseUrl`,
`shopImageUrl`, `createdAt`, `updatedAt` from that response.
- **Why:** the route is unauthenticated because both apps match the phone number
in the browser, so it shipped every customer's national ID number, debts,
credit limit and identity-document URLs to any visitor.
- **Reference:** login was `POST userAuth` → SMS → `POST userVerfiy`, returning
exactly one customer after the code matched
(`henza_server/app/Http/Controllers/Api/ApiMobileController.php:39-78`).
- **Target:** `apps/api/src/modules/customers/customers.service.ts`,
plus `CustomerSummary` narrowed in **both** apps' `src/api/client.ts`
(parity rule: the same logic change in both).
- **Route survives** until stage 2 replaces the client-side phone match with OTP.
### 4. Test script
- **What:** added `apps/api/scripts/auth-smoke.sh` — asserts 401 without a token,
403 with a low-privilege token, and pass-through for a super admin, on every
closed route; plus that the public routes still answer 200 and that
`GET /customers` exposes only the four login fields.
- **Result:** **23 passed, 0 failed** against a live server and the dev seed.
### Not changed
`/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.