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>,
"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**

View file

@ -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%

View file

@ -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>

View file

@ -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">

View file

@ -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);

View file

@ -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()

View file

@ -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

View file

@ -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'],

View file

@ -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')

View file

@ -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)

View file

@ -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