mirror of
https://github.com/flarum/core.git
synced 2025-08-02 14:37:49 +02:00
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:
39
framework/core/src/Post/PostCreationThrottler.php
Normal file
39
framework/core/src/Post/PostCreationThrottler.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -9,11 +9,10 @@
|
|||||||
|
|
||||||
namespace Flarum\Post;
|
namespace Flarum\Post;
|
||||||
|
|
||||||
use DateTime;
|
|
||||||
use Flarum\Formatter\Formatter;
|
use Flarum\Formatter\Formatter;
|
||||||
use Flarum\Foundation\AbstractServiceProvider;
|
use Flarum\Foundation\AbstractServiceProvider;
|
||||||
use Flarum\Http\RequestUtil;
|
|
||||||
use Flarum\Post\Access\ScopePostVisibility;
|
use Flarum\Post\Access\ScopePostVisibility;
|
||||||
|
use Illuminate\Contracts\Container\Container;
|
||||||
|
|
||||||
class PostServiceProvider extends AbstractServiceProvider
|
class PostServiceProvider extends AbstractServiceProvider
|
||||||
{
|
{
|
||||||
@@ -22,22 +21,8 @@ class PostServiceProvider extends AbstractServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register()
|
public function register()
|
||||||
{
|
{
|
||||||
$this->container->extend('flarum.api.throttlers', function ($throttlers) {
|
$this->container->extend('flarum.api.throttlers', function (array $throttlers, Container $container) {
|
||||||
$throttlers['postTimeout'] = function ($request) {
|
$throttlers['postTimeout'] = $container->make(PostCreationThrottler::class);
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return $throttlers;
|
return $throttlers;
|
||||||
});
|
});
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
46
framework/core/src/User/Throttler/EmailChangeThrottler.php
Normal file
46
framework/core/src/User/Throttler/EmailChangeThrottler.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
46
framework/core/src/User/Throttler/PasswordResetThrottler.php
Normal file
46
framework/core/src/User/Throttler/PasswordResetThrottler.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -24,6 +24,9 @@ use Flarum\User\DisplayName\UsernameDriver;
|
|||||||
use Flarum\User\Event\EmailChangeRequested;
|
use Flarum\User\Event\EmailChangeRequested;
|
||||||
use Flarum\User\Event\Registered;
|
use Flarum\User\Event\Registered;
|
||||||
use Flarum\User\Event\Saving;
|
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\Container\Container;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -51,6 +54,14 @@ class UserServiceProvider extends AbstractServiceProvider
|
|||||||
User::class => [Access\UserPolicy::class],
|
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()
|
protected function registerDisplayNameDrivers()
|
||||||
|
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
@@ -12,6 +12,7 @@ namespace Flarum\Tests\integration\api\users;
|
|||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||||
use Flarum\Testing\integration\TestCase;
|
use Flarum\Testing\integration\TestCase;
|
||||||
|
use Flarum\User\Throttler\EmailChangeThrottler;
|
||||||
use Flarum\User\User;
|
use Flarum\User\User;
|
||||||
|
|
||||||
class UpdateTest extends TestCase
|
class UpdateTest extends TestCase
|
||||||
@@ -156,6 +157,62 @@ class UpdateTest extends TestCase
|
|||||||
$this->assertEquals(200, $response->getStatusCode());
|
$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
|
* @test
|
||||||
*/
|
*/
|
||||||
|
Reference in New Issue
Block a user