Skip to content
8 changes: 4 additions & 4 deletions routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,12 @@
Route::group(['prefix' => 'auth'], function () {
if (config('statamic.cp.auth.enabled', true)) {
Route::get('login', [LoginController::class, 'showLoginForm'])->name('login');
Route::post('login', [LoginController::class, 'login']);
Route::post('login', [LoginController::class, 'login'])->middleware('throttle:statamic.cp.auth');

Route::get('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request');
Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email');
Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->middleware('throttle:statamic.cp.auth')->name('password.email');
Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset');
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.reset.action');
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->middleware('throttle:statamic.cp.auth')->name('password.reset.action');

if (TwoFactor::enabled()) {
Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'index'])->name('two-factor-challenge');
Expand All @@ -148,7 +148,7 @@

Route::get('stop-impersonating', [ImpersonationController::class, 'stop'])->name('impersonation.stop');

Route::group(['prefix' => 'passkeys'], function () {
Route::group(['prefix' => 'passkeys', 'middleware' => 'throttle:statamic.cp.passkeys'], function () {
Route::post('/', [PasskeyLoginController::class, 'login'])->name('passkeys.auth');
Route::get('options', [PasskeyLoginController::class, 'options'])->name('passkeys.auth.options');
});
Expand Down
10 changes: 5 additions & 5 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,27 +34,27 @@

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([HandlePrecognitiveRequests::class, 'throttle:statamic.forms'])->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::get('logout', [LoginController::class, 'logout'])->name('logout');

Route::group(['middleware' => [HandlePrecognitiveRequests::class]], function () {
Route::group(['middleware' => [HandlePrecognitiveRequests::class, 'throttle:statamic.auth']], function () {
Route::post('login', [LoginController::class, 'login'])->name('login');
Route::post('register', RegisterController::class)->name('register');
Route::post('profile', ProfileController::class)->name('profile');
Route::post('password', PasswordController::class)->name('password');
});

Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email');
Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->middleware('throttle:statamic.auth')->name('password.email');
Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset');
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.reset.action');
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->middleware('throttle:statamic.auth')->name('password.reset.action');

Route::group(['prefix' => 'passkeys'], function () {
Route::middleware(ThrottleRequests::class.':30,1')->group(function () {
Route::middleware('throttle:statamic.passkeys')->group(function () {
Route::get('options', [PasskeyLoginController::class, 'options'])->name('passkeys.options');
Route::post('auth', [PasskeyLoginController::class, 'login'])->name('passkeys.login');
});
Expand Down
20 changes: 20 additions & 0 deletions src/Providers/AuthServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,26 @@ public function boot()
: $broker;
});

RateLimiter::for('statamic.auth', function (Request $request) {
return Limit::perMinute(4)->by($request->ip());
});

RateLimiter::for('statamic.cp.auth', function (Request $request) {
return RateLimiter::limiter('statamic.auth')($request);
});

RateLimiter::for('statamic.passkeys', function (Request $request) {
return Limit::perMinute(30)->by($request->ip());
});

RateLimiter::for('statamic.cp.passkeys', function (Request $request) {
return RateLimiter::limiter('statamic.passkeys')($request);
});

RateLimiter::for('statamic.forms', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});

RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
Expand Down
199 changes: 199 additions & 0 deletions tests/Feature/RateLimitingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<?php

namespace Tests\Feature;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use PHPUnit\Framework\Attributes\Test;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

class RateLimitingTest extends TestCase
{
use PreventSavingStacheItemsToDisk;

protected function setUp(): void
{
parent::setUp();
Cache::flush();
}

#[Test]
public function login_endpoint_is_rate_limited()
{
collect(range(1, 4))->each(fn () => $this->post('/!/auth/login')->assertNotRateLimited());
$this->post('/!/auth/login')->assertRateLimited();
}

#[Test]
public function register_endpoint_is_rate_limited()
{
collect(range(1, 4))->each(fn () => $this->post('/!/auth/register')->assertNotRateLimited());
$this->post('/!/auth/register')->assertRateLimited();
}

#[Test]
public function password_email_endpoint_is_rate_limited()
{
collect(range(1, 4))->each(fn () => $this->post('/!/auth/password/email')->assertNotRateLimited());
$this->post('/!/auth/password/email')->assertRateLimited();
}

#[Test]
public function password_reset_endpoint_is_rate_limited()
{
collect(range(1, 4))->each(fn () => $this->post('/!/auth/password/reset')->assertNotRateLimited());
$this->post('/!/auth/password/reset')->assertRateLimited();
}

#[Test]
public function forms_endpoint_is_rate_limited()
{
collect(range(1, 10))->each(fn () => $this->post('/!/forms/contact')->assertNotRateLimited());
$this->post('/!/forms/contact')->assertRateLimited();
}

#[Test]
public function cp_login_endpoint_is_rate_limited()
{
collect(range(1, 4))->each(fn () => $this->post('/cp/auth/login')->assertNotRateLimited());
$this->post('/cp/auth/login')->assertRateLimited();
}

#[Test]
public function cp_password_email_endpoint_is_rate_limited()
{
collect(range(1, 4))->each(fn () => $this->post('/cp/auth/password/email')->assertNotRateLimited());
$this->post('/cp/auth/password/email')->assertRateLimited();
}

#[Test]
public function cp_password_reset_endpoint_is_rate_limited()
{
collect(range(1, 4))->each(fn () => $this->post('/cp/auth/password/reset')->assertNotRateLimited());
$this->post('/cp/auth/password/reset')->assertRateLimited();
}

#[Test]
public function cp_and_frontend_auth_have_independent_buckets()
{
collect(range(1, 4))->each(fn () => $this->post('/!/auth/login')->assertNotRateLimited());
$this->post('/!/auth/login')->assertRateLimited();

$this->post('/cp/auth/login')->assertNotRateLimited();
}

#[Test]
public function auth_rate_limiter_can_be_overridden()
{
// Simulate a developer overriding the default 4/min limit to 2/min
RateLimiter::for('statamic.auth', fn ($request) => Limit::perMinute(2)->by($request->ip()));

$this->post('/!/auth/login')->assertNotRateLimited();
$this->post('/!/auth/login')->assertNotRateLimited();
$this->post('/!/auth/login')->assertRateLimited();
}

#[Test]
public function cp_auth_rate_limiter_inherits_overrides_to_statamic_auth()
{
RateLimiter::for('statamic.auth', fn ($request) => Limit::perMinute(2)->by($request->ip()));

$this->post('/cp/auth/login')->assertNotRateLimited();
$this->post('/cp/auth/login')->assertNotRateLimited();
$this->post('/cp/auth/login')->assertRateLimited();
}

#[Test]
public function cp_auth_rate_limiter_can_be_overridden_independently()
{
RateLimiter::for('statamic.cp.auth', fn ($request) => Limit::perMinute(2)->by($request->ip()));

$this->post('/cp/auth/login')->assertNotRateLimited();
$this->post('/cp/auth/login')->assertNotRateLimited();
$this->post('/cp/auth/login')->assertRateLimited();

// Frontend auth still uses the default 4/min
collect(range(1, 4))->each(fn () => $this->post('/!/auth/login')->assertNotRateLimited());
}

#[Test]
public function forms_rate_limiter_can_be_overridden()
{
// Simulate a developer overriding the default 10/min limit to 2/min
RateLimiter::for('statamic.forms', fn ($request) => Limit::perMinute(2)->by($request->ip()));

$this->post('/!/forms/contact')->assertNotRateLimited();
$this->post('/!/forms/contact')->assertNotRateLimited();
$this->post('/!/forms/contact')->assertRateLimited();
}

#[Test]
public function passkey_endpoint_is_rate_limited()
{
collect(range(1, 30))->each(fn () => $this->post('/!/auth/passkeys/auth')->assertNotRateLimited());
$this->post('/!/auth/passkeys/auth')->assertRateLimited();
}

#[Test]
public function cp_passkey_endpoint_is_rate_limited()
{
collect(range(1, 30))->each(fn () => $this->post('/cp/auth/passkeys')->assertNotRateLimited());
$this->post('/cp/auth/passkeys')->assertRateLimited();
}

#[Test]
public function cp_and_frontend_passkeys_have_independent_buckets()
{
RateLimiter::for('statamic.passkeys', fn ($request) => Limit::perMinute(2)->by($request->ip()));

$this->post('/!/auth/passkeys/auth')->assertNotRateLimited();
$this->post('/!/auth/passkeys/auth')->assertNotRateLimited();
$this->post('/!/auth/passkeys/auth')->assertRateLimited();

$this->post('/cp/auth/passkeys')->assertNotRateLimited();
}

#[Test]
public function passkeys_bucket_is_independent_from_auth_bucket()
{
collect(range(1, 4))->each(fn () => $this->post('/!/auth/login')->assertNotRateLimited());
$this->post('/!/auth/login')->assertRateLimited();

$this->post('/!/auth/passkeys/auth')->assertNotRateLimited();
}

#[Test]
public function passkeys_rate_limiter_can_be_overridden()
{
RateLimiter::for('statamic.passkeys', fn ($request) => Limit::perMinute(2)->by($request->ip()));

$this->post('/!/auth/passkeys/auth')->assertNotRateLimited();
$this->post('/!/auth/passkeys/auth')->assertNotRateLimited();
$this->post('/!/auth/passkeys/auth')->assertRateLimited();
}

#[Test]
public function cp_passkeys_rate_limiter_inherits_overrides_to_statamic_passkeys()
{
RateLimiter::for('statamic.passkeys', fn ($request) => Limit::perMinute(2)->by($request->ip()));

$this->post('/cp/auth/passkeys')->assertNotRateLimited();
$this->post('/cp/auth/passkeys')->assertNotRateLimited();
$this->post('/cp/auth/passkeys')->assertRateLimited();
}

#[Test]
public function cp_passkeys_rate_limiter_can_be_overridden_independently()
{
RateLimiter::for('statamic.cp.passkeys', fn ($request) => Limit::perMinute(2)->by($request->ip()));

$this->post('/cp/auth/passkeys')->assertNotRateLimited();
$this->post('/cp/auth/passkeys')->assertNotRateLimited();
$this->post('/cp/auth/passkeys')->assertRateLimited();

// Frontend passkey still uses the default 30/min
collect(range(1, 30))->each(fn () => $this->post('/!/auth/passkeys/auth')->assertNotRateLimited());
}
}
16 changes: 16 additions & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ protected function setUp(): void
}

$this->addGqlMacros();
$this->addRateLimitMacros();
}

public function tearDown(): void
Expand Down Expand Up @@ -281,6 +282,21 @@ private function addGqlMacros()
});
}

private function addRateLimitMacros()
{
TestResponse::macro('assertRateLimited', function () {
Assert::assertSame(429, $this->getStatusCode(), 'Expected request to be rate limited, but it was not.');

return $this;
});

TestResponse::macro('assertNotRateLimited', function () {
Assert::assertNotSame(429, $this->getStatusCode(), 'Expected request not to be rate limited, but it was.');

return $this;
});
}

public function __call($name, $arguments)
{
if ($name == 'assertStringEqualsStringIgnoringLineEndings') {
Expand Down
Loading