mirror of
https://github.com/rr-/szurubooru.git
synced 2025-07-17 08:26:24 +00:00
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:
@ -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)
|
||||
|
@ -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');
|
||||
});
|
||||
|
116
client/js/models/user_token.js
Normal file
116
client/js/models/user_token.js
Normal 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;
|
@ -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;
|
||||
};
|
||||
|
@ -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,
|
||||
|
134
client/js/views/user_tokens_view.js
Normal file
134
client/js/views/user_tokens_view.js
Normal 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;
|
@ -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();
|
||||
|
Reference in New Issue
Block a user