diff --git a/apps/api/.env.example b/apps/api/.env.example index 57d6514..4d9d86d 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/jest.config.cjs b/apps/api/jest.config.cjs new file mode 100644 index 0000000..3fc84e8 --- /dev/null +++ b/apps/api/jest.config.cjs @@ -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: '/../tsconfig.json' }], + }, + collectCoverageFrom: ['**/*.ts', '!**/*.spec.ts', '!**/*.module.ts'], +}; diff --git a/apps/api/package.json b/apps/api/package.json index 54e74f7..bca7681 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" } diff --git a/apps/api/prisma/migrations/20260922000000_customer_otp/migration.sql b/apps/api/prisma/migrations/20260922000000_customer_otp/migration.sql new file mode 100644 index 0000000..3db98fe --- /dev/null +++ b/apps/api/prisma/migrations/20260922000000_customer_otp/migration.sql @@ -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 $$; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 53832f0..f0ec677 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/scripts/auth-smoke.sh b/apps/api/scripts/auth-smoke.sh index 2208bff..cbddb30 100644 --- a/apps/api/scripts/auth-smoke.sh +++ b/apps/api/scripts/auth-smoke.sh @@ -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 ] diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 87cddaa..16433c7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/modules/customer-auth/customer-auth.controller.ts b/apps/api/src/modules/customer-auth/customer-auth.controller.ts new file mode 100644 index 0000000..3b6a515 --- /dev/null +++ b/apps/api/src/modules/customer-auth/customer-auth.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/customer-auth/customer-auth.module.ts b/apps/api/src/modules/customer-auth/customer-auth.module.ts new file mode 100644 index 0000000..5d743bb --- /dev/null +++ b/apps/api/src/modules/customer-auth/customer-auth.module.ts @@ -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 {} diff --git a/apps/api/src/modules/customer-auth/customer-auth.service.ts b/apps/api/src/modules/customer-auth/customer-auth.service.ts new file mode 100644 index 0000000..d007946 --- /dev/null +++ b/apps/api/src/modules/customer-auth/customer-auth.service.ts @@ -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' + ); +} diff --git a/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts b/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts new file mode 100644 index 0000000..9931f71 --- /dev/null +++ b/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts @@ -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 { + // 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 }; + } +} diff --git a/apps/api/src/modules/customer-auth/customer.guards.ts b/apps/api/src/modules/customer-auth/customer.guards.ts new file mode 100644 index 0000000..36e2270 --- /dev/null +++ b/apps/api/src/modules/customer-auth/customer.guards.ts @@ -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, +); diff --git a/apps/api/src/modules/customer-auth/digits.spec.ts b/apps/api/src/modules/customer-auth/digits.spec.ts new file mode 100644 index 0000000..cfc8652 --- /dev/null +++ b/apps/api/src/modules/customer-auth/digits.spec.ts @@ -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(''); + }); +}); diff --git a/apps/api/src/modules/customer-auth/digits.ts b/apps/api/src/modules/customer-auth/digits.ts new file mode 100644 index 0000000..429f707 --- /dev/null +++ b/apps/api/src/modules/customer-auth/digits.ts @@ -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(); +} diff --git a/apps/api/src/modules/customer-auth/dto/request-otp.dto.ts b/apps/api/src/modules/customer-auth/dto/request-otp.dto.ts new file mode 100644 index 0000000..8ca5876 --- /dev/null +++ b/apps/api/src/modules/customer-auth/dto/request-otp.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/customer-auth/dto/verify-otp.dto.ts b/apps/api/src/modules/customer-auth/dto/verify-otp.dto.ts new file mode 100644 index 0000000..f93ddbe --- /dev/null +++ b/apps/api/src/modules/customer-auth/dto/verify-otp.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/customers/customers.controller.ts b/apps/api/src/modules/customers/customers.controller.ts deleted file mode 100644 index fafc4b6..0000000 --- a/apps/api/src/modules/customers/customers.controller.ts +++ /dev/null @@ -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); - } -} diff --git a/apps/api/src/modules/customers/customers.module.ts b/apps/api/src/modules/customers/customers.module.ts index febd1f8..4540111 100644 --- a/apps/api/src/modules/customers/customers.module.ts +++ b/apps/api/src/modules/customers/customers.module.ts @@ -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], }) diff --git a/apps/api/src/modules/customers/customers.service.ts b/apps/api/src/modules/customers/customers.service.ts index e29099a..f2d6586 100644 --- a/apps/api/src/modules/customers/customers.service.ts +++ b/apps/api/src/modules/customers/customers.service.ts @@ -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 }, diff --git a/apps/user-app-v2/src/api/client.ts b/apps/user-app-v2/src/api/client.ts index 27d0fec..c4073b0 100644 --- a/apps/user-app-v2/src/api/client.ts +++ b/apps/user-app-v2/src/api/client.ts @@ -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(path: string, options?: RequestInit): Promise { + 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('/catalog/products'), - listCustomers: () => request('/customers'), - getCustomer: (id: string) => request(`/customers/${id}`), - getBalance: (customerId: string) => request(`/accounting/customers/${customerId}/balance`), - getLedgerEntries: (customerId: string) => request(`/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('/customer/me'), + getMyOrders: () => request('/customer/me/orders'), + getMyLedgerEntries: () => request('/customer/me/ledger-entries'), createOrder: (dto: CreateOrderDto) => request('/orders', { method: 'POST', body: JSON.stringify(dto) }), - getCustomerOrders: (customerId: string) => request(`/customers/${customerId}/orders`), getLatestGoldPrice: (karatPerMille = 750) => request(`/pricing/latest?karatPerMille=${karatPerMille}`), }; diff --git a/apps/user-app-v2/src/main.ts b/apps/user-app-v2/src/main.ts index c8421d8..e1c1eae 100644 --- a/apps/user-app-v2/src/main.ts +++ b/apps/user-app-v2/src/main.ts @@ -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. diff --git a/apps/user-app-v2/src/router/index.ts b/apps/user-app-v2/src/router/index.ts index 17df2dc..9c613eb 100644 --- a/apps/user-app-v2/src/router/index.ts +++ b/apps/user-app-v2/src/router/index.ts @@ -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 } }; } diff --git a/apps/user-app-v2/src/stores/session.ts b/apps/user-app-v2/src/stores/session.ts index 22fe070..5307a5f 100644 --- a/apps/user-app-v2/src/stores/session.ts +++ b/apps/user-app-v2/src/stores/session.ts @@ -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); }, }, }); diff --git a/apps/user-app-v2/src/views/user/DashboardView.vue b/apps/user-app-v2/src/views/user/DashboardView.vue index 4141d23..93d8296 100644 --- a/apps/user-app-v2/src/views/user/DashboardView.vue +++ b/apps/user-app-v2/src/views/user/DashboardView.vue @@ -50,7 +50,7 @@
میز کار