diff --git a/client/css/user-view.styl b/client/css/user-view.styl index 46f3044a..9b10d260 100644 --- a/client/css/user-view.styl +++ b/client/css/user-view.styl @@ -38,23 +38,43 @@ clear: both #user-tokens + + .flex-centered + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-around; + .token-flex-container width: 100% display: flex; flex-direction column; 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 display: flex; flex-direction: row; justify-content: space-between; padding-top: 0.25em; padding-bottom: 0.25em; - border-bottom: black solid 1px; form width: auto; + .token-flex-column + display: flex; + flex-direction: column; + #user-delete form width: 100% diff --git a/client/html/user_tokens.tpl b/client/html/user_tokens.tpl index 03b3a163..a433d55c 100644 --- a/client/html/user_tokens.tpl +++ b/client/html/user_tokens.tpl @@ -2,18 +2,35 @@
<% if (ctx.tokens.length > 0) { %>
-
-
Token
-
Actions
-
<% _.each(ctx.tokens, function(token, index) { %> -
-
<%= token.token %>
-
-
- - -
+
+
+
+
Token:
+
<%= token.token %>
+
+
+
Note:
+
<%= token.note %>
+
+
+
Created:
+
<%= new Date(token.creationTime).toLocaleDateString() %>
+
+ <% if (token.expirationTime) { %> +
+
Expires:
+
<%= new Date(token.expirationTime).toLocaleDateString() %>
+
+ <% } %> +
+
+
+
+ + +
+
<% }); %> @@ -21,9 +38,23 @@ <% } else { %>

No Registered Tokens

<% } %> -
-
- -
-
+
+
+
+
+
Note:
+
+
+
+
Expiration:
+
+
+
+
+ +
+
+
+
+
diff --git a/client/js/api.js b/client/js/api.js index 637acdf9..ec1d9bcf 100644 --- a/client/js/api.js +++ b/client/js/api.js @@ -1,5 +1,6 @@ 'use strict'; +require('./util/date.js'); const cookies = require('js-cookie'); const request = require('superagent'); const config = require('./config.js'); @@ -119,8 +120,15 @@ class Api extends events.EventTarget { } 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) => { - this.post('/user-token/' + userName, {}) + this.post('/user-token/' + userName, userTokenRequest) .then(response => { cookies.set( 'auth', @@ -327,7 +335,8 @@ class Api extends events.EventTarget { try { if (this.userName && this.userToken) { 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) { req.auth( diff --git a/client/js/controllers/user_controller.js b/client/js/controllers/user_controller.js index 4a7c0221..df044177 100644 --- a/client/js/controllers/user_controller.js +++ b/client/js/controllers/user_controller.js @@ -199,7 +199,7 @@ class UserController { _evtCreateToken(e) { this._view.clearMessages(); this._view.disableForm(); - UserToken.create(e.detail.user.name) + 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.'); diff --git a/client/js/models/user_token.js b/client/js/models/user_token.js index 0ca1b9b9..93e0693a 100644 --- a/client/js/models/user_token.js +++ b/client/js/models/user_token.js @@ -11,10 +11,12 @@ class UserToken extends events.EventTarget { this._updateFromResponse({}); } - get token() { return this._token; } - get enabled() { return this._enabled; } - get version() { return this._version; } - get creationTime() { return this._creationTime; } + 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; } static fromResponse(response) { if (typeof response.results !== 'undefined') { @@ -39,8 +41,17 @@ class UserToken extends events.EventTarget { }); } - static create(userName) { - return api.post(uri.formatApiLink('user-token', userName)) + 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)) }); @@ -62,10 +73,12 @@ class UserToken extends events.EventTarget { _updateFromResponse(response) { const map = { - _token: response.token, - _enabled: response.enabled, - _version: response.version, - _creationTime: response.creationTime, + _token: response.token, + _note: response.note, + _enabled: response.enabled, + _expirationTime: response.expirationTime, + _version: response.version, + _creationTime: response.creationTime, }; Object.assign(this, map); diff --git a/client/js/util/date.js b/client/js/util/date.js new file mode 100644 index 00000000..b9744c31 --- /dev/null +++ b/client/js/util/date.js @@ -0,0 +1,7 @@ +'use strict'; + +Date.prototype.addDays = function(days) { + let dat = new Date(this.valueOf()); + dat.setDate(dat.getDate() + days); + return dat; +}; \ No newline at end of file diff --git a/client/js/views/user_tokens_view.js b/client/js/views/user_tokens_view.js index f98f8d8c..fa3daff3 100644 --- a/client/js/views/user_tokens_view.js +++ b/client/js/views/user_tokens_view.js @@ -72,7 +72,16 @@ class UserTokenView extends events.EventTarget { e.preventDefault(); this.dispatchEvent(new CustomEvent('submit', { 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() { 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; diff --git a/server/szurubooru/api/user_token_api.py b/server/szurubooru/api/user_token_api.py index 2323a97e..3f3031da 100644 --- a/server/szurubooru/api/user_token_api.py +++ b/server/szurubooru/api/user_token_api.py @@ -30,7 +30,17 @@ def create_user_token( user = users.get_user_by_name(params['user_name']) infix = 'self' if ctx.user.user_id == user.user_id else 'any' 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) @@ -47,6 +57,15 @@ def update_user_token( auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix) user_tokens.update_user_token_enabled(user_token, 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) ctx.session.commit() return _serialize(ctx, user_token) diff --git a/server/szurubooru/func/auth.py b/server/szurubooru/func/auth.py index 4e19f665..277d0b58 100644 --- a/server/szurubooru/func/auth.py +++ b/server/szurubooru/func/auth.py @@ -1,12 +1,13 @@ from typing import Tuple import hashlib import random +import uuid from collections import OrderedDict +from datetime import datetime from nacl import pwhash from nacl.exceptions import InvalidkeyError -from szurubooru import config, model, errors, db +from szurubooru import config, db, model, errors from szurubooru.func import util -import uuid 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: - 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: diff --git a/server/szurubooru/func/user_tokens.py b/server/szurubooru/func/user_tokens.py index 398c54a2..09ae8962 100644 --- a/server/szurubooru/func/user_tokens.py +++ b/server/szurubooru/func/user_tokens.py @@ -1,10 +1,20 @@ +import pytz +from dateutil import parser as dateutil_parser from datetime import datetime from typing import Any, Optional, List, Dict, Callable 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 @@ -20,10 +30,12 @@ class UserTokenSerializer(serialization.BaseSerializer): return { 'user': self.serialize_user, 'token': self.serialize_token, + 'note': self.serialize_note, 'enabled': self.serialize_enabled, - 'version': self.serialize_version, + 'expirationTime': self.serialize_expiration_time, 'creationTime': self.serialize_creation_time, 'lastEditTime': self.serialize_last_edit_time, + 'version': self.serialize_version, } def serialize_user(self) -> Any: @@ -38,9 +50,15 @@ class UserTokenSerializer(serialization.BaseSerializer): def serialize_token(self) -> Any: return self.user_token.token + def serialize_note(self) -> Any: + return self.user_token.note + def serialize_enabled(self) -> Any: return self.user_token.enabled + def serialize_expiration_time(self) -> Any: + return self.user_token.expiration_time + def serialize_version(self) -> Any: return self.user_token.version @@ -69,15 +87,13 @@ def get_user_tokens(user: model.User) -> List[model.UserToken]: .all()) -def create_user_token(user: model.User) -> model.UserToken: +def create_user_token(user: model.User, enabled: bool) -> model.UserToken: assert user user_token = model.UserToken() user_token.user = user user_token.token = auth.generate_authorization_token() - user_token.enabled = True + user_token.enabled = enabled user_token.creation_time = datetime.utcnow() - db.session.add(user_token) - db.session.commit() return user_token @@ -85,10 +101,38 @@ def update_user_token_enabled( user_token: model.UserToken, enabled: bool) -> None: assert user_token if enabled is None: - raise InvalidEnabledFieldError('Enabled cannot be empty.') + raise InvalidEnabledError('Enabled cannot be empty.') user_token.enabled = enabled def update_user_token_edit_time(user_token: model.UserToken) -> None: assert user_token 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 diff --git a/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py b/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py index 95286bbf..ded7298d 100644 --- a/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py +++ b/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py @@ -20,7 +20,9 @@ def upgrade(): sa.Column('id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), 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('expiration_time', sa.DateTime(), nullable=True), sa.Column('creation_time', sa.DateTime(), nullable=False), sa.Column('last_edit_time', sa.DateTime(), nullable=True), sa.Column('version', sa.Integer(), nullable=False), diff --git a/server/szurubooru/model/user.py b/server/szurubooru/model/user.py index a8eb86e7..d5397189 100644 --- a/server/szurubooru/model/user.py +++ b/server/szurubooru/model/user.py @@ -99,7 +99,9 @@ class UserToken(Base): nullable=False, index=True) 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) + expiration_time = sa.Column('expiration_time', sa.DateTime, nullable=True) creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) last_edit_time = sa.Column('last_edit_time', sa.DateTime) version = sa.Column('version', sa.Integer, default=1, nullable=False)