feat: rate limiting
This commit is contained in:
parent
6f619a8184
commit
cd38d88fe7
|
|
@ -8,8 +8,11 @@ use App\Mail\VerifyAccount;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Notifications\ResetPassword;
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Spatie\Health\Checks\Checks\DatabaseCheck;
|
use Spatie\Health\Checks\Checks\DatabaseCheck;
|
||||||
|
|
@ -38,6 +41,8 @@ class AppServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
$this->configureRateLimiting();
|
||||||
|
|
||||||
if ($this->app->environment('production')) {
|
if ($this->app->environment('production')) {
|
||||||
URL::forceScheme('https');
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
Route::prefix('auth')->group(function (): void {
|
Route::prefix('auth')->group(function (): void {
|
||||||
Route::post('register', [AuthController::class, 'register']);
|
Route::post('register', [AuthController::class, 'register'])->middleware('throttle:auth');
|
||||||
Route::post('login', [AuthController::class, 'login']);
|
Route::post('login', [AuthController::class, 'login'])->middleware('throttle:auth');
|
||||||
Route::post('mobile/register', [AuthController::class, 'mobileRegister']);
|
Route::post('mobile/register', [AuthController::class, 'mobileRegister'])->middleware('throttle:auth');
|
||||||
Route::post('mobile/login', [AuthController::class, 'mobileLogin']);
|
Route::post('mobile/login', [AuthController::class, 'mobileLogin'])->middleware('throttle:auth');
|
||||||
Route::post('forgot-password', [AuthController::class, 'forgotPassword'])
|
Route::post('forgot-password', [AuthController::class, 'forgotPassword'])
|
||||||
->middleware('throttle:6,1')
|
->middleware('throttle:6,1')
|
||||||
->name('password.email');
|
->name('password.email');
|
||||||
|
|
@ -33,8 +33,8 @@ Route::prefix('auth')->group(function (): void {
|
||||||
Route::middleware('auth:sanctum')->group(function (): void {
|
Route::middleware('auth:sanctum')->group(function (): void {
|
||||||
Route::post('logout', [AuthController::class, 'logout']);
|
Route::post('logout', [AuthController::class, 'logout']);
|
||||||
Route::get('me', [AuthController::class, 'me']);
|
Route::get('me', [AuthController::class, 'me']);
|
||||||
Route::patch('me', [AuthController::class, 'update'])->middleware('verified');
|
Route::patch('me', [AuthController::class, 'update'])->middleware(['verified', 'throttle:content-write']);
|
||||||
Route::delete('me', [AuthController::class, 'destroy']);
|
Route::delete('me', [AuthController::class, 'destroy'])->middleware('throttle:account-action');
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
@ -44,45 +44,47 @@ Route::get('/health', function () {
|
||||||
return response()->json(['status' => 'ok']);
|
return response()->json(['status' => 'ok']);
|
||||||
});
|
});
|
||||||
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
||||||
Route::post('device-tokens', [DeviceTokenController::class, 'store']);
|
Route::post('device-tokens', [DeviceTokenController::class, 'store'])->middleware('throttle:device-token');
|
||||||
Route::delete('device-tokens', [DeviceTokenController::class, 'destroy']);
|
Route::delete('device-tokens', [DeviceTokenController::class, 'destroy'])->middleware('throttle:device-token');
|
||||||
Route::get('test-notifs', [DeviceTokenController::class, 'notifs']);
|
Route::get('test-notifs', [DeviceTokenController::class, 'notifs'])->middleware('throttle:notifications');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Workouts
|
// Workouts
|
||||||
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
||||||
Route::get('workouts/calendar', [WorkoutSessionController::class, 'calendar'])->name('workouts.calendar');
|
Route::get('workouts/calendar', [WorkoutSessionController::class, 'calendar'])->name('workouts.calendar');
|
||||||
Route::get('workouts', [WorkoutSessionController::class, 'index'])->name('workouts.index');
|
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/status', [StravaController::class, 'status'])->name('strava.status');
|
||||||
Route::get('strava/authorization-url', [StravaController::class, 'authorizationUrl'])->name('strava.authorization-url');
|
Route::get('strava/authorization-url', [StravaController::class, 'authorizationUrl'])->middleware('throttle:external-service')->name('strava.authorization-url');
|
||||||
Route::post('strava/callback', [StravaController::class, 'callback'])->name('strava.callback');
|
Route::post('strava/callback', [StravaController::class, 'callback'])->middleware('throttle:external-service')->name('strava.callback');
|
||||||
Route::delete('strava/connection', [StravaController::class, 'disconnect'])->name('strava.disconnect');
|
Route::delete('strava/connection', [StravaController::class, 'disconnect'])->middleware('throttle:external-service')->name('strava.disconnect');
|
||||||
Route::post('strava/sync', [StravaController::class, 'sync'])->name('strava.sync');
|
Route::post('strava/sync', [StravaController::class, 'sync'])->middleware('throttle:external-service')->name('strava.sync');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Meals
|
// Meals
|
||||||
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
||||||
Route::get('users/{user}', [PublicUserProfileController::class, 'show'])->name('users.show');
|
Route::get('users/{user}', [PublicUserProfileController::class, 'show'])->name('users.show');
|
||||||
Route::post('users/{user}/reports', [ReportController::class, 'storeUser'])
|
Route::post('users/{user}/reports', [ReportController::class, 'storeUser'])
|
||||||
->middleware('throttle:10,1')
|
->middleware('throttle:reports')
|
||||||
->name('users.reports.store');
|
->name('users.reports.store');
|
||||||
Route::get('me/meal-posts', [MealPostController::class, 'mine'])->name('meal-posts.mine');
|
Route::get('me/meal-posts', [MealPostController::class, 'mine'])->name('meal-posts.mine');
|
||||||
Route::get('meal-posts/stats', [MealPostController::class, 'stats']);
|
Route::get('meal-posts/stats', [MealPostController::class, 'stats']);
|
||||||
Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']);
|
Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']);
|
||||||
Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store']);
|
Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store'])->middleware('throttle:ai-analysis');
|
||||||
Route::post('meal-posts/{mealPost}/like', [MealPostController::class, 'like'])->name('meal-posts.like');
|
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'])->name('meal-posts.unlike');
|
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'])
|
Route::post('meal-posts/{mealPost}/reports', [ReportController::class, 'storeMealPost'])
|
||||||
->middleware('throttle:10,1')
|
->middleware('throttle:reports')
|
||||||
->name('meal-posts.reports.store');
|
->name('meal-posts.reports.store');
|
||||||
Route::get('meal-posts/{mealPost}/reviews', [PostReviewsController::class, 'index'])->name('meal-posts.reviews.index');
|
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::get('post-reviews/{postReview}', [PostReviewsController::class, 'show'])->name('post-reviews.show');
|
||||||
Route::put('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->name('post-reviews.update');
|
Route::put('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->middleware('throttle:content-write')->name('post-reviews.update');
|
||||||
Route::patch('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->name('post-reviews.patch');
|
Route::patch('post-reviews/{postReview}', [PostReviewsController::class, 'update'])->middleware('throttle:content-write')->name('post-reviews.patch');
|
||||||
Route::delete('post-reviews/{postReview}', [PostReviewsController::class, 'destroy'])->name('post-reviews.destroy');
|
Route::delete('post-reviews/{postReview}', [PostReviewsController::class, 'destroy'])->middleware('throttle:content-write')->name('post-reviews.destroy');
|
||||||
Route::apiResource('meal-posts', MealPostController::class)->except(['update']);
|
Route::apiResource('meal-posts', MealPostController::class)
|
||||||
Route::put('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.update');
|
->except(['update'])
|
||||||
Route::patch('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.patch');
|
->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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('rate limits meal post creation attempts', function () {
|
||||||
|
Sanctum::actingAs(User::factory()->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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue