Implement last usage time
This commit is contained in:
parent
8d8477ee6b
commit
5ce8fab533
11 changed files with 121 additions and 32 deletions
2
API.md
2
API.md
|
@ -1850,6 +1850,7 @@ A single user token.
|
|||
"version": <version>,
|
||||
"creationTime": <creation-time>,
|
||||
"lastEditTime": <last-edit-time>,
|
||||
"lastUsageTime": <last-usage-time>,
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -1862,6 +1863,7 @@ A single user token.
|
|||
- `<version>`: resource version. See [versioning](#versioning).
|
||||
- `<creation-time>`: time the user token was created , formatted as per RFC 3339.
|
||||
- `<last-edit-time>`: time the user token was edited, formatted as per RFC 3339.
|
||||
- `<last-usage-time>`: the last time this token was used during a login involving `?bump-login`, formatted as per RFC 3339.
|
||||
|
||||
## Tag category
|
||||
**Description**
|
||||
|
|
|
@ -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%
|
||||
|
|
|
@ -4,13 +4,13 @@
|
|||
--><ul><!--
|
||||
--><li data-name='summary'><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>Summary</a></li><!--
|
||||
--><% if (ctx.canEditAnything) { %><!--
|
||||
--><li data-name='edit'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'edit') %>'>Account settings</a></li><!--
|
||||
--><li data-name='edit'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'edit') %>'>Settings</a></li><!--
|
||||
--><% } %><!--
|
||||
--><% if (ctx.canListTokens) { %><!--
|
||||
--><li data-name='list-tokens'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'list-tokens') %>'>Manage tokens</a></li><!--
|
||||
--><li data-name='list-tokens'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'list-tokens') %>'>Login tokens</a></li><!--
|
||||
--><% } %><!--
|
||||
--><% if (ctx.canDelete) { %><!--
|
||||
--><li data-name='delete'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'delete') %>'>Account deletion</a></li><!--
|
||||
--><li data-name='delete'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'delete') %>'>Delete</a></li><!--
|
||||
--><% } %><!--
|
||||
--></ul><!--
|
||||
--></nav>
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
<div class="token-flex-row">Note:</div>
|
||||
<div class="token-flex-row">Created:</div>
|
||||
<div class="token-flex-row">Expires:</div>
|
||||
<div class="token-flex-row">Used:</div>
|
||||
</div>
|
||||
<div class="token-flex-column full-width">
|
||||
<div class="token-flex-row"><%= token.token %></div>
|
||||
|
@ -19,6 +20,7 @@
|
|||
<% } else { %>
|
||||
<div class="token-flex-row">No expiration</div>
|
||||
<% } %>
|
||||
<div class="token-flex-row"><%= ctx.makeRelativeTime(token.lastUsageTime) %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-flex-row">
|
||||
|
|
|
@ -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') {
|
||||
|
@ -79,6 +81,8 @@ class UserToken extends events.EventTarget {
|
|||
_expirationTime: response.expirationTime,
|
||||
_version: response.version,
|
||||
_creationTime: response.creationTime,
|
||||
_lastEditTime: response.lastEditTime,
|
||||
_lastUsageTime: response.lastUsageTime,
|
||||
};
|
||||
|
||||
Object.assign(this, map);
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Reference in a new issue