mirror of
https://github.com/flarum/core.git
synced 2025-07-21 08:41:17 +02:00
Overhaul sessions, tokens, and authentication
- Use cookies + CSRF token for API authentication in the default client. This mitigates potential XSS attacks by making the token unavailable to JavaScript. The Authorization header is still supported, but not used by default. - Make sensitive/destructive actions (editing a user, permanently deleting anything, visiting the admin CP) require the user to re-enter their password if they haven't entered it in the last 30 minutes. - Refactor and clean up the authentication middleware. - Add an `onhide` hook to the Modal component. (+1 squashed commit)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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.
|
||||
*
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
73
framework/core/js/lib/components/ConfirmPasswordModal.js
Normal file
73
framework/core/js/lib/components/ConfirmPasswordModal.js
Normal 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);
|
||||
}
|
||||
}
|
@@ -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() {
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -77,6 +77,10 @@ export default class ModalManager extends Component {
|
||||
* @protected
|
||||
*/
|
||||
clear() {
|
||||
if (this.component) {
|
||||
this.component.onhide();
|
||||
}
|
||||
|
||||
this.component = null;
|
||||
|
||||
m.lazyRedraw();
|
||||
|
@@ -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
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user