aboutsummaryrefslogtreecommitdiff
path: root/client/js
diff options
context:
space:
mode:
authorReAnzu2018-02-25 04:44:02 -0600
committerrr-2018-03-25 22:23:29 +0200
commit2a69f0193f9e5aa451e0dbcb8c89070f3e49ffa5 (patch)
treebda640614ae711e8fef0e0463f118f85ef83ac0e /client/js
parente35e70992736340bff3995ca6f425baf2f431e2f (diff)
server/auth: add token authentication
* Users are only authenticated against their password on login, and to retrieve a token * Passwords are wiped from the GUI frontend and cookies after login and token retrieval * Tokens are revoked at the end of the session/logout * If the user chooses the "remember me" option, the token is stored in the cookie * Tokens correctly delete themselves on logout * Tokens can expire at user-specified date * Tokens have their last usage time * Tokens can have user defined descriptions * Users can manage login tokens in their account settings
Diffstat (limited to 'client/js')
-rw-r--r--client/js/api.js94
-rw-r--r--client/js/controllers/user_controller.js107
-rw-r--r--client/js/models/user_token.js116
-rw-r--r--client/js/util/polyfill.js7
-rw-r--r--client/js/util/views.js6
-rw-r--r--client/js/views/user_tokens_view.js134
-rw-r--r--client/js/views/user_view.js13
7 files changed, 469 insertions, 8 deletions
diff --git a/client/js/api.js b/client/js/api.js
index abfeb6f..3623045 100644
--- a/client/js/api.js
+++ b/client/js/api.js
@@ -15,6 +15,7 @@ class Api extends events.EventTarget {
this.user = null;
this.userName = null;
this.userPassword = null;
+ this.token = null;
this.cache = {};
this.allRanks = [
'anonymous',
@@ -87,16 +88,16 @@ class Api extends events.EventTarget {
loginFromCookies() {
const auth = cookies.getJSON('auth');
- return auth && auth.user && auth.password ?
- this.login(auth.user, auth.password, true) :
+ return auth && auth.user && auth.token ?
+ this.loginWithToken(auth.user, auth.token, true) :
Promise.resolve();
}
- login(userName, userPassword, doRemember) {
+ loginWithToken(userName, token, doRemember) {
this.cache = {};
return new Promise((resolve, reject) => {
this.userName = userName;
- this.userPassword = userPassword;
+ this.token = token;
this.get('/user/' + userName + '?bump-login=true')
.then(response => {
const options = {};
@@ -105,8 +106,70 @@ class Api extends events.EventTarget {
}
cookies.set(
'auth',
- {'user': userName, 'password': userPassword},
+ {'user': userName, 'token': token},
+ options);
+ this.user = response;
+ resolve();
+ this.dispatchEvent(new CustomEvent('login'));
+ }, error => {
+ reject(error);
+ this.logout();
+ });
+ });
+ }
+
+ createToken(userName, options) {
+ let userTokenRequest = {
+ enabled: true,
+ note: 'Web Login Token'
+ };
+ if (typeof options.expires !== 'undefined') {
+ userTokenRequest.expirationTime = new Date().addDays(options.expires).toISOString()
+ }
+ return new Promise((resolve, reject) => {
+ this.post('/user-token/' + userName, userTokenRequest)
+ .then(response => {
+ cookies.set(
+ 'auth',
+ {'user': userName, 'token': response.token},
+ options);
+ this.userName = userName;
+ this.token = response.token;
+ this.userPassword = null;
+ }, error => {
+ reject(error);
+ });
+ });
+ }
+
+ deleteToken(userName, userToken) {
+ return new Promise((resolve, reject) => {
+ this.delete('/user-token/' + userName + '/' + userToken, {})
+ .then(response => {
+ const options = {};
+ cookies.set(
+ 'auth',
+ {'user': userName, 'token': null},
options);
+ resolve();
+ }, error => {
+ reject(error);
+ });
+ });
+ }
+
+ login(userName, userPassword, doRemember) {
+ this.cache = {};
+ return new Promise((resolve, reject) => {
+ this.userName = userName;
+ this.userPassword = userPassword;
+ this.get('/user/' + userName + '?bump-login=true')
+ .then(response => {
+ const options = {};
+ if (doRemember) {
+ options.expires = 365;
+ }
+ this.createToken(this.userName, options);
this.user = response;
resolve();
this.dispatchEvent(new CustomEvent('login'));
@@ -118,9 +181,20 @@ class Api extends events.EventTarget {
}
logout() {
+ let self = this;
+ this.deleteToken(this.userName, this.token)
+ .then(response => {
+ self._logout();
+ }, error => {
+ self._logout();
+ });
+ }
+
+ _logout() {
this.user = null;
this.userName = null;
this.userPassword = null;
+ this.token = null;
this.dispatchEvent(new CustomEvent('logout'));
}
@@ -137,6 +211,10 @@ class Api extends events.EventTarget {
}
}
+ isCurrentAuthToken(userToken) {
+ return userToken.token === this.token;
+ }
+
_getFullUrl(url) {
const fullUrl =
(config.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/');
@@ -258,7 +336,11 @@ class Api extends events.EventTarget {
}
try {
- if (this.userName && this.userPassword) {
+ if (this.userName && this.token) {
+ req.auth = null;
+ req.set('Authorization', 'Token '
+ + new Buffer(this.userName + ":" + this.token).toString('base64'))
+ } else if (this.userName && this.userPassword) {
req.auth(
this.userName,
encodeURIComponent(this.userPassword)
diff --git a/client/js/controllers/user_controller.js b/client/js/controllers/user_controller.js
index 46020f3..d042e41 100644
--- a/client/js/controllers/user_controller.js
+++ b/client/js/controllers/user_controller.js
@@ -7,6 +7,7 @@ const misc = require('../util/misc.js');
const config = require('../config.js');
const views = require('../util/views.js');
const User = require('../models/user.js');
+const UserToken = require('../models/user_token.js');
const topNavigation = require('../models/top_navigation.js');
const UserView = require('../views/user_view.js');
const EmptyView = require('../views/empty_view.js');
@@ -21,8 +22,28 @@ class UserController {
return;
}
+ this._successMessages = [];
+ this._errorMessages = [];
+
+ let userTokenPromise = Promise.resolve([]);
+ if (section === 'list-tokens') {
+ userTokenPromise = UserToken.get(userName)
+ .then(userTokens => {
+ return userTokens.map(token => {
+ token.isCurrentAuthToken = api.isCurrentAuthToken(token);
+ return token;
+ });
+ }, error => {
+ return [];
+ });
+ }
+
topNavigation.setTitle('User ' + userName);
- User.get(userName).then(user => {
+ Promise.all([
+ userTokenPromise,
+ User.get(userName)
+ ]).then(responses => {
+ const [userTokens, user] = responses;
const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any';
@@ -48,6 +69,7 @@ class UserController {
} else {
topNavigation.activate('users');
}
+
this._view = new UserView({
user: user,
section: section,
@@ -58,18 +80,51 @@ class UserController {
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`),
canEditAnything: api.hasPrivilege(`users:edit:${infix}`),
+ canListTokens: api.hasPrivilege(`userTokens:list:${infix}`),
+ canCreateToken: api.hasPrivilege(`userTokens:create:${infix}`),
+ canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`),
+ canDeleteToken: api.hasPrivilege(`userTokens:delete:${infix}`),
canDelete: api.hasPrivilege(`users:delete:${infix}`),
ranks: ranks,
+ tokens: userTokens,
});
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtUpdate(e));
this._view.addEventListener('delete', e => this._evtDelete(e));
+ this._view.addEventListener('create-token', e => this._evtCreateToken(e));
+ this._view.addEventListener('delete-token', e => this._evtDeleteToken(e));
+ this._view.addEventListener('update-token', e => this._evtUpdateToken(e));
+
+ for (let message of this._successMessages) {
+ this.showSuccess(message);
+ }
+
+ for (let message of this._errorMessages) {
+ this.showError(message);
+ }
+
}, error => {
this._view = new EmptyView();
this._view.showError(error.message);
});
}
+ showSuccess(message) {
+ if (typeof this._view === 'undefined') {
+ this._successMessages.push(message)
+ } else {
+ this._view.showSuccess(message);
+ }
+ }
+
+ showError(message) {
+ if (typeof this._view === 'undefined') {
+ this._errorMessages.push(message)
+ } else {
+ this._view.showError(message);
+ }
+ }
+
_evtChange(e) {
misc.enableExitConfirmation();
}
@@ -148,6 +203,53 @@ class UserController {
this._view.enableForm();
});
}
+
+ _evtCreateToken(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ UserToken.create(e.detail.user.name, e.detail.note, e.detail.expirationTime)
+ .then(response => {
+ const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
+ ctx.controller.showSuccess('Token ' + response.token + ' created.');
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+
+ _evtDeleteToken(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ if (api.isCurrentAuthToken(e.detail.userToken)) {
+ router.show(uri.formatClientLink('logout'));
+ } else {
+ e.detail.userToken.delete(e.detail.user.name)
+ .then(() => {
+ const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
+ ctx.controller.showSuccess('Token ' + e.detail.userToken.token + ' deleted.');
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+ }
+
+ _evtUpdateToken(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+
+ if (e.detail.note !== undefined) {
+ e.detail.userToken.note = e.detail.note;
+ }
+
+ e.detail.userToken.save(e.detail.user.name).then(response => {
+ const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
+ ctx.controller.showSuccess('Token ' + response.token + ' updated.');
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
}
module.exports = router => {
@@ -157,6 +259,9 @@ module.exports = router => {
router.enter(['user', ':name', 'edit'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'edit');
});
+ router.enter(['user', ':name', 'list-tokens'], (ctx, next) => {
+ ctx.controller = new UserController(ctx, 'list-tokens');
+ });
router.enter(['user', ':name', 'delete'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'delete');
});
diff --git a/client/js/models/user_token.js b/client/js/models/user_token.js
new file mode 100644
index 0000000..6e70a94
--- /dev/null
+++ b/client/js/models/user_token.js
@@ -0,0 +1,116 @@
+'use strict';
+
+const api = require('../api.js');
+const uri = require('../util/uri.js');
+const events = require('../events.js');
+
+class UserToken extends events.EventTarget {
+ constructor() {
+ super();
+ this._orig = {};
+ this._updateFromResponse({});
+ }
+
+ get token() { return this._token; }
+ get note() { return this._note; }
+ get enabled() { return this._enabled; }
+ get version() { return this._version; }
+ get expirationTime() { return this._expirationTime; }
+ get creationTime() { return this._creationTime; }
+ get lastEditTime() { return this._lastEditTime; }
+ get lastUsageTime() { return this._lastUsageTime; }
+
+ set note(value) { this._note = value; }
+
+ static fromResponse(response) {
+ if (typeof response.results !== 'undefined') {
+ let tokenList = [];
+ for (let responseToken of response.results) {
+ const token = new UserToken();
+ token._updateFromResponse(responseToken);
+ tokenList.push(token)
+ }
+ return tokenList;
+ } else {
+ const ret = new UserToken();
+ ret._updateFromResponse(response);
+ return ret;
+ }
+ }
+
+ static get(userName) {
+ return api.get(uri.formatApiLink('user-tokens', userName))
+ .then(response => {
+ return Promise.resolve(UserToken.fromResponse(response));
+ });
+ }
+
+ static create(userName, note, expirationTime) {
+ let userTokenRequest = {
+ enabled: true
+ };
+ if (note) {
+ userTokenRequest.note = note;
+ }
+ if (expirationTime) {
+ userTokenRequest.expirationTime = expirationTime;
+ }
+ return api.post(uri.formatApiLink('user-token', userName), userTokenRequest)
+ .then(response => {
+ return Promise.resolve(UserToken.fromResponse(response))
+ });
+ }
+
+ save(userName) {
+ const detail = {version: this._version};
+
+ if (this._note !== this._orig._note) {
+ detail.note = this._note;
+ }
+
+ return api.put(
+ uri.formatApiLink('user-token', userName, this._orig._token),
+ detail)
+ .then(response => {
+ this._updateFromResponse(response);
+ this.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ userToken: this,
+ },
+ }));
+ return Promise.resolve(this);
+ });
+ }
+
+ delete(userName) {
+ return api.delete(
+ uri.formatApiLink('user-token', userName, this._orig._token),
+ {version: this._version})
+ .then(response => {
+ this.dispatchEvent(new CustomEvent('delete', {
+ detail: {
+ userToken: this,
+ },
+ }));
+ return Promise.resolve();
+ });
+ }
+
+ _updateFromResponse(response) {
+ const map = {
+ _token: response.token,
+ _note: response.note,
+ _enabled: response.enabled,
+ _expirationTime: response.expirationTime,
+ _version: response.version,
+ _creationTime: response.creationTime,
+ _lastEditTime: response.lastEditTime,
+ _lastUsageTime: response.lastUsageTime,
+ };
+
+ Object.assign(this, map);
+ Object.assign(this._orig, map);
+ }
+}
+
+module.exports = UserToken;
diff --git a/client/js/util/polyfill.js b/client/js/util/polyfill.js
index 91186b2..71ee972 100644
--- a/client/js/util/polyfill.js
+++ b/client/js/util/polyfill.js
@@ -59,3 +59,10 @@ Number.prototype.between = function(a, b, inclusive) {
// non standard
Promise.prototype.abort = () => {};
+
+// non standard
+Date.prototype.addDays = function(days) {
+ let dat = new Date(this.valueOf());
+ dat.setDate(dat.getDate() + days);
+ return dat;
+};
diff --git a/client/js/util/views.js b/client/js/util/views.js
index b0b7cce..9f238b1 100644
--- a/client/js/util/views.js
+++ b/client/js/util/views.js
@@ -168,6 +168,11 @@ function makeNumericInput(options) {
return makeInput(options);
}
+function makeDateInput(options) {
+ options.type = 'date';
+ return makeInput(options)
+}
+
function getPostUrl(id, parameters) {
return uri.formatClientLink(
'post', id,
@@ -392,6 +397,7 @@ function getTemplate(templatePath) {
makePasswordInput: makePasswordInput,
makeEmailInput: makeEmailInput,
makeColorInput: makeColorInput,
+ makeDateInput: makeDateInput,
makePostLink: makePostLink,
makeTagLink: makeTagLink,
makeUserLink: makeUserLink,
diff --git a/client/js/views/user_tokens_view.js b/client/js/views/user_tokens_view.js
new file mode 100644
index 0000000..f6c8480
--- /dev/null
+++ b/client/js/views/user_tokens_view.js
@@ -0,0 +1,134 @@
+'use strict';
+
+const events = require('../events.js');
+const views = require('../util/views.js');
+
+const template = views.getTemplate('user-tokens');
+
+class UserTokenView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._user = ctx.user;
+ this._tokens = ctx.tokens;
+ this._hostNode = ctx.hostNode;
+ this._tokenFormNodes = [];
+ views.replaceContent(this._hostNode, template(ctx));
+ views.decorateValidator(this._formNode);
+
+ this._formNode.addEventListener('submit', e => this._evtSubmit(e));
+
+ this._decorateTokenForms();
+ this._decorateTokenNoteChangeLinks();
+ }
+
+ _decorateTokenForms() {
+ this._tokenFormNodes = [];
+ for (let i = 0; i < this._tokens.length; i++) {
+ let formNode = this._hostNode.querySelector(
+ '.token[data-token-id=\"' + i + '\"]');
+ formNode.addEventListener('submit', e => this._evtDelete(e));
+ this._tokenFormNodes.push(formNode);
+ }
+ }
+
+ _decorateTokenNoteChangeLinks() {
+ for (let i = 0; i < this._tokens.length; i++) {
+ let linkNode = this._hostNode.querySelector(
+ '.token-change-note[data-token-id=\"' + i + '\"]');
+ linkNode.addEventListener(
+ 'click', e => this._evtChangeNoteClick(e));
+ }
+ }
+
+ clearMessages() {
+ views.clearMessages(this._hostNode);
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+
+ enableForm() {
+ views.enableForm(this._formNode);
+ for (let formNode of this._tokenFormNodes) {
+ views.enableForm(formNode);
+ }
+ }
+
+ disableForm() {
+ views.disableForm(this._formNode);
+ for (let formNode of this._tokenFormNodes) {
+ views.disableForm(formNode);
+ }
+ }
+
+ _evtDelete(e) {
+ e.preventDefault();
+ const userToken = this._tokens[parseInt(
+ e.target.getAttribute('data-token-id'))];
+ this.dispatchEvent(new CustomEvent('delete', {
+ detail: {
+ user: this._user,
+ userToken: userToken,
+ },
+ }));
+ }
+
+ _evtSubmit(e) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent('submit', {
+ detail: {
+ user: this._user,
+
+ note: this._userTokenNoteInputNode ?
+ this._userTokenNoteInputNode.value :
+ undefined,
+
+ expirationTime:
+ (this._userTokenExpirationTimeInputNode
+ && this._userTokenExpirationTimeInputNode.value) ?
+ new Date(this._userTokenExpirationTimeInputNode.value)
+ .toISOString() :
+ undefined,
+ },
+ }));
+ }
+
+ _evtChangeNoteClick(e) {
+ e.preventDefault();
+ const userToken = this._tokens[
+ parseInt(e.target.getAttribute('data-token-id'))];
+ const text = window.prompt(
+ 'Please enter the new name:',
+ userToken.note !== null ? userToken.note : undefined);
+ if (!text) {
+ return;
+ }
+ this.dispatchEvent(new CustomEvent('update', {
+ detail: {
+ user: this._user,
+ userToken: userToken,
+ note: text ? text : undefined,
+ },
+ }));
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector('#create-token-form');
+ }
+
+ get _userTokenNoteInputNode() {
+ return this._formNode.querySelector('.note input');
+ }
+
+ get _userTokenExpirationTimeInputNode() {
+ return this._formNode.querySelector('.expirationTime input');
+ }
+}
+
+module.exports = UserTokenView;
diff --git a/client/js/views/user_view.js b/client/js/views/user_view.js
index d52e899..75fd154 100644
--- a/client/js/views/user_view.js
+++ b/client/js/views/user_view.js
@@ -3,6 +3,7 @@
const events = require('../events.js');
const views = require('../util/views.js');
const UserDeleteView = require('./user_delete_view.js');
+const UserTokensView = require('./user_tokens_view.js');
const UserSummaryView = require('./user_summary_view.js');
const UserEditView = require('./user_edit_view.js');
const EmptyView = require('../views/empty_view.js');
@@ -45,7 +46,17 @@ class UserView extends events.EventTarget {
this._view = new UserEditView(ctx);
events.proxyEvent(this._view, this, 'submit');
}
-
+ } else if (ctx.section == 'list-tokens') {
+ if (!this._ctx.canListTokens) {
+ this._view = new EmptyView();
+ this._view.showError(
+ 'You don\'t have privileges to view user tokens.');
+ } else {
+ this._view = new UserTokensView(ctx);
+ events.proxyEvent(this._view, this, 'delete', 'delete-token');
+ events.proxyEvent(this._view, this, 'submit', 'create-token');
+ events.proxyEvent(this._view, this, 'update', 'update-token');
+ }
} else if (ctx.section == 'delete') {
if (!this._ctx.canDelete) {
this._view = new EmptyView();

© 2015 - 2026 Jakob L. Kreuze