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>
193 lines
7.7 KiB
Bash
193 lines
7.7 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# Auth smoke test for the routes closed in the security hotfix.
|
|
#
|
|
# For every route that was made non-public, asserts:
|
|
# - no token -> 401
|
|
# - low-privilege token -> 403
|
|
# - super-admin token -> anything but 401/403 (the guard let it through)
|
|
#
|
|
# And asserts the genuinely-public routes still answer 200, plus that
|
|
# GET /customers no longer leaks anything beyond the four login-lookup fields.
|
|
#
|
|
# Usage: API=http://localhost:3000/api bash scripts/auth-smoke.sh
|
|
# Needs the API running and the dev seed applied (npx prisma db seed).
|
|
|
|
set -uo pipefail
|
|
API="${API:-http://localhost:3000/api}"
|
|
|
|
SUPER_PHONE=09128081392; SUPER_PASS=09128081392 # ut 85, all rids
|
|
LOW_PHONE=09123334444; LOW_PASS=admin1234 # ut 88, 5 unrelated rids
|
|
|
|
pass=0; fail=0
|
|
ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; pass=$((pass+1)); }
|
|
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); }
|
|
|
|
login() {
|
|
curl -s -X POST "$API/admin/auth/login" -H 'Content-Type: application/json' \
|
|
-d "{\"phone\":\"$1\",\"password\":\"$2\"}" |
|
|
sed -n 's/.*"api_token":"\([^"]*\)".*/\1/p'
|
|
}
|
|
|
|
code() { # method path [token]
|
|
local m=$1 p=$2 t=${3:-}
|
|
if [ -n "$t" ]; then
|
|
curl -s -o /dev/null -w '%{http_code}' -X "$m" "$API/$p" \
|
|
-H "Authorization: Bearer $t" -H 'Content-Type: application/json' -d '{}'
|
|
else
|
|
curl -s -o /dev/null -w '%{http_code}' -X "$m" "$API/$p" \
|
|
-H 'Content-Type: application/json' -d '{}'
|
|
fi
|
|
}
|
|
|
|
SUPER=$(login "$SUPER_PHONE" "$SUPER_PASS")
|
|
LOW=$(login "$LOW_PHONE" "$LOW_PASS")
|
|
[ -z "$SUPER" ] && { echo "super-admin login failed — is the seed applied?"; exit 1; }
|
|
[ -z "$LOW" ] && { echo "low-priv login failed — is the seed applied?"; exit 1; }
|
|
|
|
# route | required rid
|
|
CLOSED=(
|
|
"GET|accounting/customers/x/balance|wallet.list"
|
|
"POST|accounting/customers/x/reconcile|wallet.charge"
|
|
"POST|accounting/repayments/gold|document.create"
|
|
"POST|accounting/repayments/rial|document.create"
|
|
"POST|accounting/entries/x/confirm|document.list.confirm"
|
|
"PATCH|orders/x/status|order.list.sts.submit"
|
|
)
|
|
|
|
echo "Closed routes — no token must be 401:"
|
|
for row in "${CLOSED[@]}"; do
|
|
IFS='|' read -r m p _ <<<"$row"
|
|
c=$(code "$m" "$p")
|
|
[ "$c" = 401 ] && ok "$m $p -> 401" || bad "$m $p -> $c (want 401)"
|
|
done
|
|
|
|
echo "Closed routes — low-privilege token must be 403:"
|
|
for row in "${CLOSED[@]}"; do
|
|
IFS='|' read -r m p rid <<<"$row"
|
|
c=$(code "$m" "$p" "$LOW")
|
|
[ "$c" = 403 ] && ok "$m $p [$rid] -> 403" || bad "$m $p [$rid] -> $c (want 403)"
|
|
done
|
|
|
|
echo "Closed routes — super admin must get past the guard:"
|
|
for row in "${CLOSED[@]}"; do
|
|
IFS='|' read -r m p _ <<<"$row"
|
|
c=$(code "$m" "$p" "$SUPER")
|
|
case "$c" in
|
|
401|403) bad "$m $p -> $c (guard rejected a super admin)" ;;
|
|
*) ok "$m $p -> $c (through the guard)" ;;
|
|
esac
|
|
done
|
|
|
|
echo "Public routes must stay reachable:"
|
|
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
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 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 ]
|