1
0
mirror of https://github.com/flarum/core.git synced 2025-10-12 07:24:27 +02:00

Overhaul sessions, tokens, and authentication

- Use cookies + CSRF token for API authentication in the default client. This mitigates potential XSS attacks by making the token unavailable to JavaScript. The Authorization header is still supported, but not used by default.
- Make sensitive/destructive actions (editing a user, permanently deleting anything, visiting the admin CP) require the user to re-enter their password if they haven't entered it in the last 30 minutes.
- Refactor and clean up the authentication middleware.
- Add an `onhide` hook to the Modal component. (+1 squashed commit)
This commit is contained in:
Toby Zerner
2015-11-05 16:17:00 +10:30
parent a1e1635019
commit 9896378b59
69 changed files with 1076 additions and 509 deletions

View File

@@ -1,80 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Api;
use Flarum\Database\AbstractModel;
use DateTime;
/**
* @property string $id
* @property int $user_id
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $expires_at
* @property \Flarum\Core\User|null $user
*/
class AccessToken extends AbstractModel
{
/**
* {@inheritdoc}
*/
protected $table = 'access_tokens';
/**
* Use a custom primary key for this model.
*
* @var bool
*/
public $incrementing = false;
/**
* {@inheritdoc}
*/
protected $dates = ['created_at', 'expires_at'];
/**
* Generate an access token for the specified user.
*
* @param int $userId
* @param int $minutes
* @return static
*/
public static function generate($userId, $minutes = 60)
{
$token = new static;
$token->id = str_random(40);
$token->user_id = $userId;
$token->created_at = time();
$token->expires_at = time() + $minutes * 60;
return $token;
}
/**
* Check that the token has not expired.
*
* @return bool
*/
public function isValid()
{
return $this->expires_at > new DateTime;
}
/**
* Define the relationship with the owner of this access token.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo('Flarum\Core\User');
}
}

View File

@@ -44,9 +44,13 @@ class ApiServiceProvider extends AbstractServiceProvider
$handler->registerHandler(new Handler\FloodingExceptionHandler);
$handler->registerHandler(new Handler\IlluminateValidationExceptionHandler);
$handler->registerHandler(new Handler\InvalidAccessTokenExceptionHandler);
$handler->registerHandler(new Handler\InvalidConfirmationTokenExceptionHandler);
$handler->registerHandler(new Handler\MethodNotAllowedExceptionHandler);
$handler->registerHandler(new Handler\ModelNotFoundExceptionHandler);
$handler->registerHandler(new Handler\PermissionDeniedExceptionHandler);
$handler->registerHandler(new Handler\RouteNotFoundExceptionHandler);
$handler->registerHandler(new Handler\TokenMismatchExceptionHandler);
$handler->registerHandler(new Handler\ValidationExceptionHandler);
$handler->registerHandler(new InvalidParameterExceptionHandler);
$handler->registerHandler(new FallbackExceptionHandler($this->app->inDebugMode()));

View File

@@ -12,6 +12,7 @@ namespace Flarum\Api;
use Flarum\Http\Controller\ControllerInterface;
use Flarum\Core\User;
use Flarum\Http\Session;
use Illuminate\Contracts\Container\Container;
use Exception;
use InvalidArgumentException;
@@ -43,14 +44,23 @@ class Client
* Execute the given API action class, pass the input and return its response.
*
* @param string|ControllerInterface $controller
* @param User $actor
* @param Session|User|null $session
* @param array $queryParams
* @param array $body
* @return \Psr\Http\Message\ResponseInterface
*/
public function send($controller, User $actor, array $queryParams = [], array $body = [])
public function send($controller, $session, array $queryParams = [], array $body = [])
{
$request = ServerRequestFactory::fromGlobals(null, $queryParams, $body)->withAttribute('actor', $actor);
$request = ServerRequestFactory::fromGlobals(null, $queryParams, $body);
if ($session instanceof Session) {
$request = $request->withAttribute('session', $session);
$actor = $session->user;
} else {
$actor = $session;
}
$request = $request->withAttribute('actor', $actor);
if (is_string($controller)) {
$controller = $this->container->make($controller);

View File

@@ -1,29 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Api\Command;
class GenerateAccessToken
{
/**
* The ID of the user to generate an access token for.
*
* @var int
*/
public $userId;
/**
* @param int $userId The ID of the user to generate an access token for.
*/
public function __construct($userId)
{
$this->userId = $userId;
}
}

View File

@@ -1,30 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Api\Command;
use Flarum\Api\AccessToken;
use Flarum\Api\Command\GenerateAccessToken;
class GenerateAccessTokenHandler
{
/**
* @param GenerateAccessToken $command
* @return AccessToken
*/
public function handle(GenerateAccessToken $command)
{
$token = AccessToken::generate($command->userId);
$token->save();
return $token;
}
}

View File

@@ -10,12 +10,15 @@
namespace Flarum\Api\Controller;
use Flarum\Core\Access\AssertPermissionTrait;
use Flarum\Core\Command\DeleteDiscussion;
use Illuminate\Contracts\Bus\Dispatcher;
use Psr\Http\Message\ServerRequestInterface;
class DeleteDiscussionController extends AbstractDeleteController
{
use AssertPermissionTrait;
/**
* @var Dispatcher
*/
@@ -38,6 +41,8 @@ class DeleteDiscussionController extends AbstractDeleteController
$actor = $request->getAttribute('actor');
$input = $request->getParsedBody();
$this->assertSudo($request);
$this->bus->dispatch(
new DeleteDiscussion($id, $actor, $input)
);

View File

@@ -10,12 +10,15 @@
namespace Flarum\Api\Controller;
use Flarum\Core\Access\AssertPermissionTrait;
use Flarum\Core\Command\DeleteGroup;
use Illuminate\Contracts\Bus\Dispatcher;
use Psr\Http\Message\ServerRequestInterface;
class DeleteGroupController extends AbstractDeleteController
{
use AssertPermissionTrait;
/**
* @var Dispatcher
*/
@@ -34,6 +37,8 @@ class DeleteGroupController extends AbstractDeleteController
*/
protected function delete(ServerRequestInterface $request)
{
$this->assertSudo($request);
$this->bus->dispatch(
new DeleteGroup(array_get($request->getQueryParams(), 'id'), $request->getAttribute('actor'))
);

View File

@@ -10,12 +10,15 @@
namespace Flarum\Api\Controller;
use Flarum\Core\Access\AssertPermissionTrait;
use Flarum\Core\Command\DeletePost;
use Illuminate\Contracts\Bus\Dispatcher;
use Psr\Http\Message\ServerRequestInterface;
class DeletePostController extends AbstractDeleteController
{
use AssertPermissionTrait;
/**
* @var Dispatcher
*/
@@ -34,6 +37,8 @@ class DeletePostController extends AbstractDeleteController
*/
protected function delete(ServerRequestInterface $request)
{
$this->assertSudo($request);
$this->bus->dispatch(
new DeletePost(array_get($request->getQueryParams(), 'id'), $request->getAttribute('actor'))
);

View File

@@ -10,12 +10,15 @@
namespace Flarum\Api\Controller;
use Flarum\Core\Access\AssertPermissionTrait;
use Flarum\Core\Command\DeleteUser;
use Illuminate\Contracts\Bus\Dispatcher;
use Psr\Http\Message\ServerRequestInterface;
class DeleteUserController extends AbstractDeleteController
{
use AssertPermissionTrait;
/**
* @var Dispatcher
*/
@@ -34,6 +37,8 @@ class DeleteUserController extends AbstractDeleteController
*/
protected function delete(ServerRequestInterface $request)
{
$this->assertSudo($request);
$this->bus->dispatch(
new DeleteUser(array_get($request->getQueryParams(), 'id'), $request->getAttribute('actor'))
);

View File

@@ -25,7 +25,7 @@ class SetPermissionController implements ControllerInterface
*/
public function handle(ServerRequestInterface $request)
{
$this->assertAdmin($request->getAttribute('actor'));
$this->assertAdminAndSudo($request);
$body = $request->getParsedBody();
$permission = array_get($body, 'permission');

View File

@@ -47,7 +47,7 @@ class SetSettingsController implements ControllerInterface
*/
public function handle(ServerRequestInterface $request)
{
$this->assertAdmin($request->getAttribute('actor'));
$this->assertAdminAndSudo($request);
$settings = $request->getParsedBody();

View File

@@ -10,11 +10,10 @@
namespace Flarum\Api\Controller;
use Flarum\Api\Command\GenerateAccessToken;
use Flarum\Core\Exception\PermissionDeniedException;
use Flarum\Core\Repository\UserRepository;
use Flarum\Event\UserEmailChangeWasRequested;
use Flarum\Http\Controller\ControllerInterface;
use Flarum\Http\Session;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Psr\Http\Message\ServerRequestInterface;
@@ -65,19 +64,13 @@ class TokenController implements ControllerInterface
throw new PermissionDeniedException;
}
if (! $user->is_activated) {
$this->events->fire(new UserEmailChangeWasRequested($user, $user->email));
$session = $request->getAttribute('session') ?: Session::generate($user);
$session->assign($user)->regenerateId()->renew()->save();
return new JsonResponse(['emailConfirmationRequired' => $user->email], 401);
}
$token = $this->bus->dispatch(
new GenerateAccessToken($user->id)
);
return new JsonResponse([
'token' => $token->id,
return (new JsonResponse([
'token' => $session->id,
'userId' => $user->id
]);
]))
->withHeader('X-CSRF-Token', $session->csrf_token);
}
}

View File

@@ -33,7 +33,7 @@ class UninstallExtensionController extends AbstractDeleteController
protected function delete(ServerRequestInterface $request)
{
$this->assertAdmin($request->getAttribute('actor'));
$this->assertAdminAndSudo($request);
$name = array_get($request->getQueryParams(), 'name');

View File

@@ -38,7 +38,7 @@ class UpdateExtensionController implements ControllerInterface
*/
public function handle(ServerRequestInterface $request)
{
$this->assertAdmin($request->getAttribute('actor'));
$this->assertAdminAndSudo($request);
$enabled = array_get($request->getParsedBody(), 'enabled');
$name = array_get($request->getQueryParams(), 'name');

View File

@@ -10,6 +10,7 @@
namespace Flarum\Api\Controller;
use Flarum\Core\Access\AssertPermissionTrait;
use Flarum\Core\Command\EditUser;
use Illuminate\Contracts\Bus\Dispatcher;
use Psr\Http\Message\ServerRequestInterface;
@@ -17,6 +18,8 @@ use Tobscure\JsonApi\Document;
class UpdateUserController extends AbstractResourceController
{
use AssertPermissionTrait;
/**
* {@inheritdoc}
*/
@@ -49,6 +52,8 @@ class UpdateUserController extends AbstractResourceController
$actor = $request->getAttribute('actor');
$data = array_get($request->getParsedBody(), 'data', []);
$this->assertSudo($request);
return $this->bus->dispatch(
new EditUser($id, $actor, $data)
);

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Api\Exception;
use Exception;
class InvalidAccessTokenException extends Exception
{
}

View File

@@ -31,7 +31,10 @@ class FloodingExceptionHandler implements ExceptionHandlerInterface
public function handle(Exception $e)
{
$status = 429;
$error = [];
$error = [
'status' => (string) $status,
'code' => 'too_many_requests'
];
return new ResponseBag($status, [$error]);
}

View File

@@ -44,8 +44,10 @@ class IlluminateValidationExceptionHandler implements ExceptionHandlerInterface
{
$errors = array_map(function ($field, $messages) {
return [
'status' => '422',
'code' => 'validation_error',
'detail' => implode("\n", $messages),
'source' => ['pointer' => '/data/attributes/' . $field],
'source' => ['pointer' => "/data/attributes/$field"]
];
}, array_keys($errors), $errors);

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Api\Handler;
use Exception;
use Flarum\Api\Exception\InvalidAccessTokenException;
use Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface;
use Tobscure\JsonApi\Exception\Handler\ResponseBag;
class InvalidAccessTokenExceptionHandler implements ExceptionHandlerInterface
{
/**
* {@inheritdoc}
*/
public function manages(Exception $e)
{
return $e instanceof InvalidAccessTokenException;
}
/**
* {@inheritdoc}
*/
public function handle(Exception $e)
{
$status = 401;
$error = [
'status' => (string) $status,
'code' => 'invalid_access_token'
];
return new ResponseBag($status, [$error]);
}
}

View File

@@ -31,7 +31,10 @@ class InvalidConfirmationTokenExceptionHandler implements ExceptionHandlerInterf
public function handle(Exception $e)
{
$status = 403;
$error = ['code' => 'invalid_confirmation_token'];
$error = [
'status' => (string) $status,
'code' => 'invalid_confirmation_token'
];
return new ResponseBag($status, [$error]);
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Api\Handler;
use Exception;
use Flarum\Http\Exception\MethodNotAllowedException;
use Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface;
use Tobscure\JsonApi\Exception\Handler\ResponseBag;
class MethodNotAllowedExceptionHandler implements ExceptionHandlerInterface
{
/**
* {@inheritdoc}
*/
public function manages(Exception $e)
{
return $e instanceof MethodNotAllowedException;
}
/**
* {@inheritdoc}
*/
public function handle(Exception $e)
{
$status = 405;
$error = [
'status' => (string) $status,
'code' => 'method_not_allowed'
];
return new ResponseBag($status, [$error]);
}
}

View File

@@ -11,6 +11,7 @@
namespace Flarum\Api\Handler;
use Exception;
use Flarum\Http\Exception\RouteNotFoundException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface;
use Tobscure\JsonApi\Exception\Handler\ResponseBag;
@@ -31,7 +32,10 @@ class ModelNotFoundExceptionHandler implements ExceptionHandlerInterface
public function handle(Exception $e)
{
$status = 404;
$error = [];
$error = [
'status' => '404',
'code' => 'resource_not_found'
];
return new ResponseBag($status, [$error]);
}

View File

@@ -31,7 +31,10 @@ class PermissionDeniedExceptionHandler implements ExceptionHandlerInterface
public function handle(Exception $e)
{
$status = 401;
$error = [];
$error = [
'status' => (string) $status,
'code' => 'permission_denied'
];
return new ResponseBag($status, [$error]);
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Api\Handler;
use Exception;
use Flarum\Http\Exception\RouteNotFoundException;
use Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface;
use Tobscure\JsonApi\Exception\Handler\ResponseBag;
class RouteNotFoundExceptionHandler implements ExceptionHandlerInterface
{
/**
* {@inheritdoc}
*/
public function manages(Exception $e)
{
return $e instanceof RouteNotFoundException;
}
/**
* {@inheritdoc}
*/
public function handle(Exception $e)
{
$status = 404;
$error = [
'status' => (string) $status,
'code' => 'route_not_found'
];
return new ResponseBag($status, [$error]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Api\Handler;
use Exception;
use Flarum\Http\Exception\TokenMismatchException;
use Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface;
use Tobscure\JsonApi\Exception\Handler\ResponseBag;
class TokenMismatchExceptionHandler implements ExceptionHandlerInterface
{
/**
* {@inheritdoc}
*/
public function manages(Exception $e)
{
return $e instanceof TokenMismatchException;
}
/**
* {@inheritdoc}
*/
public function handle(Exception $e)
{
$status = 400;
$error = [
'status' => (string) $status,
'code' => 'csrf_token_mismatch'
];
return new ResponseBag($status, [$error]);
}
}

View File

@@ -33,10 +33,13 @@ class ValidationExceptionHandler implements ExceptionHandlerInterface
$status = 422;
$messages = $e->getMessages();
$errors = array_map(function ($path, $detail) {
$source = ['pointer' => '/data/attributes/' . $path];
return compact('source', 'detail');
$errors = array_map(function ($path, $detail) use ($status) {
return [
'status' => (string) $status,
'code' => 'validation_error',
'detail' => $detail,
'source' => ['pointer' => "/data/attributes/$path"]
];
}, array_keys($messages), $messages);
return new ResponseBag($status, $errors);

View File

@@ -1,93 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Flarum\Api\Middleware;
use Flarum\Api\AccessToken;
use Flarum\Api\ApiKey;
use Flarum\Core\Guest;
use Flarum\Core\User;
use Flarum\Locale\LocaleManager;
use Illuminate\Contracts\Container\Container;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Stratigility\MiddlewareInterface;
class AuthenticateWithHeader implements MiddlewareInterface
{
/**
* @var string
*/
protected $prefix = 'Token ';
/**
* @var LocaleManager
*/
protected $locales;
/**
* @param LocaleManager $locales
*/
public function __construct(LocaleManager $locales)
{
$this->locales = $locales;
}
/**
* {@inheritdoc}
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
$request = $this->logIn($request);
return $out ? $out($request, $response) : $response;
}
/**
* @param Request $request
* @return Request
*/
protected function logIn(Request $request)
{
$header = $request->getHeaderLine('authorization');
$parts = explode(';', $header);
$actor = new Guest;
if (isset($parts[0]) && starts_with($parts[0], $this->prefix)) {
$token = substr($parts[0], strlen($this->prefix));
if (($accessToken = AccessToken::find($token)) && $accessToken->isValid()) {
$actor = $accessToken->user;
$actor->updateLastSeen()->save();
} elseif (isset($parts[1]) && ($apiKey = ApiKey::valid($token))) {
$userParts = explode('=', trim($parts[1]));
if (isset($userParts[0]) && $userParts[0] === 'userId') {
$actor = User::find($userParts[1]);
}
}
}
if ($actor->exists) {
$locale = $actor->getPreference('locale');
} else {
$locale = array_get($request->getCookieParams(), 'locale');
}
if ($locale && $this->locales->hasLocale($locale)) {
$this->locales->setLocale($locale);
}
return $request->withAttribute('actor', $actor ?: new Guest);
}
}

View File

@@ -28,10 +28,12 @@ class Server extends AbstractServer
$apiPath = parse_url($app->url('api'), PHP_URL_PATH);
if ($app->isInstalled() && $app->isUpToDate()) {
$pipe->pipe($apiPath, $app->make('Flarum\Http\Middleware\AuthenticateWithCookie'));
$pipe->pipe($apiPath, $app->make('Flarum\Api\Middleware\AuthenticateWithHeader'));
$pipe->pipe($apiPath, $app->make('Flarum\Http\Middleware\ParseJsonBody'));
$pipe->pipe($apiPath, $app->make('Flarum\Api\Middleware\FakeHttpMethods'));
$pipe->pipe($apiPath, $app->make('Flarum\Http\Middleware\AuthorizeWithCookie'));
$pipe->pipe($apiPath, $app->make('Flarum\Http\Middleware\AuthorizeWithHeader'));
$pipe->pipe($apiPath, $app->make('Flarum\Http\Middleware\StartSession'));
$pipe->pipe($apiPath, $app->make('Flarum\Http\Middleware\SetLocale'));
$pipe->pipe($apiPath, $app->make('Flarum\Http\Middleware\DispatchRoute', ['routes' => $app->make('flarum.api.routes')]));
$pipe->pipe($apiPath, $app->make('Flarum\Api\Middleware\HandleErrors'));
} else {