Implement last usage time

This commit is contained in:
ReAnzu 2018-03-10 18:15:00 -06:00
parent 8d8477ee6b
commit 5ce8fab533
11 changed files with 121 additions and 32 deletions

2
API.md
View file

@ -1850,6 +1850,7 @@ A single user token.
"version": <version>, "version": <version>,
"creationTime": <creation-time>, "creationTime": <creation-time>,
"lastEditTime": <last-edit-time>, "lastEditTime": <last-edit-time>,
"lastUsageTime": <last-usage-time>,
} }
``` ```
@ -1862,6 +1863,7 @@ A single user token.
- `<version>`: resource version. See [versioning](#versioning). - `<version>`: resource version. See [versioning](#versioning).
- `<creation-time>`: time the user token was created , formatted as per RFC 3339. - `<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-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 ## Tag category
**Description** **Description**

View file

@ -1,3 +1,6 @@
@import colors
$token-border-color = $active-tab-background-color
#user #user
width: 100% width: 100%
max-width: 35em max-width: 35em
@ -76,7 +79,7 @@
padding-right: 0.5em padding-right: 0.5em
hr hr
border-top: 1px #aaa solid; border-top: 3px solid $token-border-color
#user-delete form #user-delete form
width: 100% width: 100%

View file

@ -4,13 +4,13 @@
--><ul><!-- --><ul><!--
--><li data-name='summary'><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>Summary</a></li><!-- --><li data-name='summary'><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>Summary</a></li><!--
--><% if (ctx.canEditAnything) { %><!-- --><% 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) { %><!-- --><% 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) { %><!-- --><% 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><!-- --></ul><!--
--></nav> --></nav>

View file

@ -9,6 +9,7 @@
<div class="token-flex-row">Note:</div> <div class="token-flex-row">Note:</div>
<div class="token-flex-row">Created:</div> <div class="token-flex-row">Created:</div>
<div class="token-flex-row">Expires:</div> <div class="token-flex-row">Expires:</div>
<div class="token-flex-row">Used:</div>
</div> </div>
<div class="token-flex-column full-width"> <div class="token-flex-column full-width">
<div class="token-flex-row"><%= token.token %></div> <div class="token-flex-row"><%= token.token %></div>
@ -19,6 +20,7 @@
<% } else { %> <% } else { %>
<div class="token-flex-row">No expiration</div> <div class="token-flex-row">No expiration</div>
<% } %> <% } %>
<div class="token-flex-row"><%= ctx.makeRelativeTime(token.lastUsageTime) %></div>
</div> </div>
</div> </div>
<div class="token-flex-row"> <div class="token-flex-row">

View file

@ -17,6 +17,8 @@ class UserToken extends events.EventTarget {
get version() { return this._version; } get version() { return this._version; }
get expirationTime() { return this._expirationTime; } get expirationTime() { return this._expirationTime; }
get creationTime() { return this._creationTime; } get creationTime() { return this._creationTime; }
get lastEditTime() { return this._lastEditTime; }
get lastUsageTime() { return this._lastUsageTime; }
static fromResponse(response) { static fromResponse(response) {
if (typeof response.results !== 'undefined') { if (typeof response.results !== 'undefined') {
@ -79,6 +81,8 @@ class UserToken extends events.EventTarget {
_expirationTime: response.expirationTime, _expirationTime: response.expirationTime,
_version: response.version, _version: response.version,
_creationTime: response.creationTime, _creationTime: response.creationTime,
_lastEditTime: response.lastEditTime,
_lastUsageTime: response.lastUsageTime,
}; };
Object.assign(this, map); Object.assign(this, map);

View file

@ -35,6 +35,7 @@ class UserTokenSerializer(serialization.BaseSerializer):
'expirationTime': self.serialize_expiration_time, '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,
'lastUsageTime': self.serialize_last_usage_time,
'version': self.serialize_version, 'version': self.serialize_version,
} }
@ -47,6 +48,9 @@ class UserTokenSerializer(serialization.BaseSerializer):
def serialize_last_edit_time(self) -> Any: def serialize_last_edit_time(self) -> Any:
return self.user_token.last_edit_time 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: def serialize_token(self) -> Any:
return self.user_token.token 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.token = auth.generate_authorization_token()
user_token.enabled = enabled user_token.enabled = enabled
user_token.creation_time = datetime.utcnow() user_token.creation_time = datetime.utcnow()
user_token.last_usage_time = datetime.utcnow()
return user_token return user_token
@ -107,6 +112,7 @@ def update_user_token_enabled(
if enabled is None: if enabled is None:
raise InvalidEnabledError('Enabled cannot be empty.') raise InvalidEnabledError('Enabled cannot be empty.')
user_token.enabled = enabled user_token.enabled = enabled
update_user_token_edit_time(user_token)
def update_user_token_edit_time(user_token: model.UserToken) -> None: def update_user_token_edit_time(user_token: model.UserToken) -> None:
@ -124,6 +130,7 @@ def update_user_token_expiration_time(
raise InvalidExpirationError( raise InvalidExpirationError(
'Expiration cannot happen in the past') 'Expiration cannot happen in the past')
user_token.expiration_time = expiration_time user_token.expiration_time = expiration_time
update_user_token_edit_time(user_token)
except ValueError: except ValueError:
raise InvalidExpirationError( raise InvalidExpirationError(
'Expiration is in an invalid format {}'.format( '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): if util.value_exceeds_column_size(note, model.UserToken.note):
raise InvalidNoteError('Note is too long.') raise InvalidNoteError('Note is too long.')
user_token.note = note 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()

View file

@ -1,5 +1,5 @@
import base64 import base64
from typing import Optional from typing import Optional, Tuple
from szurubooru import db, model, errors, rest from szurubooru import db, model, errors, rest
from szurubooru.func import auth, users, user_tokens from szurubooru.func import auth, users, user_tokens
from szurubooru.rest.errors import HttpBadRequest from szurubooru.rest.errors import HttpBadRequest
@ -13,29 +13,32 @@ def _authenticate_basic_auth(username: str, password: str) -> model.User:
return 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. ''' ''' Try to authenticate user. Throw AuthError for invalid users. '''
user = users.get_user_by_name(username) user = users.get_user_by_name(username)
user_token = user_tokens.get_by_user_and_token(user, token) user_token = user_tokens.get_by_user_and_token(user, token)
if not auth.is_valid_token(user_token): if not auth.is_valid_token(user_token):
raise errors.AuthError('Invalid 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'): if not ctx.has_header('Authorization'):
return None return None
auth_token = None
try: try:
auth_type, credentials = ctx.get_header('Authorization').split(' ', 1) auth_type, credentials = ctx.get_header('Authorization').split(' ', 1)
if auth_type.lower() == 'basic': if auth_type.lower() == 'basic':
username, password = base64.decodebytes( username, password = base64.decodebytes(
credentials.encode('ascii')).decode('utf8').split(':', 1) 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': elif auth_type.lower() == 'token':
username, token = base64.decodebytes( username, token = base64.decodebytes(
credentials.encode('ascii')).decode('utf8').split(':', 1) credentials.encode('ascii')).decode('utf8').split(':', 1)
return _authenticate_token(username, token) auth_user, auth_token = _authenticate_token(username, token)
else: else:
raise HttpBadRequest( raise HttpBadRequest(
'ValidationError', 'ValidationError',
@ -48,15 +51,21 @@ def _get_user(ctx: rest.Context) -> Optional[model.User]:
'ValidationError', 'ValidationError',
msg.format(ctx.get_header('Authorization'), str(err))) 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: def process_request(ctx: rest.Context) -> None:
''' Bind the user to request. Update last login time if needed. ''' ''' 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: if auth_user:
ctx.user = 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 @rest.middleware.pre_hook

View file

@ -25,6 +25,7 @@ def upgrade():
sa.Column('expiration_time', sa.DateTime(), nullable=True), 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('last_usage_time', sa.DateTime(), nullable=True),
sa.Column('version', sa.Integer(), nullable=False), sa.Column('version', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
['user_id'], ['user_id'],

View file

@ -104,6 +104,7 @@ class UserToken(Base):
expiration_time = sa.Column('expiration_time', sa.DateTime, nullable=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)
last_usage_time = sa.Column('last_usage_time', sa.DateTime)
version = sa.Column('version', sa.Integer, default=1, nullable=False) version = sa.Column('version', sa.Integer, default=1, nullable=False)
user = sa.orm.relationship('User') user = sa.orm.relationship('User')

View file

@ -20,6 +20,7 @@ def test_serialize_user_token(user_token_factory):
'enabled': True, 'enabled': True,
'expirationTime': None, 'expirationTime': None,
'lastEditTime': None, 'lastEditTime': None,
'lastUsageTime': None,
'note': None, 'note': None,
'token': 'dummy', 'token': 'dummy',
'user': { 'user': {
@ -71,6 +72,7 @@ def test_update_user_token_enabled(user_token_factory):
user_token = user_token_factory() user_token = user_token_factory()
user_tokens.update_user_token_enabled(user_token, False) user_tokens.update_user_token_enabled(user_token, False)
assert user_token.enabled is 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): 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 assert user_token.note is None
user_tokens.update_user_token_note(user_token, ' Test Note ') user_tokens.update_user_token_note(user_token, ' Test Note ')
assert user_token.note == '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): 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_tokens.update_user_token_expiration_time(
user_token, expiration_time_str) user_token, expiration_time_str)
assert user_token.expiration_time.isoformat() == 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): 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): % expiration_time_str):
user_tokens.update_user_token_expiration_time( user_tokens.update_user_token_expiration_time(
user_token, expiration_time_str) 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)

View file

@ -1,5 +1,6 @@
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from szurubooru import db
from szurubooru.func import auth, users, user_tokens from szurubooru.func import auth, users, user_tokens
from szurubooru.middleware import authenticator from szurubooru.middleware import authenticator
from szurubooru.rest import errors from szurubooru.rest import errors
@ -11,6 +12,48 @@ def test_process_request_no_header(context_factory):
assert ctx.user.name is None 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): def test_process_request_basic_auth_valid(context_factory, user_factory):
user = user_factory() user = user_factory()
ctx = context_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'): patch('szurubooru.func.user_tokens.get_by_user_and_token'):
users.get_user_by_name.return_value = user_token.user users.get_user_by_name.return_value = user_token.user
user_tokens.get_by_user_and_token.return_value = user_token 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) authenticator.process_request(ctx)
assert ctx.user == user_token.user assert ctx.user == user_token.user