diff --git a/API.md b/API.md index 11e0d6c9..4098a83a 100644 --- a/API.md +++ b/API.md @@ -1522,9 +1522,9 @@ data. ```json5 { - "enabled": , // optional - "note": , // optional - "expirationTime": , // optional + "enabled": , // optional + "note": , // optional + "expirationTime": , // optional } ``` @@ -1842,14 +1842,15 @@ A single user token. ```json5 { - "user": , - "token": , - "note": , - "enabled": , - "expirationTime": , - "version": , - "creationTime": , - "lastEditTime": , + "user": , + "token": , + "note": , + "enabled": , + "expirationTime": , + "version": , + "creationTime": , + "lastEditTime": , + "lastUsageTime": , } ``` @@ -1862,6 +1863,7 @@ A single user token. - ``: resource version. See [versioning](#versioning). - ``: time the user token was created , formatted as per RFC 3339. - ``: time the user token was edited, formatted as per RFC 3339. +- ``: the last time this token was used during a login involving `?bump-login`, formatted as per RFC 3339. ## Tag category **Description** diff --git a/client/css/user-view.styl b/client/css/user-view.styl index 3640cac9..64a01b52 100644 --- a/client/css/user-view.styl +++ b/client/css/user-view.styl @@ -1,3 +1,6 @@ +@import colors +$token-border-color = $active-tab-background-color + #user width: 100% max-width: 35em @@ -76,7 +79,7 @@ padding-right: 0.5em hr - border-top: 1px #aaa solid; + border-top: 3px solid $token-border-color #user-delete form width: 100% diff --git a/client/html/user.tpl b/client/html/user.tpl index cbf4f18f..75c721f3 100644 --- a/client/html/user.tpl +++ b/client/html/user.tpl @@ -4,13 +4,13 @@ --> diff --git a/client/html/user_tokens.tpl b/client/html/user_tokens.tpl index 85996f0f..8a37aca2 100644 --- a/client/html/user_tokens.tpl +++ b/client/html/user_tokens.tpl @@ -9,6 +9,7 @@
Note:
Created:
Expires:
+
Used:
<%= token.token %>
@@ -19,6 +20,7 @@ <% } else { %>
No expiration
<% } %> +
<%= ctx.makeRelativeTime(token.lastUsageTime) %>
diff --git a/client/js/models/user_token.js b/client/js/models/user_token.js index 613eb4b4..fcb374f4 100644 --- a/client/js/models/user_token.js +++ b/client/js/models/user_token.js @@ -17,6 +17,8 @@ class UserToken extends events.EventTarget { 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; } static fromResponse(response) { if (typeof response.results !== 'undefined') { @@ -73,12 +75,14 @@ class UserToken extends events.EventTarget { _updateFromResponse(response) { const map = { - _token: response.token, - _note: response.note, - _enabled: response.enabled, - _expirationTime: response.expirationTime, - _version: response.version, - _creationTime: response.creationTime, + _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); diff --git a/server/szurubooru/func/user_tokens.py b/server/szurubooru/func/user_tokens.py index e9d926c5..8cd93dab 100644 --- a/server/szurubooru/func/user_tokens.py +++ b/server/szurubooru/func/user_tokens.py @@ -35,6 +35,7 @@ class UserTokenSerializer(serialization.BaseSerializer): 'expirationTime': self.serialize_expiration_time, 'creationTime': self.serialize_creation_time, 'lastEditTime': self.serialize_last_edit_time, + 'lastUsageTime': self.serialize_last_usage_time, 'version': self.serialize_version, } @@ -47,6 +48,9 @@ class UserTokenSerializer(serialization.BaseSerializer): def serialize_last_edit_time(self) -> Any: return self.user_token.last_edit_time + def serialize_last_usage_time(self) -> Any: + return self.user_token.last_usage_time + def serialize_token(self) -> Any: return self.user_token.token @@ -98,6 +102,7 @@ def create_user_token(user: model.User, enabled: bool) -> model.UserToken: user_token.token = auth.generate_authorization_token() user_token.enabled = enabled user_token.creation_time = datetime.utcnow() + user_token.last_usage_time = datetime.utcnow() return user_token @@ -107,6 +112,7 @@ def update_user_token_enabled( if enabled is None: raise InvalidEnabledError('Enabled cannot be empty.') user_token.enabled = enabled + update_user_token_edit_time(user_token) def update_user_token_edit_time(user_token: model.UserToken) -> None: @@ -124,6 +130,7 @@ def update_user_token_expiration_time( raise InvalidExpirationError( 'Expiration cannot happen in the past') user_token.expiration_time = expiration_time + update_user_token_edit_time(user_token) except ValueError: raise InvalidExpirationError( 'Expiration is in an invalid format {}'.format( @@ -136,3 +143,9 @@ def update_user_token_note(user_token: model.UserToken, note: str) -> None: if util.value_exceeds_column_size(note, model.UserToken.note): raise InvalidNoteError('Note is too long.') user_token.note = note + update_user_token_edit_time(user_token) + + +def bump_usage_time(user_token: model.UserToken) -> None: + assert user_token + user_token.last_usage_time = datetime.utcnow() diff --git a/server/szurubooru/middleware/authenticator.py b/server/szurubooru/middleware/authenticator.py index cf8555e5..b185dad6 100644 --- a/server/szurubooru/middleware/authenticator.py +++ b/server/szurubooru/middleware/authenticator.py @@ -1,5 +1,5 @@ import base64 -from typing import Optional +from typing import Optional, Tuple from szurubooru import db, model, errors, rest from szurubooru.func import auth, users, user_tokens from szurubooru.rest.errors import HttpBadRequest @@ -13,29 +13,32 @@ def _authenticate_basic_auth(username: str, password: str) -> model.User: return user -def _authenticate_token(username: str, token: str) -> model.User: +def _authenticate_token( + username: str, token: str) -> Tuple[model.User, model.UserToken]: ''' Try to authenticate user. Throw AuthError for invalid users. ''' user = users.get_user_by_name(username) user_token = user_tokens.get_by_user_and_token(user, token) if not auth.is_valid_token(user_token): raise errors.AuthError('Invalid token.') - return user + return user, user_token -def _get_user(ctx: rest.Context) -> Optional[model.User]: +def _get_user(ctx: rest.Context, bump_login: bool) -> Optional[model.User]: if not ctx.has_header('Authorization'): return None + auth_token = None + try: auth_type, credentials = ctx.get_header('Authorization').split(' ', 1) if auth_type.lower() == 'basic': username, password = base64.decodebytes( credentials.encode('ascii')).decode('utf8').split(':', 1) - return _authenticate_basic_auth(username, password) + auth_user = _authenticate_basic_auth(username, password) elif auth_type.lower() == 'token': username, token = base64.decodebytes( credentials.encode('ascii')).decode('utf8').split(':', 1) - return _authenticate_token(username, token) + auth_user, auth_token = _authenticate_token(username, token) else: raise HttpBadRequest( 'ValidationError', @@ -48,15 +51,21 @@ def _get_user(ctx: rest.Context) -> Optional[model.User]: 'ValidationError', msg.format(ctx.get_header('Authorization'), str(err))) + if bump_login and auth_user.user_id: + users.bump_user_login_time(auth_user) + if auth_token is not None: + user_tokens.bump_usage_time(auth_token) + ctx.session.commit() + + return auth_user + def process_request(ctx: rest.Context) -> None: ''' Bind the user to request. Update last login time if needed. ''' - auth_user = _get_user(ctx) + bump_login = ctx.get_param_as_bool('bump-login', default=False) + auth_user = _get_user(ctx, bump_login) if auth_user: ctx.user = auth_user - if ctx.get_param_as_bool('bump-login', default=False) and ctx.user.user_id: - users.bump_user_login_time(ctx.user) - ctx.session.commit() @rest.middleware.pre_hook 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 53f7ff5b..6978e829 100644 --- a/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py +++ b/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py @@ -25,6 +25,7 @@ def upgrade(): 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('last_usage_time', sa.DateTime(), nullable=True), sa.Column('version', sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ['user_id'], diff --git a/server/szurubooru/model/user.py b/server/szurubooru/model/user.py index d5397189..2d599e85 100644 --- a/server/szurubooru/model/user.py +++ b/server/szurubooru/model/user.py @@ -104,6 +104,7 @@ class UserToken(Base): 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) + last_usage_time = sa.Column('last_usage_time', sa.DateTime) version = sa.Column('version', sa.Integer, default=1, nullable=False) user = sa.orm.relationship('User') diff --git a/server/szurubooru/tests/func/test_user_tokens.py b/server/szurubooru/tests/func/test_user_tokens.py index 7e101ebc..8c3577c8 100644 --- a/server/szurubooru/tests/func/test_user_tokens.py +++ b/server/szurubooru/tests/func/test_user_tokens.py @@ -20,6 +20,7 @@ def test_serialize_user_token(user_token_factory): 'enabled': True, 'expirationTime': None, 'lastEditTime': None, + 'lastUsageTime': None, 'note': None, 'token': 'dummy', 'user': { @@ -71,6 +72,7 @@ def test_update_user_token_enabled(user_token_factory): user_token = user_token_factory() user_tokens.update_user_token_enabled(user_token, False) assert user_token.enabled is False + assert user_token.last_edit_time is not None def test_update_user_token_edit_time(user_token_factory): @@ -85,6 +87,7 @@ def test_update_user_token_note(user_token_factory): assert user_token.note is None user_tokens.update_user_token_note(user_token, ' Test Note ') assert user_token.note == 'Test Note' + assert user_token.last_edit_time is not None def test_update_user_token_note_input_too_long(user_token_factory): @@ -107,6 +110,7 @@ def test_update_user_token_expiration_time(user_token_factory): user_tokens.update_user_token_expiration_time( user_token, expiration_time_str) assert user_token.expiration_time.isoformat() == expiration_time_str + assert user_token.last_edit_time is not None def test_update_user_token_expiration_time_in_past(user_token_factory): @@ -142,3 +146,10 @@ def test_update_user_token_expiration_time_invalid_format( % expiration_time_str): user_tokens.update_user_token_expiration_time( user_token, expiration_time_str) + + +def test_bump_usage_time(user_token_factory, fake_datetime): + user_token = user_token_factory() + with fake_datetime('1997-01-01'): + user_tokens.bump_usage_time(user_token) + assert user_token.last_usage_time == datetime(1997, 1, 1) diff --git a/server/szurubooru/tests/middleware/test_authenticator.py b/server/szurubooru/tests/middleware/test_authenticator.py index 11b824d3..36e74adf 100644 --- a/server/szurubooru/tests/middleware/test_authenticator.py +++ b/server/szurubooru/tests/middleware/test_authenticator.py @@ -1,5 +1,6 @@ from unittest.mock import patch import pytest +from szurubooru import db from szurubooru.func import auth, users, user_tokens from szurubooru.middleware import authenticator from szurubooru.rest import errors @@ -11,6 +12,48 @@ def test_process_request_no_header(context_factory): assert ctx.user.name is None +def test_process_request_bump_login(context_factory, user_factory): + user = user_factory() + db.session.add(user) + db.session.flush() + ctx = context_factory( + headers={ + 'Authorization': "Basic dGVzdFVzZXI6dGVzdFRva2Vu" + }, + params={ + 'bump-login': 'true' + }) + with patch('szurubooru.func.auth.is_valid_password'), \ + patch('szurubooru.func.users.get_user_by_name'): + users.get_user_by_name.return_value = user + auth.is_valid_password.return_value = True + authenticator.process_request(ctx) + assert user.last_login_time is not None + + +def test_process_request_bump_login_with_token( + context_factory, user_token_factory): + user_token = user_token_factory() + db.session.add(user_token) + db.session.flush() + ctx = context_factory( + headers={ + 'Authorization': "Token dGVzdFVzZXI6dGVzdFRva2Vu" + }, + params={ + 'bump-login': 'true' + }) + with patch('szurubooru.func.auth.is_valid_token'), \ + patch('szurubooru.func.users.get_user_by_name'), \ + patch('szurubooru.func.user_tokens.get_by_user_and_token'): + users.get_user_by_name.return_value = user_token.user + user_tokens.get_by_user_and_token.return_value = user_token + auth.is_valid_token.return_value = True + authenticator.process_request(ctx) + assert user_token.user.last_login_time is not None + assert user_token.last_usage_time is not None + + def test_process_request_basic_auth_valid(context_factory, user_factory): user = user_factory() ctx = context_factory( @@ -36,7 +79,7 @@ def test_process_request_token_auth_valid(context_factory, user_token_factory): patch('szurubooru.func.user_tokens.get_by_user_and_token'): users.get_user_by_name.return_value = user_token.user user_tokens.get_by_user_and_token.return_value = user_token - auth.is_valid_password.return_value = True + auth.is_valid_token.return_value = True authenticator.process_request(ctx) assert ctx.user == user_token.user