diff --git a/framework/core/src/Post/PostCreationThrottler.php b/framework/core/src/Post/PostCreationThrottler.php new file mode 100644 index 000000000..d390f640b --- /dev/null +++ b/framework/core/src/Post/PostCreationThrottler.php @@ -0,0 +1,39 @@ +getAttribute('routeName'), ['discussions.create', 'posts.create'])) { + return; + } + + $actor = RequestUtil::getActor($request); + + if ($actor->can('postWithoutThrottle')) { + return false; + } + + if (Post::where('user_id', $actor->id)->where('created_at', '>=', Carbon::now()->subSeconds(self::$timeout))->exists()) { + return true; + } + } +} diff --git a/framework/core/src/Post/PostServiceProvider.php b/framework/core/src/Post/PostServiceProvider.php index 65f032de7..bbc28c655 100644 --- a/framework/core/src/Post/PostServiceProvider.php +++ b/framework/core/src/Post/PostServiceProvider.php @@ -9,11 +9,10 @@ namespace Flarum\Post; -use DateTime; use Flarum\Formatter\Formatter; use Flarum\Foundation\AbstractServiceProvider; -use Flarum\Http\RequestUtil; use Flarum\Post\Access\ScopePostVisibility; +use Illuminate\Contracts\Container\Container; class PostServiceProvider extends AbstractServiceProvider { @@ -22,22 +21,8 @@ class PostServiceProvider extends AbstractServiceProvider */ public function register() { - $this->container->extend('flarum.api.throttlers', function ($throttlers) { - $throttlers['postTimeout'] = function ($request) { - if (! in_array($request->getAttribute('routeName'), ['discussions.create', 'posts.create'])) { - return; - } - - $actor = RequestUtil::getActor($request); - - if ($actor->can('postWithoutThrottle')) { - return false; - } - - if (Post::where('user_id', $actor->id)->where('created_at', '>=', new DateTime('-10 seconds'))->exists()) { - return true; - } - }; + $this->container->extend('flarum.api.throttlers', function (array $throttlers, Container $container) { + $throttlers['postTimeout'] = $container->make(PostCreationThrottler::class); return $throttlers; }); diff --git a/framework/core/src/User/Throttler/EmailActivationThrottler.php b/framework/core/src/User/Throttler/EmailActivationThrottler.php new file mode 100644 index 000000000..21047ed97 --- /dev/null +++ b/framework/core/src/User/Throttler/EmailActivationThrottler.php @@ -0,0 +1,44 @@ +getAttribute('routeName') !== 'users.confirmation.send') { + return; + } + + $actor = RequestUtil::getActor($request); + + if (EmailToken::query() + ->where('user_id', $actor->id) + ->where('email', $actor->email) + ->where('created_at', '>=', Carbon::now()->subSeconds(self::$timeout)) + ->exists()) { + return true; + } + } +} diff --git a/framework/core/src/User/Throttler/EmailChangeThrottler.php b/framework/core/src/User/Throttler/EmailChangeThrottler.php new file mode 100644 index 000000000..53cd3be7d --- /dev/null +++ b/framework/core/src/User/Throttler/EmailChangeThrottler.php @@ -0,0 +1,46 @@ +getAttribute('routeName') !== 'users.update') { + return; + } + + if (! Arr::has($request->getParsedBody(), 'data.attributes.email')) { + return; + } + + $actor = RequestUtil::getActor($request); + + // Check that an email token was not already created recently (last 5 minutes). + if (EmailToken::query()->where('user_id', $actor->id)->where('created_at', '>=', Carbon::now()->subSeconds(self::$timeout))->exists()) { + return true; + } + } +} diff --git a/framework/core/src/User/Throttler/PasswordResetThrottler.php b/framework/core/src/User/Throttler/PasswordResetThrottler.php new file mode 100644 index 000000000..1b36ff2a9 --- /dev/null +++ b/framework/core/src/User/Throttler/PasswordResetThrottler.php @@ -0,0 +1,46 @@ +getAttribute('routeName') !== 'forgot') { + return; + } + + if (! Arr::has($request->getParsedBody(), 'email')) { + return; + } + + $actor = RequestUtil::getActor($request); + + if (PasswordToken::query()->where('user_id', $actor->id)->where('created_at', '>=', Carbon::now()->subSeconds(self::$timeout))->exists()) { + return true; + } + } +} diff --git a/framework/core/src/User/UserServiceProvider.php b/framework/core/src/User/UserServiceProvider.php index 6e3160d60..e936a370c 100644 --- a/framework/core/src/User/UserServiceProvider.php +++ b/framework/core/src/User/UserServiceProvider.php @@ -24,6 +24,9 @@ use Flarum\User\DisplayName\UsernameDriver; use Flarum\User\Event\EmailChangeRequested; use Flarum\User\Event\Registered; use Flarum\User\Event\Saving; +use Flarum\User\Throttler\EmailActivationThrottler; +use Flarum\User\Throttler\EmailChangeThrottler; +use Flarum\User\Throttler\PasswordResetThrottler; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Arr; @@ -51,6 +54,14 @@ class UserServiceProvider extends AbstractServiceProvider User::class => [Access\UserPolicy::class], ]; }); + + $this->container->extend('flarum.api.throttlers', function (array $throttlers, Container $container) { + $throttlers['emailChangeTimeout'] = $container->make(EmailChangeThrottler::class); + $throttlers['emailActivationTimeout'] = $container->make(EmailActivationThrottler::class); + $throttlers['passwordResetTimeout'] = $container->make(PasswordResetThrottler::class); + + return $throttlers; + }); } protected function registerDisplayNameDrivers() diff --git a/framework/core/tests/integration/api/users/SendActivationEmailTest.php b/framework/core/tests/integration/api/users/SendActivationEmailTest.php new file mode 100644 index 000000000..1707251da --- /dev/null +++ b/framework/core/tests/integration/api/users/SendActivationEmailTest.php @@ -0,0 +1,67 @@ +prepareDatabase([ + 'users' => [ + [ + 'id' => 3, + 'username' => 'normal2', + 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure" + 'email' => 'normal2@machine.local', + 'is_email_confirmed' => 0, + 'last_seen_at' => Carbon::now()->subSecond(), + ], + ] + ]); + } + + /** @test */ + public function users_can_send_confirmation_emails_in_moderate_intervals() + { + for ($i = 0; $i < 2; $i++) { + $response = $this->send( + $this->request('POST', '/api/users/3/send-confirmation', [ + 'authenticatedAs' => 3, + ]) + ); + + // We don't want to delay tests too long. + EmailActivationThrottler::$timeout = 5; + sleep(EmailActivationThrottler::$timeout + 1); + } + + $this->assertEquals(204, $response->getStatusCode()); + } + + /** @test */ + public function users_cant_send_confirmation_emails_too_fast() + { + for ($i = 0; $i < 2; $i++) { + $response = $this->send( + $this->request('POST', '/api/users/3/send-confirmation', [ + 'authenticatedAs' => 3, + ]) + ); + } + + $this->assertEquals(429, $response->getStatusCode()); + } +} diff --git a/framework/core/tests/integration/api/users/SendPasswordResetEmailTest.php b/framework/core/tests/integration/api/users/SendPasswordResetEmailTest.php new file mode 100644 index 000000000..09494edb7 --- /dev/null +++ b/framework/core/tests/integration/api/users/SendPasswordResetEmailTest.php @@ -0,0 +1,73 @@ +prepareDatabase([ + 'users' => [ + [ + 'id' => 3, + 'username' => 'normal2', + 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure" + 'email' => 'normal2@machine.local', + 'is_email_confirmed' => 0, + 'last_seen_at' => Carbon::now()->subSecond(), + ], + ] + ]); + } + + /** @test */ + public function users_can_send_password_reset_emails_in_moderate_intervals() + { + for ($i = 0; $i < 2; $i++) { + $response = $this->send( + $this->request('POST', '/api/forgot', [ + 'authenticatedAs' => 3, + 'json' => [ + 'email' => 'normal2@machine.local' + ] + ]) + ); + + // We don't want to delay tests too long. + PasswordResetThrottler::$timeout = 5; + sleep(PasswordResetThrottler::$timeout + 1); + } + + $this->assertEquals(204, $response->getStatusCode()); + } + + /** @test */ + public function users_cant_send_confirmation_emails_too_fast() + { + for ($i = 0; $i < 2; $i++) { + $response = $this->send( + $this->request('POST', '/api/forgot', [ + 'authenticatedAs' => 3, + 'json' => [ + 'email' => 'normal2@machine.local' + ] + ]) + ); + } + + $this->assertEquals(429, $response->getStatusCode()); + } +} diff --git a/framework/core/tests/integration/api/users/UpdateTest.php b/framework/core/tests/integration/api/users/UpdateTest.php index 39124ce11..56290ac43 100644 --- a/framework/core/tests/integration/api/users/UpdateTest.php +++ b/framework/core/tests/integration/api/users/UpdateTest.php @@ -12,6 +12,7 @@ namespace Flarum\Tests\integration\api\users; use Carbon\Carbon; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; +use Flarum\User\Throttler\EmailChangeThrottler; use Flarum\User\User; class UpdateTest extends TestCase @@ -156,6 +157,62 @@ class UpdateTest extends TestCase $this->assertEquals(200, $response->getStatusCode()); } + /** + * @test + */ + public function users_can_request_email_change_in_moderate_intervals() + { + for ($i = 0; $i < 2; $i++) { + $response = $this->send( + $this->request('PATCH', '/api/users/3', [ + 'authenticatedAs' => 3, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'email' => 'someOtherEmail@example.com', + ] + ], + 'meta' => [ + 'password' => 'too-obscure' + ] + ], + ]) + ); + + // We don't want to delay tests too long. + EmailChangeThrottler::$timeout = 5; + sleep(EmailChangeThrottler::$timeout + 1); + } + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @test + */ + public function users_cant_request_email_change_too_fast() + { + for ($i = 0; $i < 2; $i++) { + $response = $this->send( + $this->request('PATCH', '/api/users/3', [ + 'authenticatedAs' => 3, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'email' => 'someOtherEmail@example.com', + ] + ], + 'meta' => [ + 'password' => 'too-obscure' + ] + ], + ]) + ); + } + + $this->assertEquals(429, $response->getStatusCode()); + } + /** * @test */