From 9896378b59b3f1d260049c162abe5786c4be8ed0 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 5 Nov 2015 16:17:00 +1030 Subject: [PATCH] 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) --- CHANGELOG.md | 5 + js/forum/src/components/ChangeEmailModal.js | 7 + js/forum/src/components/LogInModal.js | 6 +- js/forum/src/components/Post.js | 4 +- js/forum/src/utils/DiscussionControls.js | 13 +- js/forum/src/utils/PostControls.js | 15 +- js/lib/App.js | 32 +++- js/lib/Model.js | 6 +- js/lib/Session.js | 24 +-- js/lib/components/ConfirmPasswordModal.js | 73 +++++++++ js/lib/components/Modal.js | 5 +- js/lib/components/ModalManager.js | 4 + js/lib/initializers/preload.js | 4 +- less/forum/Post.less | 3 + ..._12_03_010529_drop_access_tokens_table.php | 32 ++++ ...015_12_03_010610_create_sessions_table.php | 34 +++++ src/Admin/AdminServiceProvider.php | 2 + .../Middleware/RequireAdministrateAbility.php | 51 +++++-- src/Admin/Server.php | 5 +- src/Api/AccessToken.php | 80 ---------- src/Api/ApiServiceProvider.php | 4 + src/Api/Client.php | 16 +- src/Api/Command/GenerateAccessToken.php | 29 ---- .../Command/GenerateAccessTokenHandler.php | 30 ---- .../Controller/DeleteDiscussionController.php | 5 + src/Api/Controller/DeleteGroupController.php | 5 + src/Api/Controller/DeletePostController.php | 5 + src/Api/Controller/DeleteUserController.php | 5 + .../Controller/SetPermissionController.php | 2 +- src/Api/Controller/SetSettingsController.php | 2 +- src/Api/Controller/TokenController.php | 21 +-- .../UninstallExtensionController.php | 2 +- .../Controller/UpdateExtensionController.php | 2 +- src/Api/Controller/UpdateUserController.php | 5 + .../Exception/InvalidAccessTokenException.php | 17 +++ src/Api/Handler/FloodingExceptionHandler.php | 5 +- .../IlluminateValidationExceptionHandler.php | 4 +- .../InvalidAccessTokenExceptionHandler.php | 41 +++++ ...validConfirmationTokenExceptionHandler.php | 5 +- .../MethodNotAllowedExceptionHandler.php | 41 +++++ .../Handler/ModelNotFoundExceptionHandler.php | 6 +- .../PermissionDeniedExceptionHandler.php | 5 +- .../Handler/RouteNotFoundExceptionHandler.php | 41 +++++ .../Handler/TokenMismatchExceptionHandler.php | 41 +++++ .../Handler/ValidationExceptionHandler.php | 11 +- src/Api/Middleware/AuthenticateWithHeader.php | 93 ------------ src/Api/Server.php | 6 +- src/Core/Access/AssertPermissionTrait.php | 28 +++- src/Core/User.php | 8 +- src/Event/UserLoggedIn.php | 7 +- .../Controller/AuthenticateUserTrait.php | 16 +- .../Controller/ConfirmEmailController.php | 13 +- ...oginController.php => LogInController.php} | 27 ++-- ...outController.php => LogOutController.php} | 21 +-- src/Forum/Controller/RegisterController.php | 24 +-- .../Controller/WriteRememberCookieTrait.php | 42 ------ src/Forum/Server.php | 4 +- src/Http/Controller/ClientView.php | 4 +- .../Exception/MethodNotAllowedException.php | 22 +++ src/Http/Exception/TokenMismatchException.php | 18 +++ .../Middleware/AuthenticateWithCookie.php | 75 +++------- .../Middleware/AuthenticateWithHeader.php | 61 ++++++++ src/Http/Middleware/DispatchRoute.php | 9 +- src/Http/Middleware/SetLocale.php | 52 +++++++ src/Http/Middleware/StartSession.php | 81 ++++++++++ src/Http/Session.php | 140 ++++++++++++++++++ src/Http/WriteSessionCookieTrait.php | 29 ++++ src/Install/Controller/InstallController.php | 18 +-- views/login.blade.php | 32 ++++ 69 files changed, 1076 insertions(+), 509 deletions(-) create mode 100644 js/lib/components/ConfirmPasswordModal.js create mode 100644 migrations/2015_12_03_010529_drop_access_tokens_table.php create mode 100644 migrations/2015_12_03_010610_create_sessions_table.php delete mode 100644 src/Api/AccessToken.php delete mode 100644 src/Api/Command/GenerateAccessToken.php delete mode 100644 src/Api/Command/GenerateAccessTokenHandler.php create mode 100644 src/Api/Exception/InvalidAccessTokenException.php create mode 100644 src/Api/Handler/InvalidAccessTokenExceptionHandler.php create mode 100644 src/Api/Handler/MethodNotAllowedExceptionHandler.php create mode 100644 src/Api/Handler/RouteNotFoundExceptionHandler.php create mode 100644 src/Api/Handler/TokenMismatchExceptionHandler.php delete mode 100644 src/Api/Middleware/AuthenticateWithHeader.php rename src/Forum/Controller/{LoginController.php => LogInController.php} (69%) rename src/Forum/Controller/{LogoutController.php => LogOutController.php} (68%) delete mode 100644 src/Forum/Controller/WriteRememberCookieTrait.php create mode 100644 src/Http/Exception/MethodNotAllowedException.php create mode 100644 src/Http/Exception/TokenMismatchException.php create mode 100644 src/Http/Middleware/AuthenticateWithHeader.php create mode 100644 src/Http/Middleware/SetLocale.php create mode 100644 src/Http/Middleware/StartSession.php create mode 100644 src/Http/Session.php create mode 100644 src/Http/WriteSessionCookieTrait.php create mode 100644 views/login.blade.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d418f15a..e041967c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to Flarum and its bundled extensions will be documented in t This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Added +- Improve security by using HTTP-only cookie + CSRF token for API authentication +- Require user to re-enter password after 30 mins when performing sensitive/destructive actions +- Add `onhide` hook to Modal component + ### Fixed - Fix error when sorting discussions by "oldest" (#627) diff --git a/js/forum/src/components/ChangeEmailModal.js b/js/forum/src/components/ChangeEmailModal.js index 35b430889..eef95577f 100644 --- a/js/forum/src/components/ChangeEmailModal.js +++ b/js/forum/src/components/ChangeEmailModal.js @@ -81,10 +81,17 @@ export default class ChangeEmailModal extends Modal { return; } + const oldEmail = app.session.user.email(); + this.loading = true; app.session.user.save({email: this.email()}, {errorHandler: this.onerror.bind(this)}) .then(() => this.success = true) .finally(this.loaded.bind(this)); + + // The save method will update the cached email address on the user model... + // But in the case of a "sudo" password prompt, we'll still want to have + // the old email address on file for the purposes of logging in. + app.session.user.pushAttributes({email: oldEmail}); } } diff --git a/js/forum/src/components/LogInModal.js b/js/forum/src/components/LogInModal.js index a2870936e..8395061f8 100644 --- a/js/forum/src/components/LogInModal.js +++ b/js/forum/src/components/LogInModal.js @@ -124,8 +124,10 @@ export default class LogInModal extends Modal { const email = this.email(); const password = this.password(); - app.session.login(email, password, {errorHandler: this.onerror.bind(this)}) - .catch(this.loaded.bind(this)); + app.session.login(email, password, {errorHandler: this.onerror.bind(this)}).then( + () => window.location.reload(), + this.loaded.bind(this) + ); } onerror(error) { diff --git a/js/forum/src/components/Post.js b/js/forum/src/components/Post.js index ba16bf0bf..e760a5c71 100644 --- a/js/forum/src/components/Post.js +++ b/js/forum/src/components/Post.js @@ -18,6 +18,8 @@ import ItemList from 'flarum/utils/ItemList'; */ export default class Post extends Component { init() { + this.loading = false; + /** * Set up a subtree retainer so that the post will not be redrawn * unless new data comes in. @@ -37,7 +39,7 @@ export default class Post extends Component { view() { const attrs = this.attrs(); - attrs.className = 'Post ' + (attrs.className || ''); + attrs.className = 'Post ' + (this.loading ? 'Post--loading ' : '') + (attrs.className || ''); return (
diff --git a/js/forum/src/utils/DiscussionControls.js b/js/forum/src/utils/DiscussionControls.js index d62c3e4e5..aa61383d2 100644 --- a/js/forum/src/utils/DiscussionControls.js +++ b/js/forum/src/utils/DiscussionControls.js @@ -217,18 +217,19 @@ export default { */ deleteAction() { if (confirm(extractText(app.translator.trans('core.forum.discussion_controls.delete_confirmation')))) { - // If there is a discussion list in the cache, remove this discussion. - if (app.cache.discussionList) { - app.cache.discussionList.removeDiscussion(this); - } - // If we're currently viewing the discussion that was deleted, go back // to the previous page. if (app.viewingDiscussion(this)) { app.history.back(); } - return this.delete(); + return this.delete().then(() => { + // If there is a discussion list in the cache, remove this discussion. + if (app.cache.discussionList) { + app.cache.discussionList.removeDiscussion(this); + m.redraw(); + } + }); } }, diff --git a/js/forum/src/utils/PostControls.js b/js/forum/src/utils/PostControls.js index afe6a1ab3..ff6b79d1a 100644 --- a/js/forum/src/utils/PostControls.js +++ b/js/forum/src/utils/PostControls.js @@ -78,7 +78,7 @@ export default { * @return {ItemList} * @protected */ - destructiveControls(post) { + destructiveControls(post, context) { const items = new ItemList(); if (post.contentType() === 'comment' && !post.isHidden()) { @@ -101,7 +101,7 @@ export default { items.add('delete', Button.component({ icon: 'times', children: app.translator.trans('core.forum.post_controls.delete_forever_button'), - onclick: this.deleteAction.bind(post) + onclick: this.deleteAction.bind(post, context) })); } } @@ -144,9 +144,14 @@ export default { * * @return {Promise} */ - deleteAction() { - this.discussion().removePost(this.id()); + deleteAction(context) { + if (context) context.loading = true; - return this.delete(); + return this.delete().then(() => { + this.discussion().removePost(this.id()); + }).finally(() => { + if (context) context.loading = false; + m.redraw(); + }); } }; diff --git a/js/lib/App.js b/js/lib/App.js index 03b74f3f0..5fb5fc68c 100644 --- a/js/lib/App.js +++ b/js/lib/App.js @@ -2,6 +2,7 @@ import ItemList from 'flarum/utils/ItemList'; import Alert from 'flarum/components/Alert'; import Button from 'flarum/components/Button'; import RequestErrorModal from 'flarum/components/RequestErrorModal'; +import ConfirmPasswordModal from 'flarum/components/ConfirmPasswordModal'; import Translator from 'flarum/Translator'; import extract from 'flarum/utils/extract'; import patchMithril from 'flarum/utils/patchMithril'; @@ -182,14 +183,17 @@ export default class App { * @return {Promise} * @public */ - request(options) { + request(originalOptions) { + const options = Object.assign({}, originalOptions); + // Set some default options if they haven't been overridden. We want to // authenticate all requests with the session token. We also want all // requests to run asynchronously in the background, so that they don't // prevent redraws from occurring. - options.config = options.config || this.session.authorize.bind(this.session); options.background = options.background || true; + extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken)); + // If the method is something like PATCH or DELETE, which not all servers // support, then we'll send it as a POST request with a the intended method // specified in the X-Fake-Http-Method header. @@ -218,7 +222,7 @@ export default class App { if (original) { responseText = original(xhr.responseText); } else { - responseText = xhr.responseText.length > 0 ? xhr.responseText : null; + responseText = xhr.responseText || null; } const status = xhr.status; @@ -227,6 +231,11 @@ export default class App { throw new RequestError(status, responseText, options, xhr); } + if (xhr.getResponseHeader) { + const csrfToken = xhr.getResponseHeader('X-CSRF-Token'); + if (csrfToken) app.session.csrfToken = csrfToken; + } + try { return JSON.parse(responseText); } catch (e) { @@ -238,9 +247,20 @@ export default class App { // Now make the request. If it's a failure, inspect the error that was // returned and show an alert containing its contents. - return m.request(options).then(null, error => { + const deferred = m.deferred(); + + m.request(options).then(response => deferred.resolve(response), error => { this.requestError = error; + if (error.response && error.response.errors && error.response.errors[0] && error.response.errors[0].code === 'invalid_access_token') { + this.modal.show(new ConfirmPasswordModal({ + deferredRequest: originalOptions, + deferred, + error + })); + return; + } + let children; switch (error.status) { @@ -283,8 +303,10 @@ export default class App { this.alerts.show(error.alert); } - throw error; + deferred.reject(error); }); + + return deferred.promise; } /** diff --git a/js/lib/Model.js b/js/lib/Model.js index ad16ceb9b..3a6ba4053 100644 --- a/js/lib/Model.js +++ b/js/lib/Model.js @@ -150,7 +150,7 @@ export default class Model { // Before we update the model's data, we should make a copy of the model's // old data so that we can revert back to it if something goes awry during // persistence. - const oldData = JSON.parse(JSON.stringify(this.data)); + const oldData = this.copyData(); this.pushData(data); @@ -209,6 +209,10 @@ export default class Model { return '/' + this.data.type + (this.exists ? '/' + this.data.id : ''); } + copyData() { + return JSON.parse(JSON.stringify(this.data)); + } + /** * Generate a function which returns the value of the given attribute. * diff --git a/js/lib/Session.js b/js/lib/Session.js index 5053c955d..19f900fda 100644 --- a/js/lib/Session.js +++ b/js/lib/Session.js @@ -3,7 +3,7 @@ * to the current authenticated user, and provides methods to log in/out. */ export default class Session { - constructor(token, user) { + constructor(user, csrfToken) { /** * The current authenticated user. * @@ -13,12 +13,12 @@ export default class Session { this.user = user; /** - * The token that was used for authentication. + * The CSRF token. * * @type {String|null} * @public */ - this.token = token; + this.csrfToken = csrfToken; } /** @@ -35,8 +35,7 @@ export default class Session { method: 'POST', url: app.forum.attribute('baseUrl') + '/login', data: {identification, password} - }, options)) - .then(() => window.location.reload()); + }, options)); } /** @@ -45,19 +44,6 @@ export default class Session { * @public */ logout() { - window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.token; - } - - /** - * Apply an authorization header with the current token to the given - * XMLHttpRequest object. - * - * @param {XMLHttpRequest} xhr - * @public - */ - authorize(xhr) { - if (this.token) { - xhr.setRequestHeader('Authorization', 'Token ' + this.token); - } + window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken; } } diff --git a/js/lib/components/ConfirmPasswordModal.js b/js/lib/components/ConfirmPasswordModal.js new file mode 100644 index 000000000..31ac07b91 --- /dev/null +++ b/js/lib/components/ConfirmPasswordModal.js @@ -0,0 +1,73 @@ +import Modal from 'flarum/components/Modal'; +import Button from 'flarum/components/Button'; +import extractText from 'flarum/utils/extractText'; + +export default class ConfirmPasswordModal extends Modal { + init() { + super.init(); + + this.password = m.prop(''); + } + + className() { + return 'ConfirmPasswordModal Modal--small'; + } + + title() { + return app.translator.trans('core.forum.confirm_password.title'); + } + + content() { + return ( +
+
+
+ +
+ +
+ +
+
+
+ ); + } + + onsubmit(e) { + e.preventDefault(); + + this.loading = true; + + app.session.login(app.session.user.email(), this.password(), {errorHandler: this.onerror.bind(this)}) + .then(() => { + this.success = true; + this.hide(); + app.request(this.props.deferredRequest).then(response => this.props.deferred.resolve(response), response => this.props.deferred.reject(response)); + }) + .catch(this.loaded.bind(this)); + } + + onerror(error) { + if (error.status === 401) { + error.alert.props.children = app.translator.trans('core.forum.log_in.invalid_login_message'); + } + + super.onerror(error); + } + + onhide() { + if (this.success) return; + + this.props.deferred.reject(this.props.error); + } +} diff --git a/js/lib/components/Modal.js b/js/lib/components/Modal.js index 6f76bdf40..593085ad3 100644 --- a/js/lib/components/Modal.js +++ b/js/lib/components/Modal.js @@ -98,7 +98,10 @@ export default class Modal extends Component { * Focus on the first input when the modal is ready to be used. */ onready() { - this.$('form :input:first').focus().select(); + this.$('form').find('input, select, textarea').first().focus().select(); + } + + onhide() { } /** diff --git a/js/lib/components/ModalManager.js b/js/lib/components/ModalManager.js index 35eb5a445..6277b23ba 100644 --- a/js/lib/components/ModalManager.js +++ b/js/lib/components/ModalManager.js @@ -77,6 +77,10 @@ export default class ModalManager extends Component { * @protected */ clear() { + if (this.component) { + this.component.onhide(); + } + this.component = null; m.lazyRedraw(); diff --git a/js/lib/initializers/preload.js b/js/lib/initializers/preload.js index b170ac985..31d2d814f 100644 --- a/js/lib/initializers/preload.js +++ b/js/lib/initializers/preload.js @@ -18,7 +18,7 @@ export default function preload(app) { app.forum = app.store.getById('forums', 1); app.session = new Session( - app.preload.session.token, - app.store.getById('users', app.preload.session.userId) + app.store.getById('users', app.preload.session.userId), + app.preload.session.csrfToken ); } diff --git a/less/forum/Post.less b/less/forum/Post.less index da5fcb6f8..85f354a61 100644 --- a/less/forum/Post.less +++ b/less/forum/Post.less @@ -167,6 +167,9 @@ color: @muted-more-color; } } +.Post--loading { + opacity: 0.5; +} .PostMeta { display: inline; } diff --git a/migrations/2015_12_03_010529_drop_access_tokens_table.php b/migrations/2015_12_03_010529_drop_access_tokens_table.php new file mode 100644 index 000000000..d2739dda4 --- /dev/null +++ b/migrations/2015_12_03_010529_drop_access_tokens_table.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Core\Migration; + +use Flarum\Database\AbstractMigration; +use Illuminate\Database\Schema\Blueprint; + +class DropAccessTokensTable extends AbstractMigration +{ + public function up() + { + $this->schema->drop('access_tokens'); + } + + public function down() + { + $this->schema->create('access_tokens', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->integer('user_id')->unsigned(); + $table->timestamp('created_at'); + $table->timestamp('expires_at'); + }); + } +} diff --git a/migrations/2015_12_03_010610_create_sessions_table.php b/migrations/2015_12_03_010610_create_sessions_table.php new file mode 100644 index 000000000..9c37e07ba --- /dev/null +++ b/migrations/2015_12_03_010610_create_sessions_table.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Core\Migration; + +use Flarum\Database\AbstractMigration; +use Illuminate\Database\Schema\Blueprint; + +class CreateSessionsTable extends AbstractMigration +{ + public function up() + { + $this->schema->create('sessions', function (Blueprint $table) { + $table->string('id', 40)->primary(); + $table->integer('user_id')->unsigned()->nullable(); + $table->string('csrf_token', 40); + $table->integer('last_activity'); + $table->integer('duration'); + $table->dateTime('sudo_expiry_time'); + }); + } + + public function down() + { + $this->schema->drop('sessions'); + } +} diff --git a/src/Admin/AdminServiceProvider.php b/src/Admin/AdminServiceProvider.php index 114c0d7cc..ea41ef82b 100644 --- a/src/Admin/AdminServiceProvider.php +++ b/src/Admin/AdminServiceProvider.php @@ -42,6 +42,8 @@ class AdminServiceProvider extends AbstractServiceProvider */ public function boot() { + $this->loadViewsFrom(__DIR__.'/../../views', 'flarum.admin'); + $this->flushAssetsWhenThemeChanged(); $this->flushAssetsWhenExtensionsChanged(); diff --git a/src/Admin/Middleware/RequireAdministrateAbility.php b/src/Admin/Middleware/RequireAdministrateAbility.php index db91ec3c4..ac0268f87 100644 --- a/src/Admin/Middleware/RequireAdministrateAbility.php +++ b/src/Admin/Middleware/RequireAdministrateAbility.php @@ -10,26 +10,39 @@ namespace Flarum\Admin\Middleware; -use Flarum\Core\Access\Gate; +use Exception; +use Flarum\Core\Access\AssertPermissionTrait; +use Flarum\Forum\Controller\LogInController; use Illuminate\Contracts\Container\Container; +use Illuminate\Contracts\View\Factory; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Flarum\Core\Exception\PermissionDeniedException; +use Zend\Diactoros\Response\HtmlResponse; +use Zend\Diactoros\Response\RedirectResponse; use Zend\Stratigility\MiddlewareInterface; class RequireAdministrateAbility implements MiddlewareInterface { - /** - * @var Gate - */ - protected $gate; + use AssertPermissionTrait; /** - * @param Gate $gate + * @var LogInController */ - public function __construct(Gate $gate) + private $logInController; + + /** + * @var Factory + */ + private $view; + + /** + * @param LogInController $logInController + * @param Factory $view + */ + public function __construct(LogInController $logInController, Factory $view) { - $this->gate = $gate; + $this->logInController = $logInController; + $this->view = $view; } /** @@ -37,10 +50,24 @@ class RequireAdministrateAbility implements MiddlewareInterface */ public function __invoke(Request $request, Response $response, callable $out = null) { - $actor = $request->getAttribute('actor'); + try { + $this->assertAdminAndSudo($request); + } catch (Exception $e) { + if ($request->getMethod() === 'POST') { + $response = $this->logInController->handle($request); - if (! $this->gate->forUser($actor)->allows('administrate')) { - throw new PermissionDeniedException; + if ($response->getStatusCode() === 200) { + return $response + ->withStatus(302) + ->withHeader('location', app('Flarum\Admin\UrlGenerator')->toRoute('index')); + } + } + + return new HtmlResponse( + $this->view->make('flarum.admin::login') + ->with('token', $request->getAttribute('session')->csrf_token) + ->render() + ); } return $out ? $out($request, $response) : $response; diff --git a/src/Admin/Server.php b/src/Admin/Server.php index 13469292c..022924060 100644 --- a/src/Admin/Server.php +++ b/src/Admin/Server.php @@ -13,6 +13,7 @@ namespace Flarum\Admin; use Flarum\Foundation\Application; use Flarum\Http\AbstractServer; +use Zend\Diactoros\Response\HtmlResponse; use Zend\Stratigility\MiddlewarePipe; use Flarum\Http\Middleware\HandleErrors; @@ -30,8 +31,10 @@ class Server extends AbstractServer $errorDir = __DIR__ . '/../../error'; if ($app->isUpToDate()) { - $pipe->pipe($adminPath, $app->make('Flarum\Http\Middleware\AuthenticateWithCookie')); $pipe->pipe($adminPath, $app->make('Flarum\Http\Middleware\ParseJsonBody')); + $pipe->pipe($adminPath, $app->make('Flarum\Http\Middleware\AuthorizeWithCookie')); + $pipe->pipe($adminPath, $app->make('Flarum\Http\Middleware\StartSession')); + $pipe->pipe($adminPath, $app->make('Flarum\Http\Middleware\SetLocale')); $pipe->pipe($adminPath, $app->make('Flarum\Admin\Middleware\RequireAdministrateAbility')); $pipe->pipe($adminPath, $app->make('Flarum\Http\Middleware\DispatchRoute', ['routes' => $app->make('flarum.admin.routes')])); $pipe->pipe($adminPath, new HandleErrors($errorDir, $app->inDebugMode())); diff --git a/src/Api/AccessToken.php b/src/Api/AccessToken.php deleted file mode 100644 index eac23459a..000000000 --- a/src/Api/AccessToken.php +++ /dev/null @@ -1,80 +0,0 @@ - - * - * 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'); - } -} diff --git a/src/Api/ApiServiceProvider.php b/src/Api/ApiServiceProvider.php index f8c4ebcd2..0bc8d6827 100644 --- a/src/Api/ApiServiceProvider.php +++ b/src/Api/ApiServiceProvider.php @@ -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())); diff --git a/src/Api/Client.php b/src/Api/Client.php index 0a2920172..ea6666f74 100644 --- a/src/Api/Client.php +++ b/src/Api/Client.php @@ -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); diff --git a/src/Api/Command/GenerateAccessToken.php b/src/Api/Command/GenerateAccessToken.php deleted file mode 100644 index 10a13ab5b..000000000 --- a/src/Api/Command/GenerateAccessToken.php +++ /dev/null @@ -1,29 +0,0 @@ - - * - * 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; - } -} diff --git a/src/Api/Command/GenerateAccessTokenHandler.php b/src/Api/Command/GenerateAccessTokenHandler.php deleted file mode 100644 index 1e5668526..000000000 --- a/src/Api/Command/GenerateAccessTokenHandler.php +++ /dev/null @@ -1,30 +0,0 @@ - - * - * 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; - } -} diff --git a/src/Api/Controller/DeleteDiscussionController.php b/src/Api/Controller/DeleteDiscussionController.php index 06a20c04b..043261f1d 100644 --- a/src/Api/Controller/DeleteDiscussionController.php +++ b/src/Api/Controller/DeleteDiscussionController.php @@ -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) ); diff --git a/src/Api/Controller/DeleteGroupController.php b/src/Api/Controller/DeleteGroupController.php index 6f9ab7435..593300fa7 100644 --- a/src/Api/Controller/DeleteGroupController.php +++ b/src/Api/Controller/DeleteGroupController.php @@ -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')) ); diff --git a/src/Api/Controller/DeletePostController.php b/src/Api/Controller/DeletePostController.php index 7e31838f9..b32751a28 100644 --- a/src/Api/Controller/DeletePostController.php +++ b/src/Api/Controller/DeletePostController.php @@ -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')) ); diff --git a/src/Api/Controller/DeleteUserController.php b/src/Api/Controller/DeleteUserController.php index cb214e531..306e5567a 100644 --- a/src/Api/Controller/DeleteUserController.php +++ b/src/Api/Controller/DeleteUserController.php @@ -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')) ); diff --git a/src/Api/Controller/SetPermissionController.php b/src/Api/Controller/SetPermissionController.php index 783157b94..b709f08bb 100644 --- a/src/Api/Controller/SetPermissionController.php +++ b/src/Api/Controller/SetPermissionController.php @@ -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'); diff --git a/src/Api/Controller/SetSettingsController.php b/src/Api/Controller/SetSettingsController.php index 3d5289439..418184b4e 100644 --- a/src/Api/Controller/SetSettingsController.php +++ b/src/Api/Controller/SetSettingsController.php @@ -47,7 +47,7 @@ class SetSettingsController implements ControllerInterface */ public function handle(ServerRequestInterface $request) { - $this->assertAdmin($request->getAttribute('actor')); + $this->assertAdminAndSudo($request); $settings = $request->getParsedBody(); diff --git a/src/Api/Controller/TokenController.php b/src/Api/Controller/TokenController.php index 074709f90..38279dfcc 100644 --- a/src/Api/Controller/TokenController.php +++ b/src/Api/Controller/TokenController.php @@ -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); } } diff --git a/src/Api/Controller/UninstallExtensionController.php b/src/Api/Controller/UninstallExtensionController.php index 3b7082817..f1cf8113a 100644 --- a/src/Api/Controller/UninstallExtensionController.php +++ b/src/Api/Controller/UninstallExtensionController.php @@ -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'); diff --git a/src/Api/Controller/UpdateExtensionController.php b/src/Api/Controller/UpdateExtensionController.php index 3d221f88c..e5227a6f5 100644 --- a/src/Api/Controller/UpdateExtensionController.php +++ b/src/Api/Controller/UpdateExtensionController.php @@ -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'); diff --git a/src/Api/Controller/UpdateUserController.php b/src/Api/Controller/UpdateUserController.php index 601669448..ca675951c 100644 --- a/src/Api/Controller/UpdateUserController.php +++ b/src/Api/Controller/UpdateUserController.php @@ -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) ); diff --git a/src/Api/Exception/InvalidAccessTokenException.php b/src/Api/Exception/InvalidAccessTokenException.php new file mode 100644 index 000000000..340e47b76 --- /dev/null +++ b/src/Api/Exception/InvalidAccessTokenException.php @@ -0,0 +1,17 @@ + + * + * 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 +{ +} diff --git a/src/Api/Handler/FloodingExceptionHandler.php b/src/Api/Handler/FloodingExceptionHandler.php index 9f5e8440c..86a419669 100644 --- a/src/Api/Handler/FloodingExceptionHandler.php +++ b/src/Api/Handler/FloodingExceptionHandler.php @@ -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]); } diff --git a/src/Api/Handler/IlluminateValidationExceptionHandler.php b/src/Api/Handler/IlluminateValidationExceptionHandler.php index 743b4ca78..c3b3fe84f 100644 --- a/src/Api/Handler/IlluminateValidationExceptionHandler.php +++ b/src/Api/Handler/IlluminateValidationExceptionHandler.php @@ -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); diff --git a/src/Api/Handler/InvalidAccessTokenExceptionHandler.php b/src/Api/Handler/InvalidAccessTokenExceptionHandler.php new file mode 100644 index 000000000..366e57c7c --- /dev/null +++ b/src/Api/Handler/InvalidAccessTokenExceptionHandler.php @@ -0,0 +1,41 @@ + + * + * 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]); + } +} diff --git a/src/Api/Handler/InvalidConfirmationTokenExceptionHandler.php b/src/Api/Handler/InvalidConfirmationTokenExceptionHandler.php index 9202e487f..52ff1fab9 100644 --- a/src/Api/Handler/InvalidConfirmationTokenExceptionHandler.php +++ b/src/Api/Handler/InvalidConfirmationTokenExceptionHandler.php @@ -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]); } diff --git a/src/Api/Handler/MethodNotAllowedExceptionHandler.php b/src/Api/Handler/MethodNotAllowedExceptionHandler.php new file mode 100644 index 000000000..d4e1b1eff --- /dev/null +++ b/src/Api/Handler/MethodNotAllowedExceptionHandler.php @@ -0,0 +1,41 @@ + + * + * 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]); + } +} diff --git a/src/Api/Handler/ModelNotFoundExceptionHandler.php b/src/Api/Handler/ModelNotFoundExceptionHandler.php index 315f71111..ea80d07a7 100644 --- a/src/Api/Handler/ModelNotFoundExceptionHandler.php +++ b/src/Api/Handler/ModelNotFoundExceptionHandler.php @@ -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]); } diff --git a/src/Api/Handler/PermissionDeniedExceptionHandler.php b/src/Api/Handler/PermissionDeniedExceptionHandler.php index ac689d837..238dc45fd 100644 --- a/src/Api/Handler/PermissionDeniedExceptionHandler.php +++ b/src/Api/Handler/PermissionDeniedExceptionHandler.php @@ -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]); } diff --git a/src/Api/Handler/RouteNotFoundExceptionHandler.php b/src/Api/Handler/RouteNotFoundExceptionHandler.php new file mode 100644 index 000000000..5d27e0b0e --- /dev/null +++ b/src/Api/Handler/RouteNotFoundExceptionHandler.php @@ -0,0 +1,41 @@ + + * + * 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]); + } +} diff --git a/src/Api/Handler/TokenMismatchExceptionHandler.php b/src/Api/Handler/TokenMismatchExceptionHandler.php new file mode 100644 index 000000000..ad82b6aa9 --- /dev/null +++ b/src/Api/Handler/TokenMismatchExceptionHandler.php @@ -0,0 +1,41 @@ + + * + * 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]); + } +} diff --git a/src/Api/Handler/ValidationExceptionHandler.php b/src/Api/Handler/ValidationExceptionHandler.php index 4e0ad26e8..7b2ce70d1 100644 --- a/src/Api/Handler/ValidationExceptionHandler.php +++ b/src/Api/Handler/ValidationExceptionHandler.php @@ -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); diff --git a/src/Api/Middleware/AuthenticateWithHeader.php b/src/Api/Middleware/AuthenticateWithHeader.php deleted file mode 100644 index c76d42f4f..000000000 --- a/src/Api/Middleware/AuthenticateWithHeader.php +++ /dev/null @@ -1,93 +0,0 @@ - - * - * 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); - } -} diff --git a/src/Api/Server.php b/src/Api/Server.php index 48f57dc0f..56371d50f 100644 --- a/src/Api/Server.php +++ b/src/Api/Server.php @@ -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 { diff --git a/src/Core/Access/AssertPermissionTrait.php b/src/Core/Access/AssertPermissionTrait.php index a88506993..0bc5eee7f 100644 --- a/src/Core/Access/AssertPermissionTrait.php +++ b/src/Core/Access/AssertPermissionTrait.php @@ -10,8 +10,10 @@ namespace Flarum\Core\Access; +use Flarum\Api\Exception\InvalidAccessTokenException; use Flarum\Core\Exception\PermissionDeniedException; use Flarum\Core\User; +use Psr\Http\Message\ServerRequestInterface; trait AssertPermissionTrait { @@ -61,6 +63,30 @@ trait AssertPermissionTrait */ protected function assertAdmin(User $actor) { - $this->assertPermission($actor->isAdmin()); + $this->assertCan($actor, 'administrate'); + } + + /** + * @param ServerRequestInterface $request + * @throws InvalidAccessTokenException + */ + protected function assertSudo(ServerRequestInterface $request) + { + $session = $request->getAttribute('session'); + + if (! $session || ! $session->isSudo()) { + throw new InvalidAccessTokenException; + } + } + + /** + * @param ServerRequestInterface $request + * @throws PermissionDeniedException + */ + protected function assertAdminAndSudo(ServerRequestInterface $request) + { + $this->assertAdmin($request->getAttribute('actor')); + + $this->assertSudo($request); } } diff --git a/src/Core/User.php b/src/Core/User.php index 0a2c1a194..195844898 100755 --- a/src/Core/User.php +++ b/src/Core/User.php @@ -135,7 +135,7 @@ class User extends AbstractModel $user->read()->detach(); $user->groups()->detach(); - $user->accessTokens()->delete(); + $user->sessions()->delete(); $user->notifications()->delete(); }); @@ -654,13 +654,13 @@ class User extends AbstractModel } /** - * Define the relationship with the user's access tokens. + * Define the relationship with the user's sessions. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ - public function accessTokens() + public function sessions() { - return $this->hasMany('Flarum\Api\AccessToken'); + return $this->hasMany('Flarum\Http\Session'); } /** diff --git a/src/Event/UserLoggedIn.php b/src/Event/UserLoggedIn.php index a1d86a8cb..32c0ceaf4 100644 --- a/src/Event/UserLoggedIn.php +++ b/src/Event/UserLoggedIn.php @@ -11,16 +11,17 @@ namespace Flarum\Event; use Flarum\Core\User; +use Flarum\Http\Session; class UserLoggedIn { public $user; - public $token; + public $session; - public function __construct(User $user, $token) + public function __construct(User $user, Session $session) { $this->user = $user; - $this->token = $token; + $this->session = $session; } } diff --git a/src/Forum/Controller/AuthenticateUserTrait.php b/src/Forum/Controller/AuthenticateUserTrait.php index c0d8eedac..f53250da2 100644 --- a/src/Forum/Controller/AuthenticateUserTrait.php +++ b/src/Forum/Controller/AuthenticateUserTrait.php @@ -12,14 +12,11 @@ namespace Flarum\Forum\Controller; use Flarum\Core\User; use Zend\Diactoros\Response\HtmlResponse; -use Flarum\Api\Command\GenerateAccessToken; use Flarum\Core\AuthToken; -use DateTime; +use Psr\Http\Message\ServerRequestInterface as Request; trait AuthenticateUserTrait { - use WriteRememberCookieTrait; - /** * @var \Illuminate\Contracts\Bus\Dispatcher */ @@ -45,7 +42,7 @@ trait AuthenticateUserTrait * @param array $suggestions * @return HtmlResponse */ - protected function authenticate(array $identification, array $suggestions = []) + protected function authenticate(Request $request, array $identification, array $suggestions = []) { $user = User::where($identification)->first(); @@ -70,13 +67,8 @@ trait AuthenticateUserTrait $response = new HtmlResponse($content); if ($user) { - // Extend the token's expiry to 2 weeks so that we can set a - // remember cookie - $accessToken = $this->bus->dispatch(new GenerateAccessToken($user->id)); - $accessToken::unguard(); - $accessToken->update(['expires_at' => new DateTime('+2 weeks')]); - - $response = $this->withRememberCookie($response, $accessToken->id); + $session = $request->getAttribute('session'); + $session->assign($user)->regenerateId()->renew()->setDuration(60 * 24 * 14)->save(); } return $response; diff --git a/src/Forum/Controller/ConfirmEmailController.php b/src/Forum/Controller/ConfirmEmailController.php index df1fe0254..d763ac94d 100644 --- a/src/Forum/Controller/ConfirmEmailController.php +++ b/src/Forum/Controller/ConfirmEmailController.php @@ -11,7 +11,6 @@ namespace Flarum\Forum\Controller; use Flarum\Core\Command\ConfirmEmail; -use Flarum\Api\Command\GenerateAccessToken; use Flarum\Core\Exception\InvalidConfirmationTokenException; use Flarum\Foundation\Application; use Flarum\Http\Controller\ControllerInterface; @@ -22,8 +21,6 @@ use Zend\Diactoros\Response\RedirectResponse; class ConfirmEmailController implements ControllerInterface { - use WriteRememberCookieTrait; - /** * @var Dispatcher */ @@ -60,13 +57,9 @@ class ConfirmEmailController implements ControllerInterface return new HtmlResponse('Invalid confirmation token'); } - $token = $this->bus->dispatch( - new GenerateAccessToken($user->id) - ); + $session = $request->getAttribute('session'); + $session->assign($user)->regenerateId()->renew()->setDuration(60 * 24 * 14)->save(); - return $this->withRememberCookie( - new RedirectResponse($this->app->url()), - $token->id - ); + return new RedirectResponse($this->app->url()); } } diff --git a/src/Forum/Controller/LoginController.php b/src/Forum/Controller/LogInController.php similarity index 69% rename from src/Forum/Controller/LoginController.php rename to src/Forum/Controller/LogInController.php index 3ca89af69..974668106 100644 --- a/src/Forum/Controller/LoginController.php +++ b/src/Forum/Controller/LogInController.php @@ -11,19 +11,16 @@ namespace Flarum\Forum\Controller; use Flarum\Api\Client; -use Flarum\Api\AccessToken; +use Flarum\Http\Session; use Flarum\Event\UserLoggedIn; use Flarum\Core\Repository\UserRepository; use Flarum\Http\Controller\ControllerInterface; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\JsonResponse; -use DateTime; -class LoginController implements ControllerInterface +class LogInController implements ControllerInterface { - use WriteRememberCookieTrait; - /** * @var \Flarum\Core\Repository\UserRepository */ @@ -52,26 +49,20 @@ class LoginController implements ControllerInterface public function handle(Request $request, array $routeParams = []) { $controller = 'Flarum\Api\Controller\TokenController'; - $actor = $request->getAttribute('actor'); + $session = $request->getAttribute('session'); $params = array_only($request->getParsedBody(), ['identification', 'password']); - $response = $this->apiClient->send($controller, $actor, [], $params); + $response = $this->apiClient->send($controller, $session, [], $params); if ($response->getStatusCode() === 200) { $data = json_decode($response->getBody()); - // Extend the token's expiry to 2 weeks so that we can set a - // remember cookie - AccessToken::where('id', $data->token)->update(['expires_at' => new DateTime('+2 weeks')]); + $session = Session::find($data->token); + $session->setDuration(60 * 24 * 14)->save(); - event(new UserLoggedIn($this->users->findOrFail($data->userId), $data->token)); - - return $this->withRememberCookie( - $response, - $data->token - ); - } else { - return $response; + event(new UserLoggedIn($this->users->findOrFail($data->userId), $session)); } + + return $response; } } diff --git a/src/Forum/Controller/LogoutController.php b/src/Forum/Controller/LogOutController.php similarity index 68% rename from src/Forum/Controller/LogoutController.php rename to src/Forum/Controller/LogOutController.php index e8975b168..c00dc0f43 100644 --- a/src/Forum/Controller/LogoutController.php +++ b/src/Forum/Controller/LogOutController.php @@ -10,18 +10,16 @@ namespace Flarum\Forum\Controller; -use Flarum\Api\AccessToken; use Flarum\Event\UserLoggedOut; use Flarum\Foundation\Application; use Flarum\Http\Controller\ControllerInterface; +use Flarum\Http\Exception\TokenMismatchException; use Illuminate\Contracts\Events\Dispatcher; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Diactoros\Response\RedirectResponse; -class LogoutController implements ControllerInterface +class LogOutController implements ControllerInterface { - use WriteRememberCookieTrait; - /** * @var Application */ @@ -46,21 +44,24 @@ class LogoutController implements ControllerInterface * @param Request $request * @param array $routeParams * @return \Psr\Http\Message\ResponseInterface + * @throws TokenMismatchException */ public function handle(Request $request, array $routeParams = []) { - $user = $request->getAttribute('actor'); + $session = $request->getAttribute('session'); - if ($user->exists) { - $token = array_get($request->getQueryParams(), 'token'); + if ($user = $session->user) { + if (array_get($request->getQueryParams(), 'token') !== $session->csrf_token) { + throw new TokenMismatchException; + } - AccessToken::where('user_id', $user->id)->findOrFail($token); + $session->exists = false; - $user->accessTokens()->delete(); + $user->sessions()->delete(); $this->events->fire(new UserLoggedOut($user)); } - return $this->withForgetCookie(new RedirectResponse($this->app->url())); + return new RedirectResponse($this->app->url()); } } diff --git a/src/Forum/Controller/RegisterController.php b/src/Forum/Controller/RegisterController.php index 1804772af..6c0f7c954 100644 --- a/src/Forum/Controller/RegisterController.php +++ b/src/Forum/Controller/RegisterController.php @@ -11,19 +11,15 @@ namespace Flarum\Forum\Controller; use Flarum\Api\Client; -use Flarum\Api\AccessToken; +use Flarum\Core\User; use Flarum\Http\Controller\ControllerInterface; -use Flarum\Api\Command\GenerateAccessToken; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\JsonResponse; use Illuminate\Contracts\Bus\Dispatcher; -use DateTime; class RegisterController implements ControllerInterface { - use WriteRememberCookieTrait; - /** * @var Dispatcher */ @@ -61,21 +57,13 @@ class RegisterController implements ControllerInterface $body = json_decode($response->getBody()); $statusCode = $response->getStatusCode(); - $response = new JsonResponse($body, $statusCode); + if (isset($body->data)) { + $user = User::find($body->data->id); - if (! empty($body->data->attributes->isActivated)) { - $token = $this->bus->dispatch(new GenerateAccessToken($body->data->id)); - - // Extend the token's expiry to 2 weeks so that we can set a - // remember cookie - AccessToken::where('id', $token->id)->update(['expires_at' => new DateTime('+2 weeks')]); - - return $this->withRememberCookie( - $response, - $token->id - ); + $session = $request->getAttribute('session'); + $session->assign($user)->regenerateId()->renew()->setDuration(60 * 24 * 14)->save(); } - return $response; + return new JsonResponse($body, $statusCode); } } diff --git a/src/Forum/Controller/WriteRememberCookieTrait.php b/src/Forum/Controller/WriteRememberCookieTrait.php deleted file mode 100644 index 86fd77ce0..000000000 --- a/src/Forum/Controller/WriteRememberCookieTrait.php +++ /dev/null @@ -1,42 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flarum\Forum\Controller; - -use Dflydev\FigCookies\FigResponseCookies; -use Dflydev\FigCookies\SetCookie; -use Psr\Http\Message\ResponseInterface; - -trait WriteRememberCookieTrait -{ - protected function withRememberCookie(ResponseInterface $response, $token) - { - // Set a long-living cookie (two weeks) with the remember token - return FigResponseCookies::set( - $response, - SetCookie::create('flarum_remember', $token) - ->withMaxAge(14 * 24 * 60 * 60) - ->withPath('/') - ->withHttpOnly(true) - ); - } - - protected function withForgetCookie(ResponseInterface $response) - { - // Delete the cookie by setting it to an expiration date in the past - return FigResponseCookies::set( - $response, - SetCookie::create('flarum_remember') - ->withMaxAge(-2628000) - ->withPath('/') - ->withHttpOnly(true) - ); - } -} diff --git a/src/Forum/Server.php b/src/Forum/Server.php index 03d0b3029..a6dc97069 100644 --- a/src/Forum/Server.php +++ b/src/Forum/Server.php @@ -35,8 +35,10 @@ class Server extends AbstractServer $pipe->pipe($basePath, $app->make('Flarum\Http\Middleware\DispatchRoute', ['routes' => $app->make('flarum.install.routes')])); $pipe->pipe($basePath, new HandleErrors($errorDir, true)); } elseif ($app->isUpToDate()) { - $pipe->pipe($basePath, $app->make('Flarum\Http\Middleware\AuthenticateWithCookie')); $pipe->pipe($basePath, $app->make('Flarum\Http\Middleware\ParseJsonBody')); + $pipe->pipe($basePath, $app->make('Flarum\Http\Middleware\AuthorizeWithCookie')); + $pipe->pipe($basePath, $app->make('Flarum\Http\Middleware\StartSession')); + $pipe->pipe($basePath, $app->make('Flarum\Http\Middleware\SetLocale')); $pipe->pipe($basePath, $app->make('Flarum\Http\Middleware\DispatchRoute', ['routes' => $app->make('flarum.forum.routes')])); $pipe->pipe($basePath, new HandleErrors($errorDir, $app->inDebugMode())); } else { diff --git a/src/Http/Controller/ClientView.php b/src/Http/Controller/ClientView.php index e7b89e973..4f5e919db 100644 --- a/src/Http/Controller/ClientView.php +++ b/src/Http/Controller/ClientView.php @@ -339,9 +339,11 @@ class ClientView implements Renderable */ protected function getSession() { + $session = $this->request->getAttribute('session'); + return [ 'userId' => $this->actor->id, - 'token' => array_get($this->request->getCookieParams(), 'flarum_remember'), + 'csrfToken' => $session->csrf_token ]; } } diff --git a/src/Http/Exception/MethodNotAllowedException.php b/src/Http/Exception/MethodNotAllowedException.php new file mode 100644 index 000000000..0b7cd3f8e --- /dev/null +++ b/src/Http/Exception/MethodNotAllowedException.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Http\Exception; + +use Exception; + +class MethodNotAllowedException extends Exception +{ + public function __construct($message = null, $code = 405, Exception $previous = null) + { + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Http/Exception/TokenMismatchException.php b/src/Http/Exception/TokenMismatchException.php new file mode 100644 index 000000000..49da0abaa --- /dev/null +++ b/src/Http/Exception/TokenMismatchException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Http\Exception; + +use Exception; + +class TokenMismatchException extends Exception +{ +} diff --git a/src/Http/Middleware/AuthenticateWithCookie.php b/src/Http/Middleware/AuthenticateWithCookie.php index f39f7a2aa..aca046548 100644 --- a/src/Http/Middleware/AuthenticateWithCookie.php +++ b/src/Http/Middleware/AuthenticateWithCookie.php @@ -10,82 +10,43 @@ namespace Flarum\Http\Middleware; -use Flarum\Api\AccessToken; -use Flarum\Core\Guest; -use Flarum\Locale\LocaleManager; +use Flarum\Http\Exception\TokenMismatchException; +use Flarum\Http\Session; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Stratigility\MiddlewareInterface; class AuthenticateWithCookie implements MiddlewareInterface { - /** - * @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); + $id = array_get($request->getCookieParams(), 'flarum_session'); + + if ($id) { + $session = Session::find($id); + + $request = $request->withAttribute('session', $session); + + if (! $this->isReading($request) && ! $this->tokensMatch($request)) { + throw new TokenMismatchException; + } + } return $out ? $out($request, $response) : $response; } - /** - * Set the application's actor instance according to the request token. - * - * @param Request $request - * @return Request - */ - protected function logIn(Request $request) + private function isReading(Request $request) { - $actor = new Guest; - - if ($token = $this->getToken($request)) { - if (! $token->isValid()) { - // TODO: https://github.com/flarum/core/issues/253 - } elseif ($token->user) { - $actor = $token->user; - $actor->updateLastSeen()->save(); - } - } - - 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); + return in_array($request->getMethod(), ['HEAD', 'GET', 'OPTIONS']); } - /** - * Get the access token referred to by the request cookie. - * - * @param Request $request - * @return AccessToken|null - */ - protected function getToken(Request $request) + private function tokensMatch(Request $request) { - $token = array_get($request->getCookieParams(), 'flarum_remember'); + $input = $request->getHeaderLine('X-CSRF-Token') ?: array_get($request->getParsedBody(), 'token'); - if ($token) { - return AccessToken::find($token); - } + return $request->getAttribute('session')->csrf_token === $input; } } diff --git a/src/Http/Middleware/AuthenticateWithHeader.php b/src/Http/Middleware/AuthenticateWithHeader.php new file mode 100644 index 000000000..638ea5cc5 --- /dev/null +++ b/src/Http/Middleware/AuthenticateWithHeader.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Http\Middleware; + +use Flarum\Api\ApiKey; +use Flarum\Core\User; +use Flarum\Http\Session; +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 '; + + /** + * {@inheritdoc} + */ + public function __invoke(Request $request, Response $response, callable $out = null) + { + $headerLine = $request->getHeaderLine('authorization'); + + $parts = explode(';', $headerLine); + + if (isset($parts[0]) && starts_with($parts[0], $this->prefix)) { + $id = substr($parts[0], strlen($this->prefix)); + + if (isset($parts[1]) && ApiKey::valid($id)) { + if ($actor = $this->getUser($parts[1])) { + $request = $request->withAttribute('actor', $actor); + } + } else { + $session = Session::find($id); + + $request = $request->withAttribute('session', $session); + } + } + + return $out ? $out($request, $response) : $response; + } + + private function getUser($string) + { + $parts = explode('=', trim($string)); + + if (isset($parts[0]) && $parts[0] === 'userId') { + return User::find($parts[1]); + } + } +} diff --git a/src/Http/Middleware/DispatchRoute.php b/src/Http/Middleware/DispatchRoute.php index b63208fc2..f8f8d55e4 100644 --- a/src/Http/Middleware/DispatchRoute.php +++ b/src/Http/Middleware/DispatchRoute.php @@ -13,8 +13,9 @@ namespace Flarum\Http\Middleware; use FastRoute\Dispatcher; use FastRoute\RouteParser; -use Flarum\Http\RouteCollection; +use Flarum\Http\Exception\MethodNotAllowedException; use Flarum\Http\Exception\RouteNotFoundException; +use Flarum\Http\RouteCollection; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -47,6 +48,7 @@ class DispatchRoute * @param Response $response * @param callable $out * @return Response + * @throws MethodNotAllowedException * @throws RouteNotFoundException */ public function __invoke(Request $request, Response $response, callable $out = null) @@ -58,8 +60,11 @@ class DispatchRoute switch ($routeInfo[0]) { case Dispatcher::NOT_FOUND: - case Dispatcher::METHOD_NOT_ALLOWED: throw new RouteNotFoundException; + + case Dispatcher::METHOD_NOT_ALLOWED: + throw new MethodNotAllowedException; + case Dispatcher::FOUND: $handler = $routeInfo[1]; $parameters = $routeInfo[2]; diff --git a/src/Http/Middleware/SetLocale.php b/src/Http/Middleware/SetLocale.php new file mode 100644 index 000000000..6272a02aa --- /dev/null +++ b/src/Http/Middleware/SetLocale.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Http\Middleware; + +use Flarum\Locale\LocaleManager; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Stratigility\MiddlewareInterface; + +class SetLocale implements MiddlewareInterface +{ + /** + * @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) + { + $actor = $request->getAttribute('actor'); + + 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 $out ? $out($request, $response) : $response; + } +} diff --git a/src/Http/Middleware/StartSession.php b/src/Http/Middleware/StartSession.php new file mode 100644 index 000000000..9ba822492 --- /dev/null +++ b/src/Http/Middleware/StartSession.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Http\Middleware; + +use Dflydev\FigCookies\FigResponseCookies; +use Dflydev\FigCookies\SetCookie; +use Dflydev\FigCookies\SetCookies; +use Flarum\Http\Session; +use Flarum\Core\Guest; +use Flarum\Http\WriteSessionCookieTrait; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Zend\Stratigility\MiddlewareInterface; + +class StartSession implements MiddlewareInterface +{ + use WriteSessionCookieTrait; + + /** + * {@inheritdoc} + */ + public function __invoke(Request $request, Response $response, callable $out = null) + { + $this->collectGarbage(); + + $session = $this->getSession($request); + $actor = $this->getActor($session); + + $request = $request + ->withAttribute('session', $session) + ->withAttribute('actor', $actor); + + $response = $out ? $out($request, $response) : $response; + + return $this->addSessionCookieToResponse($response, $session, 'flarum_session'); + } + + private function getSession(Request $request) + { + $session = $request->getAttribute('session'); + + if (! $session) { + $session = Session::generate(); + } + + $session->extend()->save(); + + return $session; + } + + private function getActor(Session $session) + { + $actor = $session->user ?: new Guest; + + if ($actor->exists) { + $actor->updateLastSeen()->save(); + } + + return $actor; + } + + private function collectGarbage() + { + if ($this->hitsLottery()) { + Session::whereRaw('last_activity <= ? - duration * 60', [time()])->delete(); + } + } + + private function hitsLottery() + { + return mt_rand(1, 100) <= 1; + } +} diff --git a/src/Http/Session.php b/src/Http/Session.php new file mode 100644 index 000000000..dff826e49 --- /dev/null +++ b/src/Http/Session.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Http; + +use DateTime; +use Flarum\Core\User; +use Flarum\Database\AbstractModel; +use Illuminate\Support\Str; + +/** + * @property string $id + * @property int $user_id + * @property int $last_activity + * @property int $duration + * @property \Carbon\Carbon $sudo_expiry_time + * @property string $csrf_token + * @property \Flarum\Core\User|null $user + */ +class Session extends AbstractModel +{ + /** + * {@inheritdoc} + */ + protected $table = 'sessions'; + + /** + * Use a custom primary key for this model. + * + * @var bool + */ + public $incrementing = false; + + /** + * {@inheritdoc} + */ + protected $dates = ['sudo_expiry_time']; + + /** + * Generate a session. + * + * @param User|null $user + * @param int $duration How long before the session will expire, in minutes. + * @return static + */ + public static function generate(User $user = null, $duration = 60) + { + $session = new static; + + $session->assign($user) + ->regenerateId() + ->renew() + ->setDuration($duration); + + return $session->extend(); + } + + /** + * Assign the session to a user. + * + * @param User|null $user + * @return $this + */ + public function assign(User $user = null) + { + $this->user_id = $user ? $user->id : null; + + return $this; + } + + /** + * Regenerate the session ID. + * + * @return $this + */ + public function regenerateId() + { + $this->id = sha1(uniqid('', true).Str::random(25).microtime(true)); + $this->csrf_token = Str::random(40); + + return $this; + } + + /** + * @return $this + */ + public function extend() + { + $this->last_activity = time(); + + return $this; + } + + /** + * @return $this + */ + public function renew() + { + $this->extend(); + $this->sudo_expiry_time = time() + 30 * 60; + + return $this; + } + + /** + * @param int $duration How long before the session will expire, in minutes. + * @return $this + */ + public function setDuration($duration) + { + $this->duration = $duration; + + return $this; + } + + /** + * @return bool + */ + public function isSudo() + { + return $this->sudo_expiry_time > new DateTime; + } + + /** + * Define the relationship with the owner of this access token. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/src/Http/WriteSessionCookieTrait.php b/src/Http/WriteSessionCookieTrait.php new file mode 100644 index 000000000..f74cea56c --- /dev/null +++ b/src/Http/WriteSessionCookieTrait.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Http; + +use Dflydev\FigCookies\FigResponseCookies; +use Dflydev\FigCookies\SetCookie; +use Psr\Http\Message\ResponseInterface as Response; + +trait WriteSessionCookieTrait +{ + protected function addSessionCookieToResponse(Response $response, Session $session, $cookieName) + { + return FigResponseCookies::set( + $response, + SetCookie::create($cookieName, $session->exists ? $session->id : null) + ->withMaxAge($session->exists ? $session->duration * 60 : -2628000) + ->withPath('/') + ->withHttpOnly(true) + ); + } +} diff --git a/src/Install/Controller/InstallController.php b/src/Install/Controller/InstallController.php index c14af5187..d0f52ecbc 100644 --- a/src/Install/Controller/InstallController.php +++ b/src/Install/Controller/InstallController.php @@ -10,14 +10,15 @@ namespace Flarum\Install\Controller; +use Flarum\Core\User; use Flarum\Http\Controller\ControllerInterface; +use Flarum\Http\Session; +use Flarum\Http\WriteSessionCookieTrait; use Psr\Http\Message\ServerRequestInterface as Request; use Zend\Diactoros\Response\HtmlResponse; use Zend\Diactoros\Response; use Flarum\Install\Console\InstallCommand; use Flarum\Install\Console\DefaultsDataProvider; -use Flarum\Api\Command\GenerateAccessToken; -use Flarum\Forum\Controller\WriteRememberCookieTrait; use Symfony\Component\Console\Output\StreamOutput; use Symfony\Component\Console\Input\StringInput; use Illuminate\Contracts\Bus\Dispatcher; @@ -26,7 +27,7 @@ use DateTime; class InstallController implements ControllerInterface { - use WriteRememberCookieTrait; + use WriteSessionCookieTrait; protected $command; @@ -87,14 +88,9 @@ class InstallController implements ControllerInterface return new HtmlResponse($e->getMessage(), 500); } - $token = $this->bus->dispatch( - new GenerateAccessToken(1) - ); - $token->update(['expires_at' => new DateTime('+2 weeks')]); + $session = Session::generate(User::find(1), 60 * 24 * 14); + $session->save(); - return $this->withRememberCookie( - new Response($body, 200), - $token->id - ); + return $this->addSessionCookieToResponse(new Response($body, 200), $session, 'flarum_session'); } } diff --git a/views/login.blade.php b/views/login.blade.php new file mode 100644 index 000000000..8f98689ad --- /dev/null +++ b/views/login.blade.php @@ -0,0 +1,32 @@ + + + + + + Log In + + + + + +

Log In

+ +
+ + +
+ + +
+ +
+ + +
+ +
+ +
+
+ +