diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 86efd1d..71dd0e8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,8 +8,11 @@ use App\Mail\VerifyAccount; use App\Models\User; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Spatie\Health\Checks\Checks\DatabaseCheck; @@ -38,6 +41,8 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + $this->configureRateLimiting(); + if ($this->app->environment('production')) { URL::forceScheme('https'); } @@ -102,4 +107,66 @@ class AppServiceProvider extends ServiceProvider ]); } + + private function configureRateLimiting(): void + { + RateLimiter::for('auth', fn (Request $request): array => [ + Limit::perMinute(20)->by('ip:'.$request->ip()), + Limit::perMinute(5)->by($this->loginRateLimitKey($request)), + ]); + + RateLimiter::for( + 'account-action', + fn (Request $request): Limit => Limit::perMinute(5)->by($this->userRateLimitKey($request)) + ); + + RateLimiter::for( + 'content-write', + fn (Request $request): Limit => Limit::perMinute(30)->by($this->userRateLimitKey($request)) + ); + + RateLimiter::for( + 'engagement', + fn (Request $request): Limit => Limit::perMinute(60)->by($this->userRateLimitKey($request)) + ); + + RateLimiter::for('ai-analysis', fn (Request $request): array => [ + Limit::perMinute(5)->by('minute:'.$this->userRateLimitKey($request)), + Limit::perHour(30)->by('hour:'.$this->userRateLimitKey($request)), + ]); + + RateLimiter::for( + 'external-service', + fn (Request $request): Limit => Limit::perMinute(10)->by($this->userRateLimitKey($request)) + ); + + RateLimiter::for( + 'device-token', + fn (Request $request): Limit => Limit::perMinute(30)->by($this->userRateLimitKey($request)) + ); + + RateLimiter::for( + 'notifications', + fn (Request $request): Limit => Limit::perMinute(3)->by($this->userRateLimitKey($request)) + ); + + RateLimiter::for( + 'reports', + fn (Request $request): Limit => Limit::perMinute(10)->by($this->userRateLimitKey($request)) + ); + } + + private function loginRateLimitKey(Request $request): string + { + $email = strtolower((string) $request->input('email', '')); + + return $email !== '' ? 'email:'.$email : 'ip:'.$request->ip(); + } + + private function userRateLimitKey(Request $request): string + { + $userId = $request->user()?->getAuthIdentifier(); + + return $userId !== null ? 'user:'.$userId : 'ip:'.$request->ip(); + } } diff --git a/routes/api.php b/routes/api.php index 28e9bde..83a3b7e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,10 +13,10 @@ use Illuminate\Support\Facades\Route; // Auth Route::prefix('auth')->group(function (): void { - Route::post('register', [AuthController::class, 'register']); - Route::post('login', [AuthController::class, 'login']); - Route::post('mobile/register', [AuthController::class, 'mobileRegister']); - Route::post('mobile/login', [AuthController::class, 'mobileLogin']); + Route::post('register', [AuthController::class, 'register'])->middleware('throttle:auth'); + Route::post('login', [AuthController::class, 'login'])->middleware('throttle:auth'); + Route::post('mobile/register', [AuthController::class, 'mobileRegister'])->middleware('throttle:auth'); + Route::post('mobile/login', [AuthController::class, 'mobileLogin'])->middleware('throttle:auth'); Route::post('forgot-password', [AuthController::class, 'forgotPassword']) ->middleware('throttle:6,1') ->name('password.email'); @@ -33,8 +33,8 @@ Route::prefix('auth')->group(function (): void { Route::middleware('auth:sanctum')->group(function (): void { Route::post('logout', [AuthController::class, 'logout']); Route::get('me', [AuthController::class, 'me']); - Route::patch('me', [AuthController::class, 'update'])->middleware('verified'); - Route::delete('me', [AuthController::class, 'destroy']); + Route::patch('me', [AuthController::class, 'update'])->middleware(['verified', 'throttle:content-write']); + Route::delete('me', [AuthController::class, 'destroy'])->middleware('throttle:account-action'); }); }); @@ -44,45 +44,47 @@ Route::get('/health', function () { return response()->json(['status' => 'ok']); }); Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { - Route::post('device-tokens', [DeviceTokenController::class, 'store']); - Route::delete('device-tokens', [DeviceTokenController::class, 'destroy']); - Route::get('test-notifs', [DeviceTokenController::class, 'notifs']); + Route::post('device-tokens', [DeviceTokenController::class, 'store'])->middleware('throttle:device-token'); + Route::delete('device-tokens', [DeviceTokenController::class, 'destroy'])->middleware('throttle:device-token'); + Route::get('test-notifs', [DeviceTokenController::class, 'notifs'])->middleware('throttle:notifications'); }); // Workouts Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { Route::get('workouts/calendar', [WorkoutSessionController::class, 'calendar'])->name('workouts.calendar'); Route::get('workouts', [WorkoutSessionController::class, 'index'])->name('workouts.index'); - Route::post('workouts', [WorkoutSessionController::class, 'store'])->name('workouts.store'); + Route::post('workouts', [WorkoutSessionController::class, 'store'])->middleware('throttle:content-write')->name('workouts.store'); Route::get('strava/status', [StravaController::class, 'status'])->name('strava.status'); - Route::get('strava/authorization-url', [StravaController::class, 'authorizationUrl'])->name('strava.authorization-url'); - Route::post('strava/callback', [StravaController::class, 'callback'])->name('strava.callback'); - Route::delete('strava/connection', [StravaController::class, 'disconnect'])->name('strava.disconnect'); - Route::post('strava/sync', [StravaController::class, 'sync'])->name('strava.sync'); + Route::get('strava/authorization-url', [StravaController::class, 'authorizationUrl'])->middleware('throttle:external-service')->name('strava.authorization-url'); + Route::post('strava/callback', [StravaController::class, 'callback'])->middleware('throttle:external-service')->name('strava.callback'); + Route::delete('strava/connection', [StravaController::class, 'disconnect'])->middleware('throttle:external-service')->name('strava.disconnect'); + Route::post('strava/sync', [StravaController::class, 'sync'])->middleware('throttle:external-service')->name('strava.sync'); }); // Meals Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { Route::get('users/{user}', [PublicUserProfileController::class, 'show'])->name('users.show'); Route::post('users/{user}/reports', [ReportController::class, 'storeUser']) - ->middleware('throttle:10,1') + ->middleware('throttle:reports') ->name('users.reports.store'); Route::get('me/meal-posts', [MealPostController::class, 'mine'])->name('meal-posts.mine'); Route::get('meal-posts/stats', [MealPostController::class, 'stats']); Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']); - Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store']); - Route::post('meal-posts/{mealPost}/like', [MealPostController::class, 'like'])->name('meal-posts.like'); - Route::delete('meal-posts/{mealPost}/like', [MealPostController::class, 'unlike'])->name('meal-posts.unlike'); + Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store'])->middleware('throttle:ai-analysis'); + Route::post('meal-posts/{mealPost}/like', [MealPostController::class, 'like'])->middleware('throttle:engagement')->name('meal-posts.like'); + Route::delete('meal-posts/{mealPost}/like', [MealPostController::class, 'unlike'])->middleware('throttle:engagement')->name('meal-posts.unlike'); Route::post('meal-posts/{mealPost}/reports', [ReportController::class, 'storeMealPost']) - ->middleware('throttle:10,1') + ->middleware('throttle:reports') ->name('meal-posts.reports.store'); Route::get('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'index'])->name('meal-posts.reviews.index'); - Route::post('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'store'])->name('meal-posts.reviews.store'); + Route::post('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'store'])->middleware('throttle:content-write')->name('meal-posts.reviews.store'); Route::get('post-reviews/{postReview}', [PostReviewsController::class, 'show'])->name('post-reviews.show'); - Route::put('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->name('post-reviews.update'); - Route::patch('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->name('post-reviews.patch'); - Route::delete('post-reviews/{postReview}', [PostReviewsController::class, 'destroy'])->name('post-reviews.destroy'); - Route::apiResource('meal-posts', MealPostController::class)->except(['update']); - Route::put('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.update'); - Route::patch('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.patch'); + Route::put('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->middleware('throttle:content-write')->name('post-reviews.update'); + Route::patch('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->middleware('throttle:content-write')->name('post-reviews.patch'); + Route::delete('post-reviews/{postReview}', [PostReviewsController::class, 'destroy'])->middleware('throttle:content-write')->name('post-reviews.destroy'); + Route::apiResource('meal-posts', MealPostController::class) + ->except(['update']) + ->middlewareFor(['store', 'destroy'], 'throttle:content-write'); + Route::put('meal-posts/{mealPost}', [MealPostController::class, 'update'])->middleware('throttle:content-write')->name('meal-posts.update'); + Route::patch('meal-posts/{mealPost}', [MealPostController::class, 'update'])->middleware('throttle:content-write')->name('meal-posts.patch'); }); diff --git a/tests/Feature/RateLimitingTest.php b/tests/Feature/RateLimitingTest.php new file mode 100644 index 0000000..a4020c1 --- /dev/null +++ b/tests/Feature/RateLimitingTest.php @@ -0,0 +1,35 @@ +create()); + + foreach (range(1, 30) as $_) { + $this + ->postJson('/api/meal-posts') + ->assertUnprocessable(); + } + + $this + ->postJson('/api/meal-posts') + ->assertTooManyRequests(); +}); + +it('rate limits meal image analysis attempts more strictly', function () { + Sanctum::actingAs(User::factory()->create()); + + foreach (range(1, 5) as $_) { + $this + ->postJson('/api/meal-posts/analyze-image') + ->assertUnprocessable(); + } + + $this + ->postJson('/api/meal-posts/analyze-image') + ->assertTooManyRequests(); +});