Added note and expiration fields to the user_token model

* Updated UI to show more information about the token.
* Updated the js API to note the client token when creating it.
* Added prototype override to do add day calculations on dates.
* Updated auth check against token to inspect the expiration date of the token if it possesses one.
This commit is contained in:
ReAnzu 2018-03-02 02:37:31 -06:00
parent 87c9c27fba
commit 606ef31b01
12 changed files with 216 additions and 43 deletions

View file

@ -38,23 +38,43 @@
clear: both clear: both
#user-tokens #user-tokens
.flex-centered
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-around;
.token-flex-container .token-flex-container
width: 100% width: 100%
display: flex; display: flex;
flex-direction column; flex-direction column;
padding-bottom: 0.5em; padding-bottom: 0.5em;
.floor
border-bottom: black solid 1px;
.token-info
min-width: 75%;
.token-actions
max-width: 25%;
justify-content: end;
.token-flex-row .token-flex-row
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
padding-top: 0.25em; padding-top: 0.25em;
padding-bottom: 0.25em; padding-bottom: 0.25em;
border-bottom: black solid 1px;
form form
width: auto; width: auto;
.token-flex-column
display: flex;
flex-direction: column;
#user-delete form #user-delete form
width: 100% width: 100%

View file

@ -2,18 +2,35 @@
<div class='messages'></div> <div class='messages'></div>
<% if (ctx.tokens.length > 0) { %> <% if (ctx.tokens.length > 0) { %>
<div class="token-flex-container"> <div class="token-flex-container">
<div class="token-flex-row">
<div>Token</div>
<div>Actions</div>
</div>
<% _.each(ctx.tokens, function(token, index) { %> <% _.each(ctx.tokens, function(token, index) { %>
<div class="token-flex-row"> <div class="token-flex-row floor">
<div><%= token.token %></div> <div class="token-flex-column token-info">
<div> <div class="token-flex-row">
<form class='token' data-token-id='<%= index %>'> <div>Token:</div>
<input type='hidden' name='token' value='<%= token.token %>'/> <div><%= token.token %></div>
<input type='submit' value='Delete token'/> </div>
</form> <div class="token-flex-row">
<div>Note:</div>
<div><%= token.note %></div>
</div>
<div class="token-flex-row">
<div>Created:</div>
<div><%= new Date(token.creationTime).toLocaleDateString() %></div>
</div>
<% if (token.expirationTime) { %>
<div class="token-flex-row">
<div>Expires:</div>
<div><%= new Date(token.expirationTime).toLocaleDateString() %></div>
</div>
<% } %>
</div>
<div class="token-flex-column token-actions">
<div>
<form class='token' data-token-id='<%= index %>'>
<input type='hidden' name='token' value='<%= token.token %>'/>
<input type='submit' value='Delete token'/>
</form>
</div>
</div> </div>
</div> </div>
<% }); %> <% }); %>
@ -21,9 +38,23 @@
<% } else { %> <% } else { %>
<h2>No Registered Tokens</h2> <h2>No Registered Tokens</h2>
<% } %> <% } %>
<form id='create-token-form'> <div class='flex-centered'>
<div class='buttons'> <form id='create-token-form'>
<input type='submit' value='Create token'/> <div class="token-flex-container">
</div> <div class="token-flex-row">
</form> <div>Note:</div>
<div><input name='note', type='textbox'/></div>
</div>
<div class="token-flex-row">
<div>Expiration:</div>
<div><input name='expirationTime' type='date'/></div>
</div>
<div class="token-flex-row" style='justify-content: end;'>
<div class='buttons'>
<input type='submit' value='Create token'/>
</div>
</div>
</div>
</form>
</div>
</div> </div>

View file

@ -1,5 +1,6 @@
'use strict'; 'use strict';
require('./util/date.js');
const cookies = require('js-cookie'); const cookies = require('js-cookie');
const request = require('superagent'); const request = require('superagent');
const config = require('./config.js'); const config = require('./config.js');
@ -119,8 +120,15 @@ class Api extends events.EventTarget {
} }
createToken(userName, options) { createToken(userName, options) {
let userTokenRequest = {
enabled: true,
note: 'Client Login Token'
};
if (typeof options.expires !== 'undefined') {
userTokenRequest.expirationTime = new Date().addDays(options.expires).toISOString()
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.post('/user-token/' + userName, {}) this.post('/user-token/' + userName, userTokenRequest)
.then(response => { .then(response => {
cookies.set( cookies.set(
'auth', 'auth',
@ -327,7 +335,8 @@ class Api extends events.EventTarget {
try { try {
if (this.userName && this.userToken) { if (this.userName && this.userToken) {
req.auth = null; req.auth = null;
req.set('Authorization', 'Token ' + new Buffer(this.userName + ":" + this.userToken).toString('base64')) req.set('Authorization', 'Token '
+ new Buffer(this.userName + ":" + this.userToken).toString('base64'))
} }
else if (this.userName && this.userPassword) { else if (this.userName && this.userPassword) {
req.auth( req.auth(

View file

@ -199,7 +199,7 @@ class UserController {
_evtCreateToken(e) { _evtCreateToken(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
UserToken.create(e.detail.user.name) UserToken.create(e.detail.user.name, e.detail.note, e.detail.expirationTime)
.then(response => { .then(response => {
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens')); const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
ctx.controller.showSuccess('Token ' + response.token + ' created.'); ctx.controller.showSuccess('Token ' + response.token + ' created.');

View file

@ -11,10 +11,12 @@ class UserToken extends events.EventTarget {
this._updateFromResponse({}); this._updateFromResponse({});
} }
get token() { return this._token; } get token() { return this._token; }
get enabled() { return this._enabled; } get note() { return this._note; }
get version() { return this._version; } get enabled() { return this._enabled; }
get creationTime() { return this._creationTime; } get version() { return this._version; }
get expirationTime() { return this._expirationTime; }
get creationTime() { return this._creationTime; }
static fromResponse(response) { static fromResponse(response) {
if (typeof response.results !== 'undefined') { if (typeof response.results !== 'undefined') {
@ -39,8 +41,17 @@ class UserToken extends events.EventTarget {
}); });
} }
static create(userName) { static create(userName, note, expirationTime) {
return api.post(uri.formatApiLink('user-token', userName)) 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 => { .then(response => {
return Promise.resolve(UserToken.fromResponse(response)) return Promise.resolve(UserToken.fromResponse(response))
}); });
@ -62,10 +73,12 @@ class UserToken extends events.EventTarget {
_updateFromResponse(response) { _updateFromResponse(response) {
const map = { const map = {
_token: response.token, _token: response.token,
_enabled: response.enabled, _note: response.note,
_version: response.version, _enabled: response.enabled,
_creationTime: response.creationTime, _expirationTime: response.expirationTime,
_version: response.version,
_creationTime: response.creationTime,
}; };
Object.assign(this, map); Object.assign(this, map);

7
client/js/util/date.js Normal file
View file

@ -0,0 +1,7 @@
'use strict';
Date.prototype.addDays = function(days) {
let dat = new Date(this.valueOf());
dat.setDate(dat.getDate() + days);
return dat;
};

View file

@ -72,7 +72,16 @@ class UserTokenView extends events.EventTarget {
e.preventDefault(); e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', { this.dispatchEvent(new CustomEvent('submit', {
detail: { detail: {
user: this._user user: this._user,
note: this._userTokenNoteInputNode ?
this._userTokenNoteInputNode.value :
undefined,
expirationTime: this._userTokenExpirationTimeInputNode && this._userTokenExpirationTimeInputNode.value.length > 0 ?
new Date(this._userTokenExpirationTimeInputNode.value).toISOString() :
undefined,
}, },
})); }));
} }
@ -80,6 +89,14 @@ class UserTokenView extends events.EventTarget {
get _formNode() { get _formNode() {
return this._hostNode.querySelector('#create-token-form'); return this._hostNode.querySelector('#create-token-form');
} }
get _userTokenNoteInputNode() {
return this._formNode.querySelector('[name=note]');
}
get _userTokenExpirationTimeInputNode() {
return this._formNode.querySelector('[name=expirationTime]');
}
} }
module.exports = UserTokenView; module.exports = UserTokenView;

View file

@ -30,7 +30,17 @@ def create_user_token(
user = users.get_user_by_name(params['user_name']) user = users.get_user_by_name(params['user_name'])
infix = 'self' if ctx.user.user_id == user.user_id else 'any' infix = 'self' if ctx.user.user_id == user.user_id else 'any'
auth.verify_privilege(ctx.user, 'user_tokens:create:%s' % infix) auth.verify_privilege(ctx.user, 'user_tokens:create:%s' % infix)
user_token = user_tokens.create_user_token(user) enabled = ctx.get_param_as_bool('enabled', True)
user_token = user_tokens.create_user_token(user, enabled)
if ctx.has_param('note'):
note = ctx.get_param_as_string('note')
user_tokens.update_user_token_note(user_token, note)
if ctx.has_param('expirationTime'):
expiration_time = ctx.get_param_as_string('expirationTime')
user_tokens.update_user_token_expiration_time(user_token,
expiration_time)
ctx.session.add(user_token)
ctx.session.commit()
return _serialize(ctx, user_token) return _serialize(ctx, user_token)
@ -47,6 +57,15 @@ def update_user_token(
auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix) auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix)
user_tokens.update_user_token_enabled(user_token, user_tokens.update_user_token_enabled(user_token,
ctx.get_param_as_bool('enabled')) ctx.get_param_as_bool('enabled'))
if ctx.has_param('note'):
auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix)
note = ctx.get_param_as_string('note')
user_tokens.update_user_token_note(user_token, note)
if ctx.has_param('expirationTime'):
auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix)
expiration_time = ctx.get_param_as_string('expirationTime')
user_tokens.update_user_token_expiration_time(user_token,
expiration_time)
user_tokens.update_user_token_edit_time(user_token) user_tokens.update_user_token_edit_time(user_token)
ctx.session.commit() ctx.session.commit()
return _serialize(ctx, user_token) return _serialize(ctx, user_token)

View file

@ -1,12 +1,13 @@
from typing import Tuple from typing import Tuple
import hashlib import hashlib
import random import random
import uuid
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime
from nacl import pwhash from nacl import pwhash
from nacl.exceptions import InvalidkeyError from nacl.exceptions import InvalidkeyError
from szurubooru import config, model, errors, db from szurubooru import config, db, model, errors
from szurubooru.func import util from szurubooru.func import util
import uuid
RANK_MAP = OrderedDict([ RANK_MAP = OrderedDict([
@ -80,7 +81,15 @@ def is_valid_password(user: model.User, password: str) -> bool:
def is_valid_token(user_token: model.UserToken) -> bool: def is_valid_token(user_token: model.UserToken) -> bool:
return user_token is not None and user_token.enabled ''' Token must be enabled and if it has an expiration,
it must be greater than now. '''
assert user_token
if not user_token.enabled:
return False
if (user_token.expiration_time is not None
and user_token.expiration_time < datetime.utcnow()):
return False
return True
def has_privilege(user: model.User, privilege_name: str) -> bool: def has_privilege(user: model.User, privilege_name: str) -> bool:

View file

@ -1,10 +1,20 @@
import pytz
from dateutil import parser as dateutil_parser
from datetime import datetime from datetime import datetime
from typing import Any, Optional, List, Dict, Callable from typing import Any, Optional, List, Dict, Callable
from szurubooru import db, model, rest, errors from szurubooru import db, model, rest, errors
from szurubooru.func import auth, serialization, users from szurubooru.func import auth, serialization, users, util
class InvalidEnabledFieldError(errors.ValidationError): class InvalidEnabledError(errors.ValidationError):
pass
class InvalidExpirationError(errors.ValidationError):
pass
class InvalidNoteError(errors.ValidationError):
pass pass
@ -20,10 +30,12 @@ class UserTokenSerializer(serialization.BaseSerializer):
return { return {
'user': self.serialize_user, 'user': self.serialize_user,
'token': self.serialize_token, 'token': self.serialize_token,
'note': self.serialize_note,
'enabled': self.serialize_enabled, 'enabled': self.serialize_enabled,
'version': self.serialize_version, 'expirationTime': self.serialize_expiration_time,
'creationTime': self.serialize_creation_time, 'creationTime': self.serialize_creation_time,
'lastEditTime': self.serialize_last_edit_time, 'lastEditTime': self.serialize_last_edit_time,
'version': self.serialize_version,
} }
def serialize_user(self) -> Any: def serialize_user(self) -> Any:
@ -38,9 +50,15 @@ class UserTokenSerializer(serialization.BaseSerializer):
def serialize_token(self) -> Any: def serialize_token(self) -> Any:
return self.user_token.token return self.user_token.token
def serialize_note(self) -> Any:
return self.user_token.note
def serialize_enabled(self) -> Any: def serialize_enabled(self) -> Any:
return self.user_token.enabled return self.user_token.enabled
def serialize_expiration_time(self) -> Any:
return self.user_token.expiration_time
def serialize_version(self) -> Any: def serialize_version(self) -> Any:
return self.user_token.version return self.user_token.version
@ -69,15 +87,13 @@ def get_user_tokens(user: model.User) -> List[model.UserToken]:
.all()) .all())
def create_user_token(user: model.User) -> model.UserToken: def create_user_token(user: model.User, enabled: bool) -> model.UserToken:
assert user assert user
user_token = model.UserToken() user_token = model.UserToken()
user_token.user = user user_token.user = user
user_token.token = auth.generate_authorization_token() user_token.token = auth.generate_authorization_token()
user_token.enabled = True user_token.enabled = enabled
user_token.creation_time = datetime.utcnow() user_token.creation_time = datetime.utcnow()
db.session.add(user_token)
db.session.commit()
return user_token return user_token
@ -85,10 +101,38 @@ def update_user_token_enabled(
user_token: model.UserToken, enabled: bool) -> None: user_token: model.UserToken, enabled: bool) -> None:
assert user_token assert user_token
if enabled is None: if enabled is None:
raise InvalidEnabledFieldError('Enabled cannot be empty.') raise InvalidEnabledError('Enabled cannot be empty.')
user_token.enabled = enabled user_token.enabled = enabled
def update_user_token_edit_time(user_token: model.UserToken) -> None: def update_user_token_edit_time(user_token: model.UserToken) -> None:
assert user_token assert user_token
user_token.last_edit_time = datetime.utcnow() user_token.last_edit_time = datetime.utcnow()
def update_user_token_expiration_time(
user_token: model.UserToken, expiration_time: str) -> None:
assert user_token
if expiration_time is not None:
try:
expiration_time = dateutil_parser.parse(expiration_time)
except ValueError:
raise InvalidExpirationError(
'Expiration is in invalid format {}'.format(expiration_time))
if expiration_time.tzinfo is None:
raise InvalidExpirationError(
'Expiration cannot be missing timezone')
else:
expiration_time = expiration_time.astimezone(pytz.UTC)
if expiration_time < datetime.utcnow().astimezone(pytz.UTC):
raise InvalidExpirationError(
'Expiration cannot happen in the past')
user_token.expiration_time = expiration_time
def update_user_token_note(user_token: model.UserToken, note: str) -> None:
assert user_token
note = note.strip() if note is not None else ''
if util.value_exceeds_column_size(note, model.UserToken.note):
raise InvalidNoteError('Note is too long.')
user_token.note = note

View file

@ -20,7 +20,9 @@ def upgrade():
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token', sa.Unicode(length=36), nullable=False), sa.Column('token', sa.Unicode(length=36), nullable=False),
sa.Column('note', sa.Unicode(length=128), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=False), sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('expiration_time', sa.DateTime(), nullable=True),
sa.Column('creation_time', sa.DateTime(), nullable=False), sa.Column('creation_time', sa.DateTime(), nullable=False),
sa.Column('last_edit_time', sa.DateTime(), nullable=True), sa.Column('last_edit_time', sa.DateTime(), nullable=True),
sa.Column('version', sa.Integer(), nullable=False), sa.Column('version', sa.Integer(), nullable=False),

View file

@ -99,7 +99,9 @@ class UserToken(Base):
nullable=False, nullable=False,
index=True) index=True)
token = sa.Column('token', sa.Unicode(36), nullable=False) token = sa.Column('token', sa.Unicode(36), nullable=False)
note = sa.Column('note', sa.Unicode(128), nullable=True)
enabled = sa.Column('enabled', sa.Boolean, nullable=False, default=True) enabled = sa.Column('enabled', sa.Boolean, nullable=False, default=True)
expiration_time = sa.Column('expiration_time', sa.DateTime, nullable=True)
creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) creation_time = sa.Column('creation_time', sa.DateTime, nullable=False)
last_edit_time = sa.Column('last_edit_time', sa.DateTime) last_edit_time = sa.Column('last_edit_time', sa.DateTime)
version = sa.Column('version', sa.Integer, default=1, nullable=False) version = sa.Column('version', sa.Integer, default=1, nullable=False)