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 22331306c6
commit 32e9c0587c
69 changed files with 1076 additions and 509 deletions

View File

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

View File

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

View File

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

View File

@ -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 (
<article {...attrs}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<div className="Modal-body">
<div className="Form Form--centered">
<div className="Form-group">
<input
type="password"
className="FormControl"
bidi={this.password}
placeholder={extractText(app.translator.trans('core.forum.confirm_password.password_placeholder'))}
disabled={this.loading}/>
</div>
<div className="Form-group">
<Button
type="submit"
className="Button Button--primary Button--block"
loading={this.loading}>
{app.translator.trans('core.forum.confirm_password.submit_button')}
</Button>
</div>
</div>
</div>
);
}
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);
}
}

View File

@ -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() {
}
/**

View File

@ -77,6 +77,10 @@ export default class ModalManager extends Component {
* @protected
*/
clear() {
if (this.component) {
this.component.onhide();
}
this.component = null;
m.lazyRedraw();

View File

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

View File

@ -167,6 +167,9 @@
color: @muted-more-color;
}
}
.Post--loading {
opacity: 0.5;
}
.PostMeta {
display: inline;
}

View File

@ -0,0 +1,32 @@
<?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\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');
});
}
}

View File

@ -0,0 +1,34 @@
<?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\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');
}
}

View File

@ -42,6 +42,8 @@ class AdminServiceProvider extends AbstractServiceProvider
*/
public function boot()
{
$this->loadViewsFrom(__DIR__.'/../../views', 'flarum.admin');
$this->flushAssetsWhenThemeChanged();
$this->flushAssetsWhenExtensionsChanged();

View File

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

View File

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

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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,42 +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\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)
);
}
}

View File

@ -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 {

View File

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

View File

@ -0,0 +1,22 @@
<?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\Http\Exception;
use Exception;
class MethodNotAllowedException extends Exception
{
public function __construct($message = null, $code = 405, Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@ -0,0 +1,18 @@
<?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\Http\Exception;
use Exception;
class TokenMismatchException extends Exception
{
}

View File

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

View File

@ -0,0 +1,61 @@
<?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\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]);
}
}
}

View File

@ -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];

View File

@ -0,0 +1,52 @@
<?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\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;
}
}

View File

@ -0,0 +1,81 @@
<?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\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;
}
}

View File

@ -0,0 +1,140 @@
<?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\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);
}
}

View File

@ -0,0 +1,29 @@
<?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\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)
);
}
}

View File

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

View File

@ -0,0 +1,32 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Log In</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1">
</head>
<body>
<h1>Log In</h1>
<form class="form-horizontal" role="form" method="POST" action="{{ app('Flarum\Admin\UrlGenerator')->toRoute('index') }}">
<input type="hidden" name="token" value="{{ $token }}">
<div class="form-group">
<label class="control-label">Username or Email</label>
<input type="text" class="form-control" name="identification">
</div>
<div class="form-group">
<label class="control-label">Password</label>
<input type="password" class="form-control" name="password">
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Log In</button>
</div>
</form>
</body>
</html>