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
This commit is contained in:
ReAnzu
2018-02-25 04:44:02 -06:00
committed by rr-
parent e35e709927
commit 2a69f0193f
36 changed files with 1609 additions and 40 deletions

View File

@ -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,11 +88,76 @@ 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();
}
loginWithToken(userName, token, doRemember) {
this.cache = {};
return new Promise((resolve, reject) => {
this.userName = userName;
this.token = token;
this.get('/user/' + userName + '?bump-login=true')
.then(response => {
const options = {};
if (doRemember) {
options.expires = 365;
}
cookies.set(
'auth',
{'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) => {
@ -103,10 +169,7 @@ class Api extends events.EventTarget {
if (doRemember) {
options.expires = 365;
}
cookies.set(
'auth',
{'user': userName, 'password': userPassword},
options);
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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