diff options
| author | ReAnzu | 2018-02-25 04:44:02 -0600 |
|---|---|---|
| committer | rr- | 2018-03-25 22:23:29 +0200 |
| commit | 2a69f0193f9e5aa451e0dbcb8c89070f3e49ffa5 (patch) | |
| tree | bda640614ae711e8fef0e0463f118f85ef83ac0e /client/js | |
| parent | e35e70992736340bff3995ca6f425baf2f431e2f (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.js | 94 | ||||
| -rw-r--r-- | client/js/controllers/user_controller.js | 107 | ||||
| -rw-r--r-- | client/js/models/user_token.js | 116 | ||||
| -rw-r--r-- | client/js/util/polyfill.js | 7 | ||||
| -rw-r--r-- | client/js/util/views.js | 6 | ||||
| -rw-r--r-- | client/js/views/user_tokens_view.js | 134 | ||||
| -rw-r--r-- | client/js/views/user_view.js | 13 |
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(); |