feat: throttle email change, email confirmation, and password reset endpoints. (#3555)

* chore: move post throttler to separate class
* feat: throttle email change requests
* feat: throttle email activation requests
* feat: throttle password resets for logged-in users
* docs: comment new throttlers
This commit is contained in:
Sami Mazouz 2022-07-30 07:18:51 +01:00 committed by GitHub
parent 021793fc52
commit f610f8aa67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 386 additions and 18 deletions

View File

@ -0,0 +1,39 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Post;
use Carbon\Carbon;
use Flarum\Http\RequestUtil;
use Psr\Http\Message\ServerRequestInterface;
class PostCreationThrottler
{
public static $timeout = 10;
/**
* @return bool|void
*/
public function __invoke(ServerRequestInterface $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', '>=', Carbon::now()->subSeconds(self::$timeout))->exists()) {
return true;
}
}
}

View File

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

View File

@ -0,0 +1,44 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Throttler;
use Carbon\Carbon;
use Flarum\Http\RequestUtil;
use Flarum\User\EmailToken;
use Psr\Http\Message\ServerRequestInterface;
/**
* Unactivated users can request a confirmation email,
* this throttler applies a timeout of 5 minutes between confirmation requests.
*/
class EmailActivationThrottler
{
public static $timeout = 300;
/**
* @return bool|void
*/
public function __invoke(ServerRequestInterface $request)
{
if ($request->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;
}
}
}

View File

@ -0,0 +1,46 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Throttler;
use Carbon\Carbon;
use Flarum\Http\RequestUtil;
use Flarum\User\EmailToken;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
/**
* Users can request an email change,
* this throttler applies a timeout of 5 minutes between requests.
*/
class EmailChangeThrottler
{
public static $timeout = 300;
/**
* @return bool|void
*/
public function __invoke(ServerRequestInterface $request)
{
if ($request->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;
}
}
}

View File

@ -0,0 +1,46 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\User\Throttler;
use Carbon\Carbon;
use Flarum\Http\RequestUtil;
use Flarum\User\PasswordToken;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
/**
* Logged-in users can request password reset email,
* this throttler applies a timeout of 5 minutes between password resets.
* This does not apply to guests requesting password resets.
*/
class PasswordResetThrottler
{
public static $timeout = 300;
/**
* @return bool|void
*/
public function __invoke(ServerRequestInterface $request)
{
if ($request->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;
}
}
}

View File

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

View File

@ -0,0 +1,67 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\api\users;
use Carbon\Carbon;
use Flarum\Testing\integration\TestCase;
use Flarum\User\Throttler\EmailActivationThrottler;
class SendActivationEmailTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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());
}
}

View File

@ -0,0 +1,73 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tests\integration\api\users;
use Carbon\Carbon;
use Flarum\Testing\integration\TestCase;
use Flarum\User\Throttler\PasswordResetThrottler;
class SendPasswordResetEmailTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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());
}
}

View File

@ -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
*/