mirror of
https://github.com/flarum/framework.git
synced 2024-11-26 10:14:16 +08:00
Move floodgate to middleware, add extender + integration tests (#2170)
This commit is contained in:
parent
5450fcda00
commit
ed9131b36c
|
@ -42,6 +42,20 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||||
return $routes;
|
return $routes;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->app->singleton('flarum.api.throttlers', function () {
|
||||||
|
return [
|
||||||
|
'bypassThrottlingAttribute' => function ($request) {
|
||||||
|
if ($request->getAttribute('bypassThrottling')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->app->bind(Middleware\ThrottleApi::class, function ($app) {
|
||||||
|
return new Middleware\ThrottleApi($app->make('flarum.api.throttlers'));
|
||||||
|
});
|
||||||
|
|
||||||
$this->app->singleton('flarum.api.middleware', function () {
|
$this->app->singleton('flarum.api.middleware', function () {
|
||||||
return [
|
return [
|
||||||
'flarum.api.error_handler',
|
'flarum.api.error_handler',
|
||||||
|
@ -53,7 +67,8 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||||
HttpMiddleware\AuthenticateWithHeader::class,
|
HttpMiddleware\AuthenticateWithHeader::class,
|
||||||
HttpMiddleware\SetLocale::class,
|
HttpMiddleware\SetLocale::class,
|
||||||
'flarum.api.route_resolver',
|
'flarum.api.route_resolver',
|
||||||
HttpMiddleware\CheckCsrfToken::class
|
HttpMiddleware\CheckCsrfToken::class,
|
||||||
|
Middleware\ThrottleApi::class
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -64,6 +64,9 @@ class CreateDiscussionController extends AbstractCreateController
|
||||||
$actor = $request->getAttribute('actor');
|
$actor = $request->getAttribute('actor');
|
||||||
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
|
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated, remove in beta 15.
|
||||||
|
*/
|
||||||
if (! $request->getAttribute('bypassFloodgate')) {
|
if (! $request->getAttribute('bypassFloodgate')) {
|
||||||
$this->floodgate->assertNotFlooding($actor);
|
$this->floodgate->assertNotFlooding($actor);
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,6 +65,9 @@ class CreatePostController extends AbstractCreateController
|
||||||
$discussionId = Arr::get($data, 'relationships.discussion.data.id');
|
$discussionId = Arr::get($data, 'relationships.discussion.data.id');
|
||||||
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
|
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated, remove in beta 15.
|
||||||
|
*/
|
||||||
if (! $request->getAttribute('bypassFloodgate')) {
|
if (! $request->getAttribute('bypassFloodgate')) {
|
||||||
$this->floodgate->assertNotFlooding($actor);
|
$this->floodgate->assertNotFlooding($actor);
|
||||||
}
|
}
|
||||||
|
|
57
framework/core/src/Api/Middleware/ThrottleApi.php
Normal file
57
framework/core/src/Api/Middleware/ThrottleApi.php
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
<?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\Api\Middleware;
|
||||||
|
|
||||||
|
use Flarum\Post\Exception\FloodingException;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface as Middleware;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||||
|
|
||||||
|
class ThrottleApi implements Middleware
|
||||||
|
{
|
||||||
|
protected $throttlers;
|
||||||
|
|
||||||
|
public function __construct(array $throttlers)
|
||||||
|
{
|
||||||
|
$this->throttlers = $throttlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(Request $request, Handler $handler): Response
|
||||||
|
{
|
||||||
|
if ($this->throttle($request)) {
|
||||||
|
throw new FloodingException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function throttle(Request $request): bool
|
||||||
|
{
|
||||||
|
$throttle = false;
|
||||||
|
foreach ($this->throttlers as $throttler) {
|
||||||
|
$result = $throttler($request);
|
||||||
|
|
||||||
|
// Explicitly returning false overrides all throttling.
|
||||||
|
// Explicitly returning true marks the request to be throttled.
|
||||||
|
// Anything else is ignored.
|
||||||
|
if ($result === false) {
|
||||||
|
return false;
|
||||||
|
} elseif ($result === true) {
|
||||||
|
$throttle = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $throttle;
|
||||||
|
}
|
||||||
|
}
|
74
framework/core/src/Extend/ThrottleApi.php
Normal file
74
framework/core/src/Extend/ThrottleApi.php
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<?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\Extend;
|
||||||
|
|
||||||
|
use Flarum\Extension\Extension;
|
||||||
|
use Flarum\Foundation\ContainerUtil;
|
||||||
|
use Illuminate\Contracts\Container\Container;
|
||||||
|
|
||||||
|
class ThrottleApi implements ExtenderInterface
|
||||||
|
{
|
||||||
|
private $setThrottlers = [];
|
||||||
|
private $removeThrottlers = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new throttler (or override one with the same name).
|
||||||
|
*
|
||||||
|
* @param string $name: The name of the throttler.
|
||||||
|
* @param string|callable $callback
|
||||||
|
*
|
||||||
|
* The callable can be a closure or invokable class, and should accept:
|
||||||
|
* - $request: The current `\Psr\Http\Message\ServerRequestInterface` request object.
|
||||||
|
* `$request->getAttribute('actor')` can be used to get the current user.
|
||||||
|
* `$request->getAttribute('routeName')` can be used to get the current route.
|
||||||
|
* Please note that every throttler runs by default on every route.
|
||||||
|
* If you only want to throttle certain routes, you'll need to check for that inside your logic.
|
||||||
|
*
|
||||||
|
* The callable should return one of:
|
||||||
|
* - `false`: This marks the request as NOT to be throttled. It overrides all other throttlers
|
||||||
|
* - `true`: This marks the request as to be throttled.
|
||||||
|
* All other outputs will be ignored.
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function set(string $name, $callback)
|
||||||
|
{
|
||||||
|
$this->setThrottlers[$name] = $callback;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a throttler registered with this name.
|
||||||
|
*
|
||||||
|
* @param string $name: The name of the throttler to remove.
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function remove(string $name)
|
||||||
|
{
|
||||||
|
$this->removeThrottlers[] = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extend(Container $container, Extension $extension = null)
|
||||||
|
{
|
||||||
|
$container->extend('flarum.api.throttlers', function ($throttlers) use ($container) {
|
||||||
|
$throttlers = array_diff_key($throttlers, array_flip($this->removeThrottlers));
|
||||||
|
|
||||||
|
foreach ($this->setThrottlers as $name => $throttler) {
|
||||||
|
$throttlers[$name] = ContainerUtil::wrapCallback($throttler, $container);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $throttlers;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,7 +38,7 @@ class AuthenticateWithHeader implements Middleware
|
||||||
$actor = $key->user ?? $this->getUser($userId);
|
$actor = $key->user ?? $this->getUser($userId);
|
||||||
|
|
||||||
$request = $request->withAttribute('apiKey', $key);
|
$request = $request->withAttribute('apiKey', $key);
|
||||||
$request = $request->withAttribute('bypassFloodgate', true);
|
$request = $request->withAttribute('bypassThrottling', true);
|
||||||
} elseif ($token = AccessToken::find($id)) {
|
} elseif ($token = AccessToken::find($id)) {
|
||||||
$token->touch();
|
$token->touch();
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,9 @@ use Flarum\Post\Exception\FloodingException;
|
||||||
use Flarum\User\User;
|
use Flarum\User\User;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated beta 14, removed beta 15 in favor of Floodgate middleware
|
||||||
|
*/
|
||||||
class Floodgate
|
class Floodgate
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -9,11 +9,38 @@
|
||||||
|
|
||||||
namespace Flarum\Post;
|
namespace Flarum\Post;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
use Flarum\Event\ConfigurePostTypes;
|
use Flarum\Event\ConfigurePostTypes;
|
||||||
use Flarum\Foundation\AbstractServiceProvider;
|
use Flarum\Foundation\AbstractServiceProvider;
|
||||||
|
|
||||||
class PostServiceProvider extends AbstractServiceProvider
|
class PostServiceProvider extends AbstractServiceProvider
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function register()
|
||||||
|
{
|
||||||
|
$this->app->extend('flarum.api.throttlers', function ($throttlers) {
|
||||||
|
$throttlers['postTimeout'] = function ($request) {
|
||||||
|
if (! in_array($request->getAttribute('routeName'), ['discussions.create', 'posts.create'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor = $request->getAttribute('actor');
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -28,6 +28,9 @@ class WithApiKeyTest extends TestCase
|
||||||
$this->adminUser(),
|
$this->adminUser(),
|
||||||
$this->normalUser(),
|
$this->normalUser(),
|
||||||
],
|
],
|
||||||
|
'group_permission' => [
|
||||||
|
['permission' => 'viewUserList', 'group_id' => 3]
|
||||||
|
],
|
||||||
'api_keys' => [],
|
'api_keys' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,13 +27,20 @@ class CreateTest extends TestCase
|
||||||
'posts' => [],
|
'posts' => [],
|
||||||
'users' => [
|
'users' => [
|
||||||
$this->adminUser(),
|
$this->adminUser(),
|
||||||
|
$this->normalUser(),
|
||||||
],
|
],
|
||||||
'groups' => [
|
'groups' => [
|
||||||
$this->adminGroup(),
|
$this->adminGroup(),
|
||||||
|
$this->memberGroup(),
|
||||||
],
|
],
|
||||||
'group_user' => [
|
'group_user' => [
|
||||||
['user_id' => 1, 'group_id' => 1],
|
['user_id' => 1, 'group_id' => 1],
|
||||||
|
['user_id' => 2, 'group_id' => 3],
|
||||||
],
|
],
|
||||||
|
'group_permission' => [
|
||||||
|
['permission' => 'viewDiscussions', 'group_id' => 3],
|
||||||
|
['permission' => 'startDiscussion', 'group_id' => 3],
|
||||||
|
]
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,4 +144,76 @@ class CreateTest extends TestCase
|
||||||
$this->assertEquals('test - too-obscure', $discussion->title);
|
$this->assertEquals('test - too-obscure', $discussion->title);
|
||||||
$this->assertEquals('test - too-obscure', Arr::get($data, 'data.attributes.title'));
|
$this->assertEquals('test - too-obscure', Arr::get($data, 'data.attributes.title'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function discussion_creation_limited_by_throttler()
|
||||||
|
{
|
||||||
|
$this->send(
|
||||||
|
$this->request('POST', '/api/discussions', [
|
||||||
|
'authenticatedAs' => 2,
|
||||||
|
'json' => [
|
||||||
|
'data' => [
|
||||||
|
'attributes' => [
|
||||||
|
'title' => 'test - too-obscure',
|
||||||
|
'content' => 'predetermined content for automated testing - too-obscure',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->send(
|
||||||
|
$this->request('POST', '/api/discussions', [
|
||||||
|
'authenticatedAs' => 2,
|
||||||
|
'json' => [
|
||||||
|
'data' => [
|
||||||
|
'attributes' => [
|
||||||
|
'title' => 'test - too-obscure',
|
||||||
|
'content' => 'Second predetermined content for automated testing - too-obscure',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(429, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function throttler_doesnt_apply_to_admin()
|
||||||
|
{
|
||||||
|
$this->send(
|
||||||
|
$this->request('POST', '/api/discussions', [
|
||||||
|
'authenticatedAs' => 1,
|
||||||
|
'json' => [
|
||||||
|
'data' => [
|
||||||
|
'attributes' => [
|
||||||
|
'title' => 'test - too-obscure',
|
||||||
|
'content' => 'predetermined content for automated testing - too-obscure',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->send(
|
||||||
|
$this->request('POST', '/api/discussions', [
|
||||||
|
'authenticatedAs' => 1,
|
||||||
|
'json' => [
|
||||||
|
'data' => [
|
||||||
|
'attributes' => [
|
||||||
|
'title' => 'test - too-obscure',
|
||||||
|
'content' => 'Second predetermined content for automated testing - too-obscure',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(201, $response->getStatusCode());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,4 +64,44 @@ class CreateTest extends TestCase
|
||||||
|
|
||||||
$this->assertEquals(201, $response->getStatusCode());
|
$this->assertEquals(201, $response->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function limited_by_throttler()
|
||||||
|
{
|
||||||
|
$this->send(
|
||||||
|
$this->request('POST', '/api/posts', [
|
||||||
|
'authenticatedAs' => 2,
|
||||||
|
'json' => [
|
||||||
|
'data' => [
|
||||||
|
'attributes' => [
|
||||||
|
'content' => 'reply with predetermined content for automated testing - too-obscure',
|
||||||
|
],
|
||||||
|
'relationships' => [
|
||||||
|
'discussion' => ['data' => ['id' => 1]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->send(
|
||||||
|
$this->request('POST', '/api/posts', [
|
||||||
|
'authenticatedAs' => 2,
|
||||||
|
'json' => [
|
||||||
|
'data' => [
|
||||||
|
'attributes' => [
|
||||||
|
'content' => 'Second reply with predetermined content for automated testing - too-obscure',
|
||||||
|
],
|
||||||
|
'relationships' => [
|
||||||
|
'discussion' => ['data' => ['id' => 1]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(429, $response->getStatusCode());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,12 @@ class MailTest extends TestCase
|
||||||
'users' => [
|
'users' => [
|
||||||
$this->adminUser(),
|
$this->adminUser(),
|
||||||
],
|
],
|
||||||
|
'groups' => [
|
||||||
|
$this->adminGroup(),
|
||||||
|
],
|
||||||
|
'group_user' => [
|
||||||
|
['user_id' => 1, 'group_id' => 1],
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?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\extenders;
|
||||||
|
|
||||||
|
use Flarum\Extend;
|
||||||
|
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
|
||||||
|
use Flarum\Tests\integration\TestCase;
|
||||||
|
|
||||||
|
class ThrottleApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use RetrievesAuthorizedUsers;
|
||||||
|
|
||||||
|
protected function prepDb(): void
|
||||||
|
{
|
||||||
|
$this->prepareDatabase([
|
||||||
|
'users' => [
|
||||||
|
$this->normalUser(),
|
||||||
|
],
|
||||||
|
'groups' => [
|
||||||
|
$this->memberGroup(),
|
||||||
|
],
|
||||||
|
'group_user' => [
|
||||||
|
['user_id' => 2, 'group_id' => 3],
|
||||||
|
],
|
||||||
|
'group_permission' => [
|
||||||
|
['permission' => 'viewDiscussions', 'group_id' => 3],
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function list_discussions_not_restricted_by_default()
|
||||||
|
{
|
||||||
|
$this->prepDb();
|
||||||
|
|
||||||
|
$response = $this->send($this->request('GET', '/api/discussions', ['authenticatedAs' => 2]));
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function list_discussions_can_be_restricted()
|
||||||
|
{
|
||||||
|
$this->extend((new Extend\ThrottleApi)->set('blockListDiscussions', function ($request) {
|
||||||
|
if ($request->getAttribute('routeName') === 'discussions.index') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
$response = $this->send($this->request('GET', '/api/discussions', ['authenticatedAs' => 2]));
|
||||||
|
|
||||||
|
$this->assertEquals(429, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function false_overrides_true_for_evaluating_throttlers()
|
||||||
|
{
|
||||||
|
$this->extend((new Extend\ThrottleApi)->set('blockListDiscussions', function ($request) {
|
||||||
|
if ($request->getAttribute('routeName') === 'discussions.index') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
$this->extend((new Extend\ThrottleApi)->set('blockListDiscussionsOverride', function ($request) {
|
||||||
|
if ($request->getAttribute('routeName') === 'discussions.index') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
$response = $this->send($this->request('GET', '/api/discussions', ['authenticatedAs' => 2]));
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,6 +26,9 @@ class UserTest extends TestCase
|
||||||
$this->adminUser(),
|
$this->adminUser(),
|
||||||
$this->normalUser(),
|
$this->normalUser(),
|
||||||
],
|
],
|
||||||
|
'group_permission' => [
|
||||||
|
['permission' => 'viewUserList', 'group_id' => 3]
|
||||||
|
],
|
||||||
'settings' => [
|
'settings' => [
|
||||||
['key' => 'display_name_driver', 'value' => 'custom'],
|
['key' => 'display_name_driver', 'value' => 'custom'],
|
||||||
],
|
],
|
||||||
|
|
Loading…
Reference in New Issue
Block a user