fix: D4 — حدّ الطلبات كان مُعطَّلاً كلياً رغم أنه يبدو مفعَّلاً
ThrottlerModule.forRoot(...) كان مسجَّلاً في app.module.ts منذ البداية — بلا أي حارس يطبّقه (لا APP_GUARD ولا @UseGuards). التسجيل وحده لا يفعل شيئاً في NestJS: كل نقطة في الـAPI، بما فيها verify-otp و payouts/*، كانت بلا أي حدّ طلبات إطلاقاً منذ اليوم الأول. - APP_GUARD → ThrottlerGuard يُفعّل الحدّ العام (120/دقيقة) على كل نقطة - عدّاد محاولات لكل (مستأجر، رقم) في AuthService.verifyOtp — الحماية الحقيقية ضد تخمين الرمز لا الحدّ العام: رمز 4 خانات = 10000 احتمال، ومهاجم يدوّر IP يتجاوز أي حدّ بالـIP وحده. 5 محاولات ثم إبطال الرمز، نفس نمط payouts.service (I4) - حدود أضيق للأهداف عالية القيمة: send-otp (كل إرسال يكلّف رسالة واتساب مدفوعة فعلياً)، verify-otp، payouts/request، payouts/confirm - HealthController مُستثنى (@SkipThrottle) — مراقبة تشغيل بلا بيانات حساسة قرار وعي بالمخاطرة: بنيت أولاً حارساً مخصَّصاً يتتبّع بالمستخدم المصادَق لا بالـIP وحده (مهم لموبايل — NAT عند مشغّلي الجوّال يجمع آلاف المستخدمين خلف IP واحد)، لكن تطلّب حقناً يدوياً دقيقاً (InjectThrottlerOptions/ InjectThrottlerStorage) — خطأ فيه يمنع إقلاع التطبيق كاملاً، ولا بيئة هنا لاختبار NestFactory.create() قبل الدفع (jest ينشئ الخدمات يدوياً فلا يكشف أخطاء DI لحارس عالمي). حذفته ورجّحت الأمان بالتتبّع الافتراضي — موثّق في docs/17 D4 لتُنفَّذ حين يمكن اختبارها فعلياً على السيرفر. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
253fbb4b2a
commit
c258a8c4b8
@@ -1,7 +1,8 @@
|
|||||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
import { TenantMiddleware } from './common/tenant/tenant.middleware';
|
import { TenantMiddleware } from './common/tenant/tenant.middleware';
|
||||||
import { RedisModule } from './common/redis/redis.module';
|
import { RedisModule } from './common/redis/redis.module';
|
||||||
@@ -99,6 +100,12 @@ import { GeminiModule } from './integrations/gemini/gemini.module';
|
|||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
SeedModule,
|
SeedModule,
|
||||||
],
|
],
|
||||||
|
providers: [
|
||||||
|
// ThrottlerModule.forRoot() وحده لا يفعل شيئاً — كان مسجَّلاً منذ البداية
|
||||||
|
// بلا أي حارس يطبّقه، فتحديد 120/60s لم يكن ساري المفعول إطلاقاً على أي
|
||||||
|
// نقطة. APP_GUARD يُفعّله عالمياً على كل نقطة تلقائياً (docs/17 — D4).
|
||||||
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Post, Body, Headers, UnauthorizedException } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@@ -7,6 +8,12 @@ import { AuthService } from './auth.service';
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* حدّ أضيق من العام (docs/17 — D4): كل إرسال ينادي Nabeh (رسالة واتساب
|
||||||
|
* مدفوعة فعلياً) — الحدّ العام (120/دقيقة على كل نقاط API) كان سيسمح
|
||||||
|
* بإغراق مالي رخيص لرقم واحد أو أرقام كثيرة.
|
||||||
|
*/
|
||||||
|
@Throttle({ default: { limit: 3, ttl: 300_000 } })
|
||||||
@Post('send-otp')
|
@Post('send-otp')
|
||||||
async sendOtp(
|
async sendOtp(
|
||||||
@Headers('x-tenant-id') tenantId: string,
|
@Headers('x-tenant-id') tenantId: string,
|
||||||
@@ -16,6 +23,12 @@ export class AuthController {
|
|||||||
return this.authService.sendOtp(tenantId, phone);
|
return this.authService.sendOtp(tenantId, phone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* الحارس الأقوى فعلياً هو عدّاد المحاولات لكل (مستأجر، رقم) داخل
|
||||||
|
* `AuthService` — يصمد أمام تدوير الـIP. هذا الحدّ طبقة إضافية بسيطة على
|
||||||
|
* مستوى الشبكة، لا الحماية الأساسية.
|
||||||
|
*/
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 300_000 } })
|
||||||
@Post('verify-otp')
|
@Post('verify-otp')
|
||||||
async verifyOtp(
|
async verifyOtp(
|
||||||
@Headers('x-tenant-id') tenantId: string,
|
@Headers('x-tenant-id') tenantId: string,
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ export class AuthService {
|
|||||||
return `otp:${tenantId}:${phone}`;
|
return `otp:${tenantId}:${phone}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private otpAttemptsKey(tenantId: string, phone: string): string {
|
||||||
|
return `otp:attempts:${tenantId}:${phone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// حدّ محاولات لكل (مستأجر، رقم) — لا لكل IP (docs/17 — D4). حارس الطلبات
|
||||||
|
// العام (ThrottlerGuard) يُبطئ مهاجماً واحداً من عنوان واحد؛ هذا يمنعه حتى
|
||||||
|
// لو دوّر عناوين IP، لأن رمزاً من 4 خانات = 10000 احتمال يُخمَّن في دقائق
|
||||||
|
// بلا هذا الحدّ. نفس النمط المستعمل في payouts.service (I4).
|
||||||
|
private readonly OTP_MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
private genCode(): string {
|
private genCode(): string {
|
||||||
if (this.devMode) return '1234';
|
if (this.devMode) return '1234';
|
||||||
const len = this.config.get<number>('auth.otpLength') ?? 4;
|
const len = this.config.get<number>('auth.otpLength') ?? 4;
|
||||||
@@ -80,11 +90,23 @@ export class AuthService {
|
|||||||
// في وضع التطوير: الرمز الثابت 1234 يمرّ دائماً (تسهيل الاختبار).
|
// في وضع التطوير: الرمز الثابت 1234 يمرّ دائماً (تسهيل الاختبار).
|
||||||
const devBypass = this.devMode && code === '1234';
|
const devBypass = this.devMode && code === '1234';
|
||||||
if (!devBypass) {
|
if (!devBypass) {
|
||||||
|
const attemptsKey = this.otpAttemptsKey(tenant.id, canonical);
|
||||||
|
const attempts = await this.redis.incr(attemptsKey);
|
||||||
|
if (attempts === 1) {
|
||||||
|
// نفس عمر الرمز — لا داعي لعدّاد يبقى بعد انتهاء صلاحية الرمز نفسه.
|
||||||
|
await this.redis.expire(attemptsKey, this.config.get<number>('auth.otpTtl') ?? 300);
|
||||||
|
}
|
||||||
|
if (attempts > this.OTP_MAX_ATTEMPTS) {
|
||||||
|
await this.redis.del(this.otpKey(tenant.id, canonical)); // إبطال الرمز فوراً
|
||||||
|
throw new UnauthorizedException('Too many attempts — request a new code');
|
||||||
|
}
|
||||||
|
|
||||||
const stored = await this.redis.get(this.otpKey(tenant.id, canonical));
|
const stored = await this.redis.get(this.otpKey(tenant.id, canonical));
|
||||||
if (!stored || stored !== code) {
|
if (!stored || stored !== code) {
|
||||||
throw new UnauthorizedException('Invalid or expired OTP code');
|
throw new UnauthorizedException('Invalid or expired OTP code');
|
||||||
}
|
}
|
||||||
await this.redis.del(this.otpKey(tenant.id, canonical));
|
// نجاح — يُستهلك الرمز والعدّاد معاً؛ لا فائدة من عدّاد بعد رمز صحيح.
|
||||||
|
await this.redis.del(this.otpKey(tenant.id, canonical), attemptsKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = await this.usersService.findByPhone(tenant.id, canonical);
|
let user = await this.usersService.findByPhone(tenant.id, canonical);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { SkipThrottle } from '@nestjs/throttler';
|
||||||
|
|
||||||
|
// مراقبة التشغيل (uptime checks/curl متكرر) لا تخضع للحدّ العام — راجع
|
||||||
|
// docs/17 D4. لا بيانات حساسة هنا فلا خطر من كثرة النداء.
|
||||||
|
@SkipThrottle()
|
||||||
@ApiTags('health')
|
@ApiTags('health')
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { PayoutsService, RequestContext } from './payouts.service';
|
import { PayoutsService, RequestContext } from './payouts.service';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
@@ -26,6 +27,7 @@ export class PayoutsController {
|
|||||||
* الخطوة 1: السائق يطلب السحب → يصله رمز على واتساب.
|
* الخطوة 1: السائق يطلب السحب → يصله رمز على واتساب.
|
||||||
* **لا يُخصم شيء هنا** (docs/17 — I4).
|
* **لا يُخصم شيء هنا** (docs/17 — I4).
|
||||||
*/
|
*/
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 300_000 } })
|
||||||
@UseGuards(JwtAuthGuard, SignatureGuard)
|
@UseGuards(JwtAuthGuard, SignatureGuard)
|
||||||
@Post('request')
|
@Post('request')
|
||||||
request(@CurrentUser() user: AuthUser, @Body() body: any, @Req() req: any) {
|
request(@CurrentUser() user: AuthUser, @Body() body: any, @Req() req: any) {
|
||||||
@@ -49,6 +51,7 @@ export class PayoutsController {
|
|||||||
* فقط ولا يُعتمد كمصادقة** — العميل يستطيع ادّعاءه. المصادقة الحقيقية هي
|
* فقط ولا يُعتمد كمصادقة** — العميل يستطيع ادّعاءه. المصادقة الحقيقية هي
|
||||||
* JWT + رمز واتساب (docs/17 — I5).
|
* JWT + رمز واتساب (docs/17 — I5).
|
||||||
*/
|
*/
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 300_000 } })
|
||||||
@UseGuards(JwtAuthGuard, SignatureGuard)
|
@UseGuards(JwtAuthGuard, SignatureGuard)
|
||||||
@Post(':id/confirm')
|
@Post(':id/confirm')
|
||||||
confirm(
|
confirm(
|
||||||
|
|||||||
@@ -73,6 +73,16 @@
|
|||||||
| D1 | **تطبيع أرقام الهاتف (JO/EG/SY)** | ✅ `common/phone/phone.service.ts`. مفتاح قانوني واحد لكل رقم حقيقي — يقبل صفراً محلياً/دولياً/`+`/`00`/بلا صفر، ويرجع دائماً صيغة دولية بلا `+`. يُطبَّق في `send-otp`/`verify-otp`/`platform/users/role`. هجرة `NormalizePhones` تُصحّح حسابات الاختبار الموجودة على السيرفر (بحذر: تتخطّى أي صفٍّ يتصادم ناتجه مع صفٍّ آخر بدل كسر القيد الفريد). |
|
| D1 | **تطبيع أرقام الهاتف (JO/EG/SY)** | ✅ `common/phone/phone.service.ts`. مفتاح قانوني واحد لكل رقم حقيقي — يقبل صفراً محلياً/دولياً/`+`/`00`/بلا صفر، ويرجع دائماً صيغة دولية بلا `+`. يُطبَّق في `send-otp`/`verify-otp`/`platform/users/role`. هجرة `NormalizePhones` تُصحّح حسابات الاختبار الموجودة على السيرفر (بحذر: تتخطّى أي صفٍّ يتصادم ناتجه مع صفٍّ آخر بدل كسر القيد الفريد). |
|
||||||
| D2 | **بصمة الجهاز** | 🟡 **مبنيّة ومطفأة** (`AUTH_REQUIRE_DEVICE_BINDING=false`) حتى يرسل فلاتر `x-device-id`. **مُنفَّذة داخل `JwtStrategy` نفسها لا كحارس منفصل** — يُفرض على كل نقطة محميّة تلقائياً، فلا نقطة منسيّة. توكن الدخول يحمل `hash(deviceId)` لا القيمة الخام؛ توكن مسروق من جهاز آخر يُرفض بمجرد تفعيل العلم. |
|
| D2 | **بصمة الجهاز** | 🟡 **مبنيّة ومطفأة** (`AUTH_REQUIRE_DEVICE_BINDING=false`) حتى يرسل فلاتر `x-device-id`. **مُنفَّذة داخل `JwtStrategy` نفسها لا كحارس منفصل** — يُفرض على كل نقطة محميّة تلقائياً، فلا نقطة منسيّة. توكن الدخول يحمل `hash(deviceId)` لا القيمة الخام؛ توكن مسروق من جهاز آخر يُرفض بمجرد تفعيل العلم. |
|
||||||
| D3 | **HMAC للعمليات الحساسة** | ✅ **منفَّذ ضمن I6** — `SigningService`/`SignatureGuard`، نفس البند لا تكرار. |
|
| D3 | **HMAC للعمليات الحساسة** | ✅ **منفَّذ ضمن I6** — `SigningService`/`SignatureGuard`، نفس البند لا تكرار. |
|
||||||
|
| D4 | **حدّ الطلبات (rate limiting)** | ✅ **إصلاح ثغرة قائمة** — راجع أدناه. |
|
||||||
|
|
||||||
|
### D4 — حدّ الطلبات: كان مُعطَّلاً كلياً رغم أنه يبدو مفعَّلاً
|
||||||
|
`ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }])` كان مسجَّلاً في `app.module.ts` منذ البداية — لكن **بلا أي حارس يطبّقه**. لا `APP_GUARD`، ولا `@UseGuards(ThrottlerGuard)` في أي متحكّم. أي أن كل نقطة في الـAPI، بما فيها `verify-otp` و`payouts/*`، كانت **بلا أي حدّ طلبات إطلاقاً** — التسجيل وحده لا يفعل شيئاً في NestJS.
|
||||||
|
|
||||||
|
- ✅ `APP_GUARD → ThrottlerGuard` في `app.module.ts` — يُفعِّل الحدّ العام (120/دقيقة) على كل نقطة تلقائياً.
|
||||||
|
- ✅ **عدّاد محاولات لكل (مستأجر، رقم) في `AuthService.verifyOtp`** — الحماية الحقيقية ضد تخمين الرمز، لا الحدّ العام: رمز 4 خانات = 10000 احتمال، ومهاجم يدوّر عناوين IP يتجاوز أي حدّ بالـIP وحده. 5 محاولات ثم إبطال الرمز، بنفس نمط `payouts.service` (I4).
|
||||||
|
- ✅ حدود أضيق لكل نقطة على الأهداف عالية القيمة: `send-otp` (3/5د — كل إرسال يكلّف رسالة واتساب مدفوعة فعلياً عبر نبيه)، `verify-otp` (10/5د)، `payouts/request` (5/5د)، `payouts/confirm` (10/5د).
|
||||||
|
- ✅ `HealthController` مُستثنى (`@SkipThrottle()`) — مراقبة التشغيل بلا بيانات حساسة.
|
||||||
|
- 🟡 **قرار وعي بالمخاطرة**: فكّرت في تتبّع بالمستخدم المصادَق لا بالـIP وحده (مهم لتطبيق موبايل — عناوين NAT عند مشغّلي الجوّال تجمع آلاف المستخدمين خلف IP واحد). التنفيذ يحتاج حارساً مخصَّصاً بحقن يدوي دقيق (`InjectThrottlerOptions`/`InjectThrottlerStorage`)، وخطأ فيه **يمنع إقلاع التطبيق كاملاً** — ولا بيئة هنا لتشغيل `NestFactory.create()` والتحقق قبل الدفع (اختبارات jest تُنشئ الخدمات يدوياً، فلا تكشف أخطاء DI للحرّاس العالميين). رجّحت الأمان: تُرك بالتتبّع الافتراضي (IP)، والفكرة موثّقة هنا لتُنفَّذ حين يمكن اختبارها فعلياً على السيرفر قبل الدفع.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user