feat: rate limiting
Laravel CI-CD / Tests Unitaires (push) Successful in 1m30s Details
Laravel CI-CD / Deploy with Kamal (push) Successful in 1m43s Details

This commit is contained in:
Leon Morival 2026-05-23 09:34:51 +02:00
parent 6f619a8184
commit cd38d88fe7
3 changed files with 130 additions and 26 deletions

View File

@ -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();
}
}

View File

@ -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');
});

View File

@ -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();
});