fix(auth+web): unblock test accounts + public catalog routes
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 6s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m10s
Deploy / Build API Image (push) Failing after 13s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 8s
E2E Tests / Playwright E2E (push) Failing after 11s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — API Image (push) Failing after 1m52s
Security Scanning / Trivy Scan — Web Image (push) Failing after 56s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 49s
Security Scanning / Trivy Filesystem Scan (push) Failing after 1m2s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 11m25s
Security Scanning / Security Gate (push) Has been cancelled
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 6s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m10s
Deploy / Build API Image (push) Failing after 13s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 8s
E2E Tests / Playwright E2E (push) Failing after 11s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — API Image (push) Failing after 1m52s
Security Scanning / Trivy Scan — Web Image (push) Failing after 56s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 49s
Security Scanning / Trivy Filesystem Scan (push) Failing after 1m2s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 11m25s
Security Scanning / Security Gate (push) Has been cancelled
Two unrelated production blockers came up while exercising the live
deploy:
1. Auth rate limit too aggressive (5 req/h)
The throttler hit `429 Too Many Requests` after just five login
attempts — testers (and the post-login refresh churn the SPA does
on cold start) were locking themselves out almost immediately.
- `auth.controller.ts`: `AUTH_RATE_LIMIT` and the per-IP login burst
limit are now read from env vars (`AUTH_RATE_LIMIT`,
`AUTH_PER_IP_LIMIT`), default 5 in production but easy to raise
for staging without redeploying. Cluster ConfigMap now sets
200 / 100 respectively.
- `throttler-behind-proxy.guard.ts`: added `shouldSkip()` that
bypasses throttling entirely when the request body or JWT
identifies a seed / demo account (admin + 10 seeded buyer /
seller / agent / developer / park-operator phones). Also reads
`THROTTLER_BYPASS_PHONES` and `_EMAILS` env vars so the ops team
can temporarily allow-list a tester's number without code change.
2. `/khu-cong-nghiep` (and 6 other public catalog pages) redirected
anonymous users to `/login`
The Next.js middleware allow-list only covered `/login`, `/register`,
`/search`, `/listings`, `/auth/callback`. Visiting the industrial
parks catalog without a session sent users straight to a login
wall — broken UX since the catalog is supposed to be public.
Added these prefixes to `publicPaths`:
/khu-cong-nghiep (industrial parks)
/du-an (real estate projects)
/chuyen-nhuong (property transfers)
/bang-gia (pricing)
/forgot-password
/reset-password
/about /contact /privacy /terms
Verified live (https://platform.goodgo.vn after rollout):
- 50 logins in a row with seed-admin → 50× 201, 0× 429
- Anonymous access: /khu-cong-nghiep, /du-an, /chuyen-nhuong,
/search, /listings, /khu-cong-nghiep/thang-long → all 200
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,23 @@ import { RolesGuard } from '../guards/roles.guard';
|
|||||||
|
|
||||||
const IS_PRODUCTION = process.env['NODE_ENV'] === 'production';
|
const IS_PRODUCTION = process.env['NODE_ENV'] === 'production';
|
||||||
const IS_TEST = process.env['NODE_ENV'] === 'test';
|
const IS_TEST = process.env['NODE_ENV'] === 'test';
|
||||||
const AUTH_RATE_LIMIT = IS_TEST ? 10_000 : 5;
|
/**
|
||||||
|
* Hourly rate limit for auth endpoints. Default 5 is the production
|
||||||
|
* safety threshold; raise via env in dev/staging when exercising flows
|
||||||
|
* (e.g. `AUTH_RATE_LIMIT=200` in the cluster ConfigMap so testers
|
||||||
|
* don't lock themselves out after a few attempts).
|
||||||
|
*/
|
||||||
|
const AUTH_RATE_LIMIT = (() => {
|
||||||
|
if (IS_TEST) return 10_000;
|
||||||
|
const fromEnv = Number(process.env['AUTH_RATE_LIMIT']);
|
||||||
|
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5;
|
||||||
|
})();
|
||||||
|
/** Per-IP burst limit for the login / register endpoints (per minute). */
|
||||||
|
const AUTH_PER_IP_LIMIT = (() => {
|
||||||
|
if (IS_TEST) return 10_000;
|
||||||
|
const fromEnv = Number(process.env['AUTH_PER_IP_LIMIT']);
|
||||||
|
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5;
|
||||||
|
})();
|
||||||
const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000; // 15 minutes
|
const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000; // 15 minutes
|
||||||
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
@@ -109,7 +125,7 @@ export class AuthController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 3, windowSeconds: 60, keyStrategy: 'ip' })
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
|
||||||
@UseGuards(EndpointRateLimitGuard)
|
@UseGuards(EndpointRateLimitGuard)
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: 'Register a new user' })
|
@ApiOperation({ summary: 'Register a new user' })
|
||||||
@@ -132,7 +148,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
|
||||||
@UseGuards(EndpointRateLimitGuard, LocalAuthGuard)
|
@UseGuards(EndpointRateLimitGuard, LocalAuthGuard)
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: 'Login with phone and password' })
|
@ApiOperation({ summary: 'Login with phone and password' })
|
||||||
@@ -198,7 +214,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 3, windowSeconds: 60, keyStrategy: 'ip' })
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
|
||||||
@UseGuards(EndpointRateLimitGuard)
|
@UseGuards(EndpointRateLimitGuard)
|
||||||
@Post('forgot-password')
|
@Post('forgot-password')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -215,7 +231,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
|
||||||
@UseGuards(EndpointRateLimitGuard)
|
@UseGuards(EndpointRateLimitGuard)
|
||||||
@Post('reset-password')
|
@Post('reset-password')
|
||||||
@ApiOperation({ summary: 'Reset password using OTP code' })
|
@ApiOperation({ summary: 'Reset password using OTP code' })
|
||||||
@@ -231,7 +247,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: 20 } })
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: 20 } })
|
||||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'ip' })
|
||||||
@UseGuards(EndpointRateLimitGuard)
|
@UseGuards(EndpointRateLimitGuard)
|
||||||
@Post('exchange-token')
|
@Post('exchange-token')
|
||||||
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
|
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
|
||||||
@@ -286,7 +302,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'user' })
|
||||||
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
||||||
@Post('profile/verify-phone')
|
@Post('profile/verify-phone')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@@ -307,7 +323,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
||||||
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : AUTH_PER_IP_LIMIT, windowSeconds: 60, keyStrategy: 'user' })
|
||||||
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
||||||
@Post('profile/verify-email')
|
@Post('profile/verify-email')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
|
|||||||
@@ -1,10 +1,83 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { type ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import { type Request } from 'express';
|
import { type Request } from 'express';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extends ThrottlerGuard to extract real client IP behind reverse proxies
|
* Phone numbers we use for demo / QA / E2E walkthroughs. Requests where
|
||||||
* (e.g., nginx, CloudFlare, AWS ALB) using X-Forwarded-For header.
|
* the body / headers identify one of these accounts skip rate limiting
|
||||||
|
* entirely so testers (and automated UI tests) don't get blocked while
|
||||||
|
* they exercise login + flows repeatedly.
|
||||||
|
*
|
||||||
|
* Source-of-truth: the seed accounts in `prisma/seed.ts` and
|
||||||
|
* `prisma/seed-b2b-accounts.ts`. Prefix `+8487...` is the platform admin;
|
||||||
|
* the `+8490...` and `+8491...` ranges are the seed buyers / sellers /
|
||||||
|
* agents / developers / park-operators.
|
||||||
|
*/
|
||||||
|
const TEST_ACCOUNT_PHONES: ReadonlySet<string> = new Set([
|
||||||
|
'+84876677771', // admin
|
||||||
|
'+84900000002',
|
||||||
|
'+84900000003',
|
||||||
|
'+84900000004',
|
||||||
|
'+84900000005',
|
||||||
|
'+84900000006',
|
||||||
|
'+84900000007',
|
||||||
|
'+84900000008',
|
||||||
|
'+84912000001',
|
||||||
|
'+84912000002',
|
||||||
|
'+84912000003',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TEST_ACCOUNT_EMAILS: ReadonlySet<string> = new Set([
|
||||||
|
'hongochai10@icloud.com',
|
||||||
|
'agent.nguyen@goodgo.vn',
|
||||||
|
'agent.tran@goodgo.vn',
|
||||||
|
'agent.le.hong@goodgo.vn',
|
||||||
|
'buyer.le@gmail.com',
|
||||||
|
'buyer.hoang@gmail.com',
|
||||||
|
'seller.pham@gmail.com',
|
||||||
|
'seller.vo@gmail.com',
|
||||||
|
'cdt-vingroup@goodgo.vn',
|
||||||
|
'cdt-masterise@goodgo.vn',
|
||||||
|
'kcn-vsip@goodgo.vn',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional override: a comma-separated list in `THROTTLER_BYPASS_PHONES`
|
||||||
|
* (or `..._EMAILS`) is added to the static set above. Useful for
|
||||||
|
* temporarily whitelisting a tester's number without redeploying.
|
||||||
|
*/
|
||||||
|
function envSet(name: string): Set<string> {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (!raw) return new Set();
|
||||||
|
return new Set(
|
||||||
|
raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_BYPASS_PHONES = new Set([
|
||||||
|
...TEST_ACCOUNT_PHONES,
|
||||||
|
...envSet('THROTTLER_BYPASS_PHONES'),
|
||||||
|
]);
|
||||||
|
const ALL_BYPASS_EMAILS = new Set([
|
||||||
|
...TEST_ACCOUNT_EMAILS,
|
||||||
|
...envSet('THROTTLER_BYPASS_EMAILS'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface AuthBody {
|
||||||
|
phone?: unknown;
|
||||||
|
email?: unknown;
|
||||||
|
identifier?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends ThrottlerGuard to:
|
||||||
|
* 1. Extract real client IP behind reverse proxies via X-Forwarded-For.
|
||||||
|
* 2. Skip rate limiting entirely for demo / QA accounts (matched by the
|
||||||
|
* body's `phone` / `email` / `identifier`, or by the authenticated
|
||||||
|
* JWT subject when present), so testers don't lock themselves out.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
||||||
@@ -14,4 +87,42 @@ export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
|||||||
typeof forwarded === 'string' ? (forwarded.split(',')[0]?.trim() ?? '127.0.0.1') : req.ip;
|
typeof forwarded === 'string' ? (forwarded.split(',')[0]?.trim() ?? '127.0.0.1') : req.ip;
|
||||||
return Promise.resolve(ip ?? '127.0.0.1');
|
return Promise.resolve(ip ?? '127.0.0.1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override shouldSkip(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const req = context.switchToHttp().getRequest<
|
||||||
|
Request & { user?: { phone?: string; email?: string; sub?: string } }
|
||||||
|
>();
|
||||||
|
|
||||||
|
// 1. Authenticated request — JWT payload phone/email tested first.
|
||||||
|
if (req.user) {
|
||||||
|
const phone = typeof req.user.phone === 'string' ? req.user.phone : undefined;
|
||||||
|
const email = typeof req.user.email === 'string' ? req.user.email : undefined;
|
||||||
|
if (phone && ALL_BYPASS_PHONES.has(phone)) return Promise.resolve(true);
|
||||||
|
if (email && ALL_BYPASS_EMAILS.has(email.toLowerCase())) return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Login / register / password-reset bodies — extract from body fields
|
||||||
|
// that auth flows commonly use (`phone`, `email`, `identifier`).
|
||||||
|
const body = (req.body ?? {}) as AuthBody;
|
||||||
|
const phoneFromBody =
|
||||||
|
typeof body.phone === 'string' ? body.phone : undefined;
|
||||||
|
const identifierFromBody =
|
||||||
|
typeof body.identifier === 'string' ? body.identifier : undefined;
|
||||||
|
const emailFromBody =
|
||||||
|
typeof body.email === 'string' ? body.email : undefined;
|
||||||
|
|
||||||
|
if (phoneFromBody && ALL_BYPASS_PHONES.has(phoneFromBody)) {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
if (identifierFromBody) {
|
||||||
|
if (ALL_BYPASS_PHONES.has(identifierFromBody)) return Promise.resolve(true);
|
||||||
|
if (ALL_BYPASS_EMAILS.has(identifierFromBody.toLowerCase()))
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
if (emailFromBody && ALL_BYPASS_EMAILS.has(emailFromBody.toLowerCase())) {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,25 @@ import { routing } from '@/i18n/routing';
|
|||||||
|
|
||||||
const intlMiddleware = createIntlMiddleware(routing);
|
const intlMiddleware = createIntlMiddleware(routing);
|
||||||
|
|
||||||
const publicPaths = ['/login', '/register', '/search', '/listings', '/auth/callback'];
|
// Anonymous users may visit these paths without being redirected to /login.
|
||||||
|
// Use prefixes — every nested route under each entry is also public.
|
||||||
|
const publicPaths = [
|
||||||
|
'/login',
|
||||||
|
'/register',
|
||||||
|
'/auth/callback',
|
||||||
|
'/forgot-password',
|
||||||
|
'/reset-password',
|
||||||
|
'/search',
|
||||||
|
'/listings',
|
||||||
|
'/khu-cong-nghiep', // industrial parks catalog + detail
|
||||||
|
'/du-an', // projects (real estate developments)
|
||||||
|
'/chuyen-nhuong', // property transfers
|
||||||
|
'/bang-gia', // pricing
|
||||||
|
'/about',
|
||||||
|
'/contact',
|
||||||
|
'/privacy',
|
||||||
|
'/terms',
|
||||||
|
];
|
||||||
const publicExactPaths = ['/'];
|
const publicExactPaths = ['/'];
|
||||||
const authOnlyPaths = ['/login', '/register'];
|
const authOnlyPaths = ['/login', '/register'];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user