diff --git a/config/routes.php b/config/routes.php index 95a6427684c..b9de16a738b 100644 --- a/config/routes.php +++ b/config/routes.php @@ -53,4 +53,34 @@ 'middleware' => 'web', + /* + |-------------------------------------------------------------------------- + | Auth Route Middleware + |-------------------------------------------------------------------------- + | + | Additional middleware applied to the frontend auth routes (login, + | register, password reset, etc). Useful for rate limiting, e.g. + | 'throttle:5,1' to allow 5 attempts per minute. + | + */ + + 'auth_middleware' => [ + \Illuminate\Routing\Middleware\ThrottleRequests::class.':5,1', + ], + + /* + |-------------------------------------------------------------------------- + | Forms Route Middleware + |-------------------------------------------------------------------------- + | + | Additional middleware applied to the frontend form submission route. + | Useful for rate limiting, e.g. 'throttle:10,1' to allow 10 submissions + | per minute. + | + */ + + 'forms_middleware' => [ + \Illuminate\Routing\Middleware\ThrottleRequests::class.':10,1', + ], + ]; diff --git a/routes/web.php b/routes/web.php index a1f706681a1..aff7df3b899 100755 --- a/routes/web.php +++ b/routes/web.php @@ -34,12 +34,12 @@ Route::name('statamic.')->group(function () { Route::group(['prefix' => config('statamic.routes.action')], function () { - Route::post('forms/{form}', [FormController::class, 'submit'])->middleware([HandlePrecognitiveRequests::class])->name('forms.submit'); + Route::post('forms/{form}', [FormController::class, 'submit'])->middleware(array_merge([HandlePrecognitiveRequests::class], (array) config('statamic.routes.forms_middleware', [])))->name('forms.submit'); Route::get('protect/password', [PasswordProtectController::class, 'show'])->name('protect.password.show')->middleware([HandleInertiaRequests::class]); Route::post('protect/password', [PasswordProtectController::class, 'store'])->name('protect.password.store'); - Route::group(['prefix' => 'auth', 'middleware' => [AuthGuard::class]], function () { + Route::group(['prefix' => 'auth', 'middleware' => array_merge([AuthGuard::class], (array) config('statamic.routes.auth_middleware', []))], function () { Route::get('logout', [LoginController::class, 'logout'])->name('logout'); Route::group(['middleware' => [HandlePrecognitiveRequests::class]], function () { diff --git a/tests/Routing/RouteMiddlewareTest.php b/tests/Routing/RouteMiddlewareTest.php new file mode 100644 index 00000000000..5f95af80d63 --- /dev/null +++ b/tests/Routing/RouteMiddlewareTest.php @@ -0,0 +1,124 @@ +set('statamic.routes.auth_middleware', [ThrottleRequests::class.':2,1']); + } + + protected function withFormsThrottleMiddleware($app) + { + $app['config']->set('statamic.routes.forms_middleware', [ThrottleRequests::class.':2,1']); + } + + #[Test] + public function no_extra_middleware_is_applied_to_auth_routes_by_default() + { + for ($i = 0; $i < 5; $i++) { + $this->post('/!/auth/login', ['email' => 'test@example.com', 'password' => 'wrong']) + ->assertStatus(302); + } + } + + #[Test] + #[DefineEnvironment('withAuthThrottleMiddleware')] + public function custom_middleware_is_applied_to_auth_login_route() + { + $this->post('/!/auth/login', ['email' => 'test@example.com', 'password' => 'wrong'])->assertStatus(302); + $this->post('/!/auth/login', ['email' => 'test@example.com', 'password' => 'wrong'])->assertStatus(302); + $this->post('/!/auth/login', ['email' => 'test@example.com', 'password' => 'wrong'])->assertStatus(429); + } + + #[Test] + #[DefineEnvironment('withAuthThrottleMiddleware')] + public function custom_auth_middleware_is_applied_to_all_auth_routes() + { + $this->post('/!/auth/password/email', ['email' => 'test@example.com'])->assertStatus(302); + $this->post('/!/auth/password/email', ['email' => 'test@example.com'])->assertStatus(302); + $this->post('/!/auth/password/email', ['email' => 'test@example.com'])->assertStatus(429); + } + + #[Test] + #[DefineEnvironment('withAuthThrottleMiddleware')] + public function custom_auth_middleware_does_not_affect_forms_route() + { + $this->createContactForm(); + + // Auth routes reach the throttle limit + $this->post('/!/auth/login', ['email' => 'test@example.com', 'password' => 'wrong'])->assertStatus(302); + $this->post('/!/auth/login', ['email' => 'test@example.com', 'password' => 'wrong'])->assertStatus(302); + $this->post('/!/auth/login', ['email' => 'test@example.com', 'password' => 'wrong'])->assertStatus(429); + + // Forms route is unaffected + $this->post('/!/forms/contact', ['email' => 'test@example.com'])->assertStatus(302); + $this->post('/!/forms/contact', ['email' => 'test@example.com'])->assertStatus(302); + $this->post('/!/forms/contact', ['email' => 'test@example.com'])->assertStatus(302); + } + + #[Test] + public function no_extra_middleware_is_applied_to_forms_route_by_default() + { + $this->createContactForm(); + + for ($i = 0; $i < 5; $i++) { + $this->post('/!/forms/contact', ['email' => 'test@example.com'])->assertStatus(302); + } + } + + #[Test] + #[DefineEnvironment('withFormsThrottleMiddleware')] + public function custom_middleware_is_applied_to_forms_route() + { + $this->createContactForm(); + + $this->post('/!/forms/contact', ['email' => 'test@example.com'])->assertStatus(302); + $this->post('/!/forms/contact', ['email' => 'test@example.com'])->assertStatus(302); + $this->post('/!/forms/contact', ['email' => 'test@example.com'])->assertStatus(429); + } + + #[Test] + #[DefineEnvironment('withFormsThrottleMiddleware')] + public function custom_forms_middleware_does_not_affect_auth_routes() + { + $this->createContactForm(); + + // Forms route reaches the throttle limit + $this->post('/!/forms/contact', ['email' => 'test@example.com'])->assertStatus(302); + $this->post('/!/forms/contact', ['email' => 'test@example.com'])->assertStatus(302); + $this->post('/!/forms/contact', ['email' => 'test@example.com'])->assertStatus(429); + + // Auth routes are unaffected + $this->post('/!/auth/login', ['email' => 'test@example.com', 'password' => 'wrong'])->assertStatus(302); + $this->post('/!/auth/login', ['email' => 'test@example.com', 'password' => 'wrong'])->assertStatus(302); + $this->post('/!/auth/login', ['email' => 'test@example.com', 'password' => 'wrong'])->assertStatus(302); + } + + private function createContactForm(): void + { + $blueprint = Blueprint::make()->setContents([ + 'fields' => [ + ['handle' => 'email', 'field' => ['type' => 'text', 'validate' => 'required|email']], + ], + ]); + + Blueprint::shouldReceive('find')->with('forms.contact')->andReturn($blueprint); + Blueprint::makePartial(); + + $form = Form::make()->handle('contact'); + Form::shouldReceive('find')->with('contact')->andReturn($form); + Form::makePartial(); + } +}