summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorReAnzu <anzu@reanzu.com>2018-02-25 04:44:02 -0600
committerrr- <rr-@sakuya.pl>2018-03-25 22:23:29 +0200
commit2a69f0193f9e5aa451e0dbcb8c89070f3e49ffa5 (patch)
treebda640614ae711e8fef0e0463f118f85ef83ac0e
parente35e70992736340bff3995ca6f425baf2f431e2f (diff)
server/auth: add token authentication
* Users are only authenticated against their password on login, and to retrieve a token * Passwords are wiped from the GUI frontend and cookies after login and token retrieval * Tokens are revoked at the end of the session/logout * If the user chooses the "remember me" option, the token is stored in the cookie * Tokens correctly delete themselves on logout * Tokens can expire at user-specified date * Tokens have their last usage time * Tokens can have user defined descriptions * Users can manage login tokens in their account settings
-rw-r--r--API.md166
-rw-r--r--README.md1
-rw-r--r--client/css/core-forms.styl32
-rw-r--r--client/css/user-view.styl43
-rw-r--r--client/html/user.tpl7
-rw-r--r--client/html/user_tokens.tpl74
-rw-r--r--client/js/api.js94
-rw-r--r--client/js/controllers/user_controller.js107
-rw-r--r--client/js/models/user_token.js116
-rw-r--r--client/js/util/polyfill.js7
-rw-r--r--client/js/util/views.js6
-rw-r--r--client/js/views/user_tokens_view.js134
-rw-r--r--client/js/views/user_view.js13
-rw-r--r--config.yaml.dist9
-rw-r--r--server/requirements.txt4
-rw-r--r--server/szurubooru/api/__init__.py1
-rw-r--r--server/szurubooru/api/user_token_api.py83
-rw-r--r--server/szurubooru/func/auth.py28
-rw-r--r--server/szurubooru/func/user_tokens.py146
-rw-r--r--server/szurubooru/func/users.py4
-rw-r--r--server/szurubooru/func/util.py6
-rw-r--r--server/szurubooru/middleware/authenticator.py59
-rw-r--r--server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py39
-rw-r--r--server/szurubooru/model/__init__.py2
-rw-r--r--server/szurubooru/model/user.py22
-rw-r--r--server/szurubooru/rest/__init__.py1
-rw-r--r--server/szurubooru/tests/api/test_user_token_creating.py29
-rw-r--r--server/szurubooru/tests/api/test_user_token_deleting.py30
-rw-r--r--server/szurubooru/tests/api/test_user_token_retrieving.py31
-rw-r--r--server/szurubooru/tests/api/test_user_token_updating.py42
-rw-r--r--server/szurubooru/tests/conftest.py25
-rw-r--r--server/szurubooru/tests/func/test_auth.py24
-rw-r--r--server/szurubooru/tests/func/test_user_tokens.py155
-rw-r--r--server/szurubooru/tests/middleware/__init__.py0
-rw-r--r--server/szurubooru/tests/middleware/test_authenticator.py93
-rw-r--r--server/szurubooru/tests/model/test_user_token.py14
36 files changed, 1608 insertions, 39 deletions
diff --git a/API.md b/API.md
index 62e0eb2..c23f045 100644
--- a/API.md
+++ b/API.md
@@ -7,6 +7,7 @@
1. [General rules](#general-rules)
- [Authentication](#authentication)
+ - [User token authentication](#user-token-authentication)
- [Basic requests](#basic-requests)
- [File uploads](#file-uploads)
- [Error handling](#error-handling)
@@ -56,6 +57,11 @@
- [Updating user](#updating-user)
- [Getting user](#getting-user)
- [Deleting user](#deleting-user)
+ - User Tokens
+ - [Listing user tokens](#listing-user-tokens)
+ - [Creating user token](#creating-user-token)
+ - [Updating user token](#updating-user-token)
+ - [Deleting user token](#deleting-user-token)
- Password reset
- [Password reset - step 1: mail request](#password-reset---step-2-confirmation)
- [Password reset - step 2: confirmation](#password-reset---step-2-confirmation)
@@ -70,6 +76,7 @@
- [User](#user)
- [Micro user](#micro-user)
+ - [User token](#user-token)
- [Tag category](#tag-category)
- [Tag](#tag)
- [Micro tag](#micro-tag)
@@ -91,7 +98,8 @@
## Authentication
Authentication is achieved by means of [basic HTTP
-auth](https://en.wikipedia.org/wiki/Basic_access_authentication). For this
+auth](https://en.wikipedia.org/wiki/Basic_access_authentication) or through the
+use of [user token authentication](#user-token-authentication). For this
reason, it is recommended to connect through HTTPS. There are no sessions, so
every privileged request must be authenticated. Available privileges depend on
the user's rank. The way how rank translates to privileges is defined in the
@@ -101,6 +109,24 @@ It is recommended to add `?bump-login` GET parameter to the first request in a
client "session" (where the definition of a session is up to the client), so
that the user's last login time is kept up to date.
+## User token authentication
+
+User token authentication works similarly to [basic HTTP
+auth](https://en.wikipedia.org/wiki/Basic_access_authentication). Because it
+operates similarly to ***basic HTTP auth*** it is still recommended to connect
+through HTTPS. The authorization header uses the type of `Token` and the
+username and token are encoded as Base64 and sent as the second parameter.
+
+Example header for user1:token-is-more-secure
+```
+Authorization: Token dXNlcjE6dG9rZW4taXMtbW9yZS1zZWN1cmU=
+```
+
+The benefit of token authentication is that beyond the initial login to acquire
+the first token, there is no need to transmit the user password in plaintext
+via basic auth. Additionally tokens can be revoked at anytime allowing a
+cleaner interface for isolating clients from user credentials.
+
## Basic requests
Every request must use `Content-Type: application/json` and `Accept:
@@ -1469,6 +1495,112 @@ data.
Deletes existing user.
+## Listing user tokens
+- **Request**
+
+ `GET /user-tokens/<user_name>`
+
+- **Output**
+
+ An [unpaged search result resource](#unpaged-search-result), for which
+ `<resource>` is a [user token resource](#user-token).
+
+- **Errors**
+
+ - privileges are too low
+
+- **Description**
+
+ Searches for user tokens for the given user.
+
+## Creating user token
+- **Request**
+
+ `POST /user-token/<user_name>`
+
+- **Input**
+
+ ```json5
+ {
+ "enabled": <enabled>, // optional
+ "note": <note>, // optional
+ "expirationTime": <expiration-time> // optional
+ }
+ ```
+
+- **Output**
+
+ A [user token resource](#user-token).
+
+- **Errors**
+
+ - privileges are too low
+
+- **Description**
+
+ Creates a new user token that can be used for authentication of API
+ endpoints instead of a password.
+
+## Updating user token
+- **Request**
+
+ `PUT /user-token/<user_name>/<token>`
+
+- **Input**
+
+ ```json5
+ {
+ "version": <version>,
+ "enabled": <enabled>, // optional
+ "note": <note>, // optional
+ "expirationTime": <expiration-time> // optional
+ }
+ ```
+
+- **Output**
+
+ A [user token resource](#user-token).
+
+- **Errors**
+
+ - the version is outdated
+ - the user token does not exist
+ - privileges are too low
+
+- **Description**
+
+ Updates an existing user token using specified parameters. All fields
+ except the [`version`](#versioning) are optional - update concerns only
+ provided fields.
+
+## Deleting user token
+- **Request**
+
+ `DELETE /user-token/<user_name>/<token>`
+
+- **Input**
+
+ ```json5
+ {
+ "version": <version>
+ }
+ ```
+
+- **Output**
+
+ ```json5
+ {}
+ ```
+
+- **Errors**
+
+ - the token does not exist
+ - privileges are too low
+
+- **Description**
+
+ Deletes existing user token.
+
## Password reset - step 1: mail request
- **Request**
@@ -1701,6 +1833,38 @@ A single user.
A [user resource](#user) stripped down to `name` and `avatarUrl` fields.
+## User token
+**Description**
+
+A single user token.
+
+**Structure**
+
+```json5
+{
+ "user": <user>,
+ "token": <token>,
+ "note": <token>,
+ "enabled": <enabled>,
+ "expirationTime": <expiration-time>,
+ "version": <version>,
+ "creationTime": <creation-time>,
+ "lastEditTime": <last-edit-time>,
+ "lastUsageTime": <last-usage-time>
+}
+```
+
+**Field meaning**
+- `<user>`: micro user. See [micro user](#micro-user).
+- `<token>`: the token that can be used to authenticate the user.
+- `<note>`: a note that describes the token.
+- `<enabled>`: whether the token is still valid for authentication.
+- `<expiration-time>`: time when the token expires. It must include the timezone as per RFC 3339.
+- `<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**
diff --git a/README.md b/README.md
index ceb9109..3b5c4e4 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
- Post comments
- Post notes / annotations, including arbitrary polygons
- Rich JSON REST API ([see documentation](https://github.com/rr-/szurubooru/blob/master/API.md))
+- Token based authentication for clients
- Rich search system
- Rich privilege system
- Autocomplete in search and while editing tags
diff --git a/client/css/core-forms.styl b/client/css/core-forms.styl
index f083ffb..bed63e3 100644
--- a/client/css/core-forms.styl
+++ b/client/css/core-forms.styl
@@ -138,6 +138,38 @@ input[type=checkbox]:focus + .checkbox:before
/*
+ * Date and time inputs
+ */
+
+input[type=date],
+input[type=time]
+ vertical-align: top
+ font-family: 'Droid Sans', sans-serif
+ font-size: 100%
+ padding: 0.2em 0.3em
+ box-sizing: border-box
+ border: 2px solid $input-enabled-border-color
+ background: $input-enabled-background-color
+ color: $input-enabled-text-color
+ box-shadow: none /* :-moz-submit-invalid on FF */
+ transition: border-color 0.1s linear, background-color 0.1s linear
+
+ &:disabled
+ border: 2px solid $input-disabled-border-color
+ background: $input-disabled-background-color
+ color: $input-disabled-text-color
+
+ &:focus
+ border-color: $main-color
+
+ &[readonly]
+ border: 2px solid $input-disabled-border-color
+ background: $input-disabled-background-color
+ color: $input-disabled-text-color
+
+
+
+/*
* Regular inputs
*/
select,
diff --git a/client/css/user-view.styl b/client/css/user-view.styl
index 12cba75..3cdd29c 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
@@ -37,7 +40,43 @@
height: 1px
clear: both
- #user-delete form
- width: 100%
+ #user-tokens
+
+ .token-flex-container
+ width: 100%
+ display: flex;
+ flex-direction column;
+ padding-bottom: 0.5em;
+
+ .full-width
+ width: 100%
+
+ .token-flex-row
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ padding: 0.2em;
+
+ .no-wrap
+ white-space: nowrap;
+ .token-input
+ min-height: 2em;
+ line-height: 2em;
+ text-align: center;
+ .token-flex-column
+ display: flex;
+ flex-direction: column;
+
+ .token-flex-labels
+ padding-right: 0.5em
+
+ hr
+ border-top: 3px solid $token-border-color
+
+ form
+ width: 100%;
+
+ #user-delete form
+ width: 100%
diff --git a/client/html/user.tpl b/client/html/user.tpl
index 28e34e6..75c721f 100644
--- a/client/html/user.tpl
+++ b/client/html/user.tpl
@@ -4,10 +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') %>'>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>
diff --git a/client/html/user_tokens.tpl b/client/html/user_tokens.tpl
new file mode 100644
index 0000000..73db7a1
--- /dev/null
+++ b/client/html/user_tokens.tpl
@@ -0,0 +1,74 @@
+<div id='user-tokens'>
+ <div class='messages'></div>
+ <% if (ctx.tokens.length > 0) { %>
+ <div class='token-flex-container'>
+ <% _.each(ctx.tokens, function(token, index) { %>
+ <div class='token-flex-row'>
+ <div class='token-flex-column token-flex-labels'>
+ <div class='token-flex-row'>Token:</div>
+ <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 no-wrap'>Last used:</div>
+ </div>
+ <div class='token-flex-column full-width'>
+ <div class='token-flex-row'><%= token.token %></div>
+ <div class='token-flex-row'>
+ <% if (token.note !== null) { %>
+ <%= token.note %>
+ <% } else { %>
+ No note
+ <% } %>
+ <a class='token-change-note' data-token-id='<%= index %>' href='#'>(change)</a>
+ </div>
+ <div class='token-flex-row'><%= ctx.makeRelativeTime(token.creationTime) %></div>
+ <div class='token-flex-row'>
+ <% if (token.expirationTime) { %>
+ <%= ctx.makeRelativeTime(token.expirationTime) %>
+ <% } else { %>
+ No expiration
+ <% } %>
+ </div>
+ <div class='token-flex-row'><%= ctx.makeRelativeTime(token.lastUsageTime) %></div>
+ </div>
+ </div>
+ <div class='token-flex-row'>
+ <div class='token-flex-column full-width'>
+ <div class='token-flex-row'>
+ <form class='token' data-token-id='<%= index %>'>
+ <% if (token.isCurrentAuthToken) { %>
+ <input type='submit' value='Delete and logout'
+ title='This token is used to authenticate this client, deleting it will force a logout.'/>
+ <% } else { %>
+ <input type='submit' value='Delete'/>
+ <% } %>
+ </form>
+ </div>
+ </div>
+ </div>
+ <hr/>
+ <% }); %>
+ </div>
+ <% } else { %>
+ <h2>No Registered Tokens</h2>
+ <% } %>
+ <form id='create-token-form'>
+ <ul class='input'>
+ <li class='note'>
+ <%= ctx.makeTextInput({
+ text: 'Note',
+ id: 'note',
+ }) %>
+ </li>
+ <li class='expirationTime'>
+ <%= ctx.makeDateInput({
+ text: 'Expires',
+ id: 'expirationTime',
+ }) %>
+ </li>
+ </ul>
+ <div class='buttons'>
+ <input type='submit' value='Create token'/>
+ </div>
+ </form>
+</div>
diff --git a/client/js/api.js b/client/js/api.js
index abfeb6f..3623045 100644
--- a/client/js/api.js
+++ b/client/js/api.js
@@ -15,6 +15,7 @@ class Api extends events.EventTarget {
this.user = null;
this.userName = null;
this.userPassword = null;
+ this.token = null;
this.cache = {};
this.allRanks = [
'anonymous',
@@ -87,16 +88,16 @@ class Api extends events.EventTarget {
loginFromCookies() {
const auth = cookies.getJSON('auth');
- return auth && auth.user && auth.password ?
- this.login(auth.user, auth.password, true) :
+ return auth && auth.user && auth.token ?
+ this.loginWithToken(auth.user, auth.token, true) :
Promise.resolve();
}
- login(userName, userPassword, doRemember) {
+ loginWithToken(userName, token, doRemember) {
this.cache = {};
return new Promise((resolve, reject) => {
this.userName = userName;
- this.userPassword = userPassword;
+ this.token = token;
this.get('/user/' + userName + '?bump-login=true')
.then(response => {
const options = {};
@@ -105,8 +106,70 @@ class Api extends events.EventTarget {
}
cookies.set(
'auth',
- {'user': userName, 'password': userPassword},
+ {'user': userName, 'token': token},
+ options);
+ this.user = response;
+ resolve();
+ this.dispatchEvent(new CustomEvent('login'));
+ }, error => {
+ reject(error);
+ this.logout();
+ });
+ });
+ }
+
+ createToken(userName, options) {
+ let userTokenRequest = {
+ enabled: true,
+ note: 'Web 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, userTokenRequest)
+ .then(response => {
+ cookies.set(
+ 'auth',
+ {'user': userName, 'token': response.token},
+ options);
+ this.userName = userName;
+ this.token = response.token;
+ this.userPassword = null;
+ }, error => {
+ reject(error);
+ });
+ });
+ }
+
+ deleteToken(userName, userToken) {
+ return new Promise((resolve, reject) => {
+ this.delete('/user-token/' + userName + '/' + userToken, {})
+ .then(response => {
+ const options = {};
+ cookies.set(
+ 'auth',
+ {'user': userName, 'token': null},
options);
+ resolve();
+ }, error => {
+ reject(error);
+ });
+ });
+ }
+
+ login(userName, userPassword, doRemember) {
+ this.cache = {};
+ return new Promise((resolve, reject) => {
+ this.userName = userName;
+ this.userPassword = userPassword;
+ this.get('/user/' + userName + '?bump-login=true')
+ .then(response => {
+ const options = {};
+ if (doRemember) {
+ options.expires = 365;
+ }
+ this.createToken(this.userName, options);
this.user = response;
resolve();
this.dispatchEvent(new CustomEvent('login'));
@@ -118,9 +181,20 @@ class Api extends events.EventTarget {
}
logout() {
+ let self = this;
+ this.deleteToken(this.userName, this.token)
+ .then(response => {
+ self._logout();
+ }, error => {
+ self._logout();
+ });
+ }
+
+ _logout() {
this.user = null;
this.userName = null;
this.userPassword = null;
+ this.token = null;
this.dispatchEvent(new CustomEvent('logout'));
}
@@ -137,6 +211,10 @@ class Api extends events.EventTarget {
}
}
+ isCurrentAuthToken(userToken) {
+ return userToken.token === this.token;
+ }
+
_getFullUrl(url) {
const fullUrl =
(config.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/');
@@ -258,7 +336,11 @@ class Api extends events.EventTarget {
}
try {
- if (this.userName && this.userPassword) {
+ if (this.userName && this.token) {
+ req.auth = null;
+ req.set('Authorization', 'Token '
+ + new Buffer(this.userName + ":" + this.token).toString('base64'))
+ } else if (this.userName && this.userPassword) {
req.auth(
this.userName,
encodeURIComponent(this.userPassword)
diff --git a/client/js/controllers/user_controller.js b/client/js/controllers/user_controller.js
index 46020f3..d042e41 100644
--- a/client/js/controllers/user_controller.js
+++ b/client/js/controllers/user_controller.js
@@ -7,6 +7,7 @@ const misc = require('../util/misc.js');
const config = require('../config.js');
const views = require('../util/views.js');
const User = require('../models/user.js');
+const UserToken = require('../models/user_token.js');
const topNavigation = require('../models/top_navigation.js');
const UserView = require('../views/user_view.js');
const EmptyView = require('../views/empty_view.js');
@@ -21,8 +22,28 @@ class UserController {
return;
}
+ this._successMessages = [];
+ this._errorMessages = [];
+
+ let userTokenPromise = Promise.resolve([]);
+ if (section === 'list-tokens') {
+ userTokenPromise = UserToken.get(userName)
+ .then(userTokens => {
+ return userTokens.map(token => {
+ token.isCurrentAuthToken = api.isCurrentAuthToken(token);
+ return token;
+ });
+ }, error => {
+ return [];
+ });
+ }
+
topNavigation.setTitle('User ' + userName);
- User.get(userName).then(user => {
+ Promise.all([
+ userTokenPromise,
+ User.get(userName)
+ ]).then(responses => {
+ const [userTokens, user] = responses;
const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any';
@@ -48,6 +69,7 @@ class UserController {
} else {
topNavigation.activate('users');
}
+
this._view = new UserView({
user: user,
section: section,
@@ -58,18 +80,51 @@ class UserController {
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`),
canEditAnything: api.hasPrivilege(`users:edit:${infix}`),
+ canListTokens: api.hasPrivilege(`userTokens:list:${infix}`),
+ canCreateToken: api.hasPrivilege(`userTokens:create:${infix}`),
+ canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`),
+ canDeleteToken: api.hasPrivilege(`userTokens:delete:${infix}`),
canDelete: api.hasPrivilege(`users:delete:${infix}`),
ranks: ranks,
+ tokens: userTokens,
});
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtUpdate(e));
this._view.addEventListener('delete', e => this._evtDelete(e));
+ this._view.addEventListener('create-token', e => this._evtCreateToken(e));
+ this._view.addEventListener('delete-token', e => this._evtDeleteToken(e));
+ this._view.addEventListener('update-token', e => this._evtUpdateToken(e));
+
+ for (let message of this._successMessages) {
+ this.showSuccess(message);
+ }
+
+ for (let message of this._errorMessages) {
+ this.showError(message);
+ }
+
}, error => {
this._view = new EmptyView();
this._view.showError(error.message);
});
}
+ showSuccess(message) {
+ if (typeof this._view === 'undefined') {
+ this._successMessages.push(message)
+ } else {
+ this._view.showSuccess(message);
+ }
+ }
+
+ showError(message) {
+ if (typeof this._view === 'undefined') {
+ this._errorMessages.push(message)
+ } else {
+ this._view.showError(message);
+ }
+ }
+
_evtChange(e) {
misc.enableExitConfirmation();
}
@@ -148,6 +203,53 @@ class UserController {
this._view.enableForm();
});
}
+
+ _evtCreateToken(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ 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.');
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+
+ _evtDeleteToken(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ if (api.isCurrentAuthToken(e.detail.userToken)) {
+ router.show(uri.formatClientLink('logout'));
+ } else {
+ e.detail.userToken.delete(e.detail.user.name)
+ .then(() => {
+ const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
+ ctx.controller.showSuccess('Token ' + e.detail.userToken.token + ' deleted.');
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+ }
+
+ _evtUpdateToken(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+
+ if (e.detail.note !== undefined) {
+ e.detail.userToken.note = e.detail.note;
+ }
+
+ e.detail.userToken.save(e.detail.user.name).then(response => {
+ const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
+ ctx.controller.showSuccess('Token ' + response.token + ' updated.');
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
}
module.exports = router => {
@@ -157,6 +259,9 @@ module.exports = router => {
router.enter(['user', ':name', 'edit'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'edit');
});
+ router.enter(['user', ':name', 'list-tokens'], (ctx, next) => {
+ ctx.controller = new UserController(ctx, 'list-tokens');
+ });
router.enter(['user', ':name', 'delete'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'delete');
});
diff --git a/client/js/models/user_token.js b/client/js/models/user_token.js
new file mode 100644
index 0000000..6e70a94
--- /dev/null
+++ b/client/js/models/user_token.js
@@ -0,0 +1,116 @@
+'use strict';
+
+const api = require('../api.js');
+const uri = require('../util/uri.js');
+const events = require('../events.js');
+
+class UserToken extends events.EventTarget {
+ constructor() {
+ super();
+ this._orig = {};
+ this._updateFromResponse({});
+ }
+
+ 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; }
+ get lastEditTime() { return this._lastEditTime; }
+ get lastUsageTime() { return this._lastUsageTime; }
+
+ set note(value) { this._note = value; }
+
+ static fromResponse(response) {
+ if (typeof response.results !== 'undefined') {
+ let tokenList = [];
+ for (let responseToken of response.results) {
+ const token = new UserToken();
+ token._updateFromResponse(responseToken);
+ tokenList.push(token)
+ }
+ return tokenList;
+ } else {
+ const ret = new UserToken();
+ ret._updateFromResponse(response);
+ return ret;
+ }
+ }
+
+ static get(userName) {
+ return api.get(uri.formatApiLink('user-tokens', userName))
+ .then(response => {
+ return Promise.resolve(UserToken.fromResponse(response));
+ });
+ }
+
+ 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))
+ });
+ }
+
+ save(userName) {
+ const detail = {version: this._version};
+
+ if (this._note !== this._orig._note) {
+ detail.note = this._note;
+ }
+
+ return api.put(
+ uri.formatApiLink('user-token', userName, this._orig._token),
+ detail)
+ .then(response => {
+ this._updateFromResponse(response);
+ this.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ userToken: this,
+ },
+ }));
+ return Promise.resolve(this);
+ });
+ }
+
+ delete(userName) {
+ return api.delete(
+ uri.formatApiLink('user-token', userName, this._orig._token),
+ {version: this._version})
+ .then(response => {
+ this.dispatchEvent(new CustomEvent('delete', {
+ detail: {
+ userToken: this,
+ },
+ }));
+ return Promise.resolve();
+ });
+ }
+
+ _updateFromResponse(response) {
+ const map = {
+ _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);
+ Object.assign(this._orig, map);
+ }
+}
+
+module.exports = UserToken;
diff --git a/client/js/util/polyfill.js b/client/js/util/polyfill.js
index 91186b2..71ee972 100644
--- a/client/js/util/polyfill.js
+++ b/client/js/util/polyfill.js
@@ -59,3 +59,10 @@ Number.prototype.between = function(a, b, inclusive) {
// non standard
Promise.prototype.abort = () => {};
+
+// non standard
+Date.prototype.addDays = function(days) {
+ let dat = new Date(this.valueOf());
+ dat.setDate(dat.getDate() + days);
+ return dat;
+};
diff --git a/client/js/util/views.js b/client/js/util/views.js
index b0b7cce..9f238b1 100644
--- a/client/js/util/views.js
+++ b/client/js/util/views.js
@@ -168,6 +168,11 @@ function makeNumericInput(options) {
return makeInput(options);
}
+function makeDateInput(options) {
+ options.type = 'date';
+ return makeInput(options)
+}
+
function getPostUrl(id, parameters) {
return uri.formatClientLink(
'post', id,
@@ -392,6 +397,7 @@ function getTemplate(templatePath) {
makePasswordInput: makePasswordInput,
makeEmailInput: makeEmailInput,
makeColorInput: makeColorInput,
+ makeDateInput: makeDateInput,
makePostLink: makePostLink,
makeTagLink: makeTagLink,
makeUserLink: makeUserLink,
diff --git a/client/js/views/user_tokens_view.js b/client/js/views/user_tokens_view.js
new file mode 100644
index 0000000..f6c8480
--- /dev/null
+++ b/client/js/views/user_tokens_view.js
@@ -0,0 +1,134 @@
+'use strict';
+
+const events = require('../events.js');
+const views = require('../util/views.js');
+
+const template = views.getTemplate('user-tokens');
+
+class UserTokenView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._user = ctx.user;
+ this._tokens = ctx.tokens;
+ this._hostNode = ctx.hostNode;
+ this._tokenFormNodes = [];
+ views.replaceContent(this._hostNode, template(ctx));
+ views.decorateValidator(this._formNode);
+
+ this._formNode.addEventListener('submit', e => this._evtSubmit(e));
+
+ this._decorateTokenForms();
+ this._decorateTokenNoteChangeLinks();
+ }
+
+ _decorateTokenForms() {
+ this._tokenFormNodes = [];
+ for (let i = 0; i < this._tokens.length; i++) {
+ let formNode = this._hostNode.querySelector(
+ '.token[data-token-id=\"' + i + '\"]');
+ formNode.addEventListener('submit', e => this._evtDelete(e));
+ this._tokenFormNodes.push(formNode);
+ }
+ }
+
+ _decorateTokenNoteChangeLinks() {
+ for (let i = 0; i < this._tokens.length; i++) {
+ let linkNode = this._hostNode.querySelector(
+ '.token-change-note[data-token-id=\"' + i + '\"]');
+ linkNode.addEventListener(
+ 'click', e => this._evtChangeNoteClick(e));
+ }
+ }
+
+ clearMessages() {
+ views.clearMessages(this._hostNode);
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+
+ enableForm() {
+ views.enableForm(this._formNode);
+ for (let formNode of this._tokenFormNodes) {
+ views.enableForm(formNode);
+ }
+ }
+
+ disableForm() {
+ views.disableForm(this._formNode);
+ for (let formNode of this._tokenFormNodes) {
+ views.disableForm(formNode);
+ }
+ }
+
+ _evtDelete(e) {
+ e.preventDefault();
+ const userToken = this._tokens[parseInt(
+ e.target.getAttribute('data-token-id'))];
+ this.dispatchEvent(new CustomEvent('delete', {
+ detail: {
+ user: this._user,
+ userToken: userToken,
+ },
+ }));
+ }
+
+ _evtSubmit(e) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent('submit', {
+ detail: {
+ user: this._user,
+
+ note: this._userTokenNoteInputNode ?
+ this._userTokenNoteInputNode.value :
+ undefined,
+
+ expirationTime:
+ (this._userTokenExpirationTimeInputNode
+ && this._userTokenExpirationTimeInputNode.value) ?
+ new Date(this._userTokenExpirationTimeInputNode.value)
+ .toISOString() :
+ undefined,
+ },
+ }));
+ }
+
+ _evtChangeNoteClick(e) {
+ e.preventDefault();
+ const userToken = this._tokens[
+ parseInt(e.target.getAttribute('data-token-id'))];
+ const text = window.prompt(
+ 'Please enter the new name:',
+ userToken.note !== null ? userToken.note : undefined);
+ if (!text) {
+ return;
+ }
+ this.dispatchEvent(new CustomEvent('update', {
+ detail: {
+ user: this._user,
+ userToken: userToken,
+ note: text ? text : undefined,
+ },
+ }));
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector('#create-token-form');
+ }
+
+ get _userTokenNoteInputNode() {
+ return this._formNode.querySelector('.note input');
+ }
+
+ get _userTokenExpirationTimeInputNode() {
+ return this._formNode.querySelector('.expirationTime input');
+ }
+}
+
+module.exports = UserTokenView;
diff --git a/client/js/views/user_view.js b/client/js/views/user_view.js
index d52e899..75fd154 100644
--- a/client/js/views/user_view.js
+++ b/client/js/views/user_view.js
@@ -3,6 +3,7 @@
const events = require('../events.js');
const views = require('../util/views.js');
const UserDeleteView = require('./user_delete_view.js');
+const UserTokensView = require('./user_tokens_view.js');
const UserSummaryView = require('./user_summary_view.js');
const UserEditView = require('./user_edit_view.js');
const EmptyView = require('../views/empty_view.js');
@@ -45,7 +46,17 @@ class UserView extends events.EventTarget {
this._view = new UserEditView(ctx);
events.proxyEvent(this._view, this, 'submit');
}
-
+ } else if (ctx.section == 'list-tokens') {
+ if (!this._ctx.canListTokens) {
+ this._view = new EmptyView();
+ this._view.showError(
+ 'You don\'t have privileges to view user tokens.');
+ } else {
+ this._view = new UserTokensView(ctx);
+ events.proxyEvent(this._view, this, 'delete', 'delete-token');
+ events.proxyEvent(this._view, this, 'submit', 'create-token');
+ events.proxyEvent(this._view, this, 'update', 'update-token');
+ }
} else if (ctx.section == 'delete') {
if (!this._ctx.canDelete) {
this._view = new EmptyView();
diff --git a/config.yaml.dist b/config.yaml.dist
index a8b0a1f..297dc6f 100644
--- a/config.yaml.dist
+++ b/config.yaml.dist
@@ -91,6 +91,15 @@ privileges:
'users:delete:any': administrator
'users:delete:self': regular
+ 'user_tokens:list:any': administrator
+ 'user_tokens:list:self': regular
+ 'user_tokens:create:any': administrator
+ 'user_tokens:create:self': regular
+ 'user_tokens:edit:any': administrator
+ 'user_tokens:edit:self': regular
+ 'user_tokens:delete:any': administrator
+ 'user_tokens:delete:self': regular
+
'posts:create:anonymous': regular
'posts:create:identified': regular
'posts:list': anonymous
diff --git a/server/requirements.txt b/server/requirements.txt
index 7cc4786..b11c3b5 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -11,4 +11,6 @@ scipy>=0.18.1
elasticsearch>=5.0.0
elasticsearch-dsl>=5.0.0
scikit-image>=0.12
-pynacl>=1.2.1 \ No newline at end of file
+pynacl>=1.2.1
+pytz>=2018.3
+pyRFC3339>=1.0 \ No newline at end of file
diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py
index 2a2d5af..0d7f75f 100644
--- a/server/szurubooru/api/__init__.py
+++ b/server/szurubooru/api/__init__.py
@@ -1,5 +1,6 @@
import szurubooru.api.info_api
import szurubooru.api.user_api
+import szurubooru.api.user_token_api
import szurubooru.api.post_api
import szurubooru.api.tag_api
import szurubooru.api.tag_category_api
diff --git a/server/szurubooru/api/user_token_api.py b/server/szurubooru/api/user_token_api.py
new file mode 100644
index 0000000..7739823
--- /dev/null
+++ b/server/szurubooru/api/user_token_api.py
@@ -0,0 +1,83 @@
+from typing import Dict
+from szurubooru import model, rest
+from szurubooru.func import auth, users, user_tokens, serialization, versions
+
+
+def _serialize(
+ ctx: rest.Context, user_token: model.UserToken) -> rest.Response:
+ return user_tokens.serialize_user_token(
+ user_token,
+ ctx.user,
+ options=serialization.get_serialization_options(ctx))
+
+
+@rest.routes.get('/user-tokens/(?P<user_name>[^/]+)/?')
+def get_user_tokens(
+ ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
+ 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:list:%s' % infix)
+ user_token_list = user_tokens.get_user_tokens(user)
+ return {
+ 'results': [_serialize(ctx, token) for token in user_token_list]
+ }
+
+
+@rest.routes.post('/user-token/(?P<user_name>[^/]+)/?')
+def create_user_token(
+ ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
+ 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)
+ 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)
+
+
+@rest.routes.put('/user-token/(?P<user_name>[^/]+)/(?P<user_token>[^/]+)/?')
+def update_user_token(
+ ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
+ 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:edit:%s' % infix)
+ user_token = user_tokens.get_by_user_and_token(user, params['user_token'])
+ versions.verify_version(user_token, ctx)
+ versions.bump_version(user_token)
+ if ctx.has_param('enabled'):
+ 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)
+
+
+@rest.routes.delete('/user-token/(?P<user_name>[^/]+)/(?P<user_token>[^/]+)/?')
+def delete_user_token(
+ ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
+ 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:delete:%s' % infix)
+ user_token = user_tokens.get_by_user_and_token(user, params['user_token'])
+ if user_token is not None:
+ ctx.session.delete(user_token)
+ ctx.session.commit()
+ return {}
diff --git a/server/szurubooru/func/auth.py b/server/szurubooru/func/auth.py
index c9740fe..65be79a 100644
--- a/server/szurubooru/func/auth.py
+++ b/server/szurubooru/func/auth.py
@@ -1,10 +1,12 @@
-from typing import Tuple
+from typing import Tuple, Optional
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
@@ -26,7 +28,8 @@ def get_password_hash(salt: str, password: str) -> Tuple[str, int]:
).decode('utf8'), 3
-def get_sha256_legacy_password_hash(salt: str, password: str) -> Tuple[str, int]:
+def get_sha256_legacy_password_hash(
+ salt: str, password: str) -> Tuple[str, int]:
''' Retrieve old-style sha256 password hash. '''
digest = hashlib.sha256()
digest.update(config.config['secret'].encode('utf8'))
@@ -78,6 +81,21 @@ def is_valid_password(user: model.User, password: str) -> bool:
return False
+def is_valid_token(user_token: Optional[model.UserToken]) -> bool:
+ '''
+ Token must be enabled and if it has an expiration, it must be
+ greater than now.
+ '''
+ if user_token is None:
+ return False
+ 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:
assert user
all_ranks = list(RANK_MAP.keys())
@@ -102,3 +120,7 @@ def generate_authentication_token(user: model.User) -> str:
digest.update(config.config['secret'].encode('utf8'))
digest.update(user.password_salt.encode('utf8'))
return digest.hexdigest()
+
+
+def generate_authorization_token() -> str:
+ return uuid.uuid4().__str__()
diff --git a/server/szurubooru/func/user_tokens.py b/server/szurubooru/func/user_tokens.py
new file mode 100644
index 0000000..c0f4bad
--- /dev/null
+++ b/server/szurubooru/func/user_tokens.py
@@ -0,0 +1,146 @@
+from datetime import datetime
+from typing import Any, Optional, List, Dict, Callable
+from pyrfc3339 import parser as rfc3339_parser
+import pytz
+from szurubooru import db, model, rest, errors
+from szurubooru.func import auth, serialization, users, util
+
+
+class InvalidExpirationError(errors.ValidationError):
+ pass
+
+
+class InvalidNoteError(errors.ValidationError):
+ pass
+
+
+class UserTokenSerializer(serialization.BaseSerializer):
+ def __init__(
+ self,
+ user_token: model.UserToken,
+ auth_user: model.User) -> None:
+ self.user_token = user_token
+ self.auth_user = auth_user
+
+ def _serializers(self) -> Dict[str, Callable[[], Any]]:
+ return {
+ 'user': self.serialize_user,
+ 'token': self.serialize_token,
+ 'note': self.serialize_note,
+ 'enabled': self.serialize_enabled,
+ '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,
+ }
+
+ def serialize_user(self) -> Any:
+ return users.serialize_micro_user(self.user_token.user, self.auth_user)
+
+ def serialize_creation_time(self) -> Any:
+ return self.user_token.creation_time
+
+ 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
+
+ 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
+
+
+def serialize_user_token(
+ user_token: Optional[model.UserToken],
+ auth_user: model.User,
+ options: List[str] = []) -> Optional[rest.Response]:
+ if not user_token:
+ return None
+ return UserTokenSerializer(user_token, auth_user).serialize(options)
+
+
+def get_by_user_and_token(
+ user: model.User, token: str) -> model.UserToken:
+ return (
+ db.session
+ .query(model.UserToken)
+ .filter(model.UserToken.user_id == user.user_id)
+ .filter(model.UserToken.token == token)
+ .one_or_none())
+
+
+def get_user_tokens(user: model.User) -> List[model.UserToken]:
+ assert user
+ return (
+ db.session
+ .query(model.UserToken)
+ .filter(model.UserToken.user_id == user.user_id)
+ .all())
+
+
+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 = enabled
+ user_token.creation_time = datetime.utcnow()
+ user_token.last_usage_time = datetime.utcnow()
+ return user_token
+
+
+def update_user_token_enabled(
+ user_token: model.UserToken, enabled: bool) -> None:
+ assert user_token
+ user_token.enabled = enabled
+ update_user_token_edit_time(user_token)
+
+
+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: str) -> None:
+ assert user_token
+ try:
+ expiration_time = rfc3339_parser.parse(expiration_time_str, utc=True)
+ expiration_time = expiration_time.astimezone(pytz.UTC)
+ if expiration_time < datetime.utcnow().replace(tzinfo=pytz.UTC):
+ 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(
+ expiration_time_str))
+
+
+def update_user_token_note(user_token: model.UserToken, note: str) -> None:
+ assert user_token
+ note = note.strip() if note is not None else ''
+ note = None if len(note) == 0 else note
+ 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/func/users.py b/server/szurubooru/func/users.py
index 012debc..e5946dc 100644
--- a/server/szurubooru/func/users.py
+++ b/server/szurubooru/func/users.py
@@ -1,6 +1,6 @@
-import re
-from typing import Any, Optional, Union, List, Dict, Callable
from datetime import datetime
+from typing import Any, Optional, Union, List, Dict, Callable
+import re
import sqlalchemy as sa
from szurubooru import config, db, model, errors, rest
from szurubooru.func import auth, util, serialization, files, images
diff --git a/server/szurubooru/func/util.py b/server/szurubooru/func/util.py
index ba2d4dc..5e82286 100644
--- a/server/szurubooru/func/util.py
+++ b/server/szurubooru/func/util.py
@@ -160,6 +160,12 @@ def value_exceeds_column_size(value: Optional[str], column: Any) -> bool:
return len(value) > max_length
+def get_column_size(column: Any) -> Optional[int]:
+ if not column:
+ return None
+ return column.property.columns[0].type.length
+
+
def chunks(source_list: List[Any], part_size: int) -> Generator:
for i in range(0, len(source_list), part_size):
yield source_list[i:i + part_size]
diff --git a/server/szurubooru/middleware/authenticator.py b/server/szurubooru/middleware/authenticator.py
index 644fe3b..4340ec9 100644
--- a/server/szurubooru/middleware/authenticator.py
+++ b/server/szurubooru/middleware/authenticator.py
@@ -1,11 +1,11 @@
import base64
-from typing import Optional
-from szurubooru import db, model, errors, rest
-from szurubooru.func import auth, users
+from typing import Optional, Tuple
+from szurubooru import model, errors, rest
+from szurubooru.func import auth, users, user_tokens
from szurubooru.rest.errors import HttpBadRequest
-def _authenticate(username: str, password: str) -> model.User:
+def _authenticate_basic_auth(username: str, password: str) -> model.User:
''' Try to authenticate user. Throw AuthError for invalid users. '''
user = users.get_user_by_name(username)
if not auth.is_valid_password(user, password):
@@ -13,34 +13,61 @@ def _authenticate(username: str, password: str) -> model.User:
return user
-def _get_user(ctx: rest.Context) -> Optional[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, user_token
+
+
+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':
+ if auth_type.lower() == 'basic':
+ username, password = base64.decodebytes(
+ credentials.encode('ascii')).decode('utf8').split(':', 1)
+ auth_user = _authenticate_basic_auth(username, password)
+ elif auth_type.lower() == 'token':
+ username, token = base64.decodebytes(
+ credentials.encode('ascii')).decode('utf8').split(':', 1)
+ auth_user, auth_token = _authenticate_token(username, token)
+ else:
raise HttpBadRequest(
'ValidationError',
- 'Only basic HTTP authentication is supported.')
- username, password = base64.decodebytes(
- credentials.encode('ascii')).decode('utf8').split(':', 1)
- return _authenticate(username, password)
+ 'Only basic or token HTTP authentication is supported.')
except ValueError as err:
msg = (
- 'Basic authentication header value are not properly formed. '
+ 'Authorization header values are not properly formed. '
'Supplied header {0}. Got error: {1}')
raise HttpBadRequest(
'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
+
-@rest.middleware.pre_hook
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
+def process_request_hook(ctx: rest.Context) -> None:
+ process_request(ctx)
diff --git a/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py b/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py
new file mode 100644
index 0000000..899eaa7
--- /dev/null
+++ b/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py
@@ -0,0 +1,39 @@
+'''
+Added a user_token table for API authorization
+
+Revision ID: a39c7f98a7fa
+Created at: 2018-02-25 01:31:27.345595
+'''
+
+import sqlalchemy as sa
+from alembic import op
+
+
+revision = 'a39c7f98a7fa'
+down_revision = '9ef1a1643c2a'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ 'user_token',
+ 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('last_usage_time', sa.DateTime(), nullable=True),
+ sa.Column('version', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id'))
+ op.create_index(
+ op.f('ix_user_token_user_id'), 'user_token', ['user_id'], unique=False)
+
+
+def downgrade():
+ op.drop_index(op.f('ix_user_token_user_id'), table_name='user_token')
+ op.drop_table('user_token')
diff --git a/server/szurubooru/model/__init__.py b/server/szurubooru/model/__init__.py
index ad2231c..4892b97 100644
--- a/server/szurubooru/model/__init__.py
+++ b/server/szurubooru/model/__init__.py
@@ -1,5 +1,5 @@
from szurubooru.model.base import Base
-from szurubooru.model.user import User
+from szurubooru.model.user import User, UserToken
from szurubooru.model.tag_category import TagCategory
from szurubooru.model.tag import Tag, TagName, TagSuggestion, TagImplication
from szurubooru.model.post import (
diff --git a/server/szurubooru/model/user.py b/server/szurubooru/model/user.py
index 39c5a91..2d599e8 100644
--- a/server/szurubooru/model/user.py
+++ b/server/szurubooru/model/user.py
@@ -86,3 +86,25 @@ class User(Base):
'version_id_col': version,
'version_id_generator': False,
}
+
+
+class UserToken(Base):
+ __tablename__ = 'user_token'
+
+ user_token_id = sa.Column('id', sa.Integer, primary_key=True)
+ user_id = sa.Column(
+ 'user_id',
+ sa.Integer,
+ sa.ForeignKey('user.id', ondelete='CASCADE'),
+ 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)
+ 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/rest/__init__.py b/server/szurubooru/rest/__init__.py
index 14a3e30..d6b3ef2 100644
--- a/server/szurubooru/rest/__init__.py
+++ b/server/szurubooru/rest/__init__.py
@@ -1,2 +1,3 @@
from szurubooru.rest.app import application
from szurubooru.rest.context import Context, Response
+import szurubooru.rest.routes
diff --git a/server/szurubooru/tests/api/test_user_token_creating.py b/server/szurubooru/tests/api/test_user_token_creating.py
new file mode 100644
index 0000000..f550f63
--- /dev/null
+++ b/server/szurubooru/tests/api/test_user_token_creating.py
@@ -0,0 +1,29 @@
+from unittest.mock import patch
+import pytest
+from szurubooru import api
+from szurubooru.func import user_tokens, users
+
+
+@pytest.fixture(autouse=True)
+def inject_config(config_injector):
+ config_injector({'privileges': {'user_tokens:create:self': 'regular'}})
+
+
+def test_creating_user_token(
+ user_token_factory, context_factory, fake_datetime):
+ user_token = user_token_factory()
+ with patch('szurubooru.func.user_tokens.create_user_token'), \
+ patch('szurubooru.func.user_tokens.serialize_user_token'), \
+ patch('szurubooru.func.users.get_user_by_name'), \
+ fake_datetime('1969-02-12'):
+ users.get_user_by_name.return_value = user_token.user
+ user_tokens.serialize_user_token.return_value = 'serialized user token'
+ user_tokens.create_user_token.return_value = user_token
+ result = api.user_token_api.create_user_token(
+ context_factory(user=user_token.user),
+ {
+ 'user_name': user_token.user.name
+ })
+ assert result == 'serialized user token'
+ user_tokens.create_user_token.assert_called_once_with(
+ user_token.user, True)
diff --git a/server/szurubooru/tests/api/test_user_token_deleting.py b/server/szurubooru/tests/api/test_user_token_deleting.py
new file mode 100644
index 0000000..8534152
--- /dev/null
+++ b/server/szurubooru/tests/api/test_user_token_deleting.py
@@ -0,0 +1,30 @@
+from unittest.mock import patch
+import pytest
+from szurubooru import api, db
+from szurubooru.func import user_tokens, users
+
+
+@pytest.fixture(autouse=True)
+def inject_config(config_injector):
+ config_injector({'privileges': {'user_tokens:delete:self': 'regular'}})
+
+
+def test_deleting_user_token(
+ user_token_factory, context_factory, fake_datetime):
+ user_token = user_token_factory()
+ db.session.add(user_token)
+ db.session.commit()
+ with patch('szurubooru.func.user_tokens.get_by_user_and_token'), \
+ patch('szurubooru.func.users.get_user_by_name'), \
+ fake_datetime('1969-02-12'):
+ users.get_user_by_name.return_value = user_token.user
+ user_tokens.get_by_user_and_token.return_value = user_token
+ result = api.user_token_api.delete_user_token(
+ context_factory(user=user_token.user),
+ {
+ 'user_name': user_token.user.name,
+ 'user_token': user_token.token
+ })
+ assert result == {}
+ user_tokens.get_by_user_and_token.assert_called_once_with(
+ user_token.user, user_token.token)
diff --git a/server/szurubooru/tests/api/test_user_token_retrieving.py b/server/szurubooru/tests/api/test_user_token_retrieving.py
new file mode 100644
index 0000000..01b2534
--- /dev/null
+++ b/server/szurubooru/tests/api/test_user_token_retrieving.py
@@ -0,0 +1,31 @@
+from unittest.mock import patch
+import pytest
+from szurubooru import api
+from szurubooru.func import user_tokens, users
+
+
+@pytest.fixture(autouse=True)
+def inject_config(config_injector):
+ config_injector({'privileges': {'user_tokens:list:self': 'regular'}})
+
+
+def test_retrieving_user_tokens(
+ user_token_factory, context_factory, fake_datetime):
+ user_token1 = user_token_factory()
+ user_token2 = user_token_factory(user=user_token1.user)
+ user_token3 = user_token_factory(user=user_token1.user)
+ with patch('szurubooru.func.user_tokens.get_user_tokens'), \
+ patch('szurubooru.func.user_tokens.serialize_user_token'), \
+ patch('szurubooru.func.users.get_user_by_name'), \
+ fake_datetime('1969-02-12'):
+ users.get_user_by_name.return_value = user_token1.user
+ user_tokens.serialize_user_token.return_value = 'serialized user token'
+ user_tokens.get_user_tokens.return_value = [user_token1, user_token2,
+ user_token3]
+ result = api.user_token_api.get_user_tokens(
+ context_factory(user=user_token1.user),
+ {
+ 'user_name': user_token1.user.name
+ })
+ assert result == {'results': ['serialized user token'] * 3}
+ user_tokens.get_user_tokens.assert_called_once_with(user_token1.user)
diff --git a/server/szurubooru/tests/api/test_user_token_updating.py b/server/szurubooru/tests/api/test_user_token_updating.py
new file mode 100644
index 0000000..bf725a3
--- /dev/null
+++ b/server/szurubooru/tests/api/test_user_token_updating.py
@@ -0,0 +1,42 @@
+from unittest.mock import patch
+import pytest
+from szurubooru import api, db
+from szurubooru.func import user_tokens, users
+
+
+@pytest.fixture(autouse=True)
+def inject_config(config_injector):
+ config_injector({'privileges': {'user_tokens:edit:self': 'regular'}})
+
+
+def test_edit_user_token(user_token_factory, context_factory, fake_datetime):
+ user_token = user_token_factory()
+ db.session.add(user_token)
+ db.session.commit()
+ with patch('szurubooru.func.user_tokens.get_by_user_and_token'), \
+ patch('szurubooru.func.user_tokens.update_user_token_enabled'), \
+ patch('szurubooru.func.user_tokens.update_user_token_edit_time'), \
+ patch('szurubooru.func.user_tokens.serialize_user_token'), \
+ patch('szurubooru.func.users.get_user_by_name'), \
+ fake_datetime('1969-02-12'):
+ users.get_user_by_name.return_value = user_token.user
+ user_tokens.serialize_user_token.return_value = 'serialized user token'
+ user_tokens.get_by_user_and_token.return_value = user_token
+ result = api.user_token_api.update_user_token(
+ context_factory(
+ params={
+ 'version': user_token.version,
+ 'enabled': False,
+ },
+ user=user_token.user),
+ {
+ 'user_name': user_token.user.name,
+ 'user_token': user_token.token
+ })
+ assert result == 'serialized user token'
+ user_tokens.get_by_user_and_token.assert_called_once_with(
+ user_token.user, user_token.token)
+ user_tokens.update_user_token_enabled.assert_called_once_with(
+ user_token, False)
+ user_tokens.update_user_token_edit_time.assert_called_once_with(
+ user_token)
diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py
index db7806e..f6eeee1 100644
--- a/server/szurubooru/tests/conftest.py
+++ b/server/szurubooru/tests/conftest.py
@@ -93,11 +93,11 @@ def session(query_logger): # pylint: disable=unused-argument
@pytest.fixture
def context_factory(session):
- def factory(params=None, files=None, user=None):
+ def factory(params=None, files=None, user=None, headers=None):
ctx = rest.Context(
method=None,
url=None,
- headers={},
+ headers=headers or {},
params=params or {},
files=files or {})
ctx.session = session
@@ -134,6 +134,27 @@ def user_factory():
@pytest.fixture
+def user_token_factory(user_factory):
+ def factory(
+ user=None,
+ token=None,
+ expiration_time=None,
+ enabled=None,
+ creation_time=None):
+ if user is None:
+ user = user_factory()
+ db.session.add(user)
+ user_token = model.UserToken()
+ user_token.user = user
+ user_token.token = token or 'dummy'
+ user_token.expiration_time = expiration_time
+ user_token.enabled = enabled if enabled is not None else True
+ user_token.creation_time = creation_time or datetime(1997, 1, 1)
+ return user_token
+ return factory
+
+
+@pytest.fixture
def tag_category_factory():
def factory(name=None, color='dummy', default=False):
category = model.TagCategory()
diff --git a/server/szurubooru/tests/func/test_auth.py b/server/szurubooru/tests/func/test_auth.py
index 5d0955a..6dc79bb 100644
--- a/server/szurubooru/tests/func/test_auth.py
+++ b/server/szurubooru/tests/func/test_auth.py
@@ -1,5 +1,6 @@
-from szurubooru.func import auth
+from datetime import datetime, timedelta
import pytest
+from szurubooru.func import auth
@pytest.fixture(autouse=True)
@@ -41,3 +42,24 @@ def test_is_valid_password_auto_upgrades_user_password_hash(user_factory):
assert result is True
assert user.password_hash != hash
assert user.password_revision > revision
+
+
+def test_is_valid_token(user_token_factory):
+ user_token = user_token_factory()
+ assert auth.is_valid_token(user_token)
+
+
+def test_expired_token_is_invalid(user_token_factory):
+ past_expiration = (datetime.utcnow() - timedelta(minutes=30))
+ user_token = user_token_factory(expiration_time=past_expiration)
+ assert not auth.is_valid_token(user_token)
+
+
+def test_disabled_token_is_invalid(user_token_factory):
+ user_token = user_token_factory(enabled=False)
+ assert not auth.is_valid_token(user_token)
+
+
+def test_generate_authorization_token():
+ result = auth.generate_authorization_token()
+ assert result != auth.generate_authorization_token()
diff --git a/server/szurubooru/tests/func/test_user_tokens.py b/server/szurubooru/tests/func/test_user_tokens.py
new file mode 100644
index 0000000..8c3577c
--- /dev/null
+++ b/server/szurubooru/tests/func/test_user_tokens.py
@@ -0,0 +1,155 @@
+from datetime import datetime, timedelta
+from unittest.mock import patch
+import pytest
+import pytz
+import random
+import string
+from szurubooru import db, model
+from szurubooru.func import user_tokens, users, auth, util
+
+
+def test_serialize_user_token(user_token_factory):
+ user_token = user_token_factory()
+ db.session.add(user_token)
+ db.session.flush()
+ with patch('szurubooru.func.users.get_avatar_url'):
+ users.get_avatar_url.return_value = 'https://example.com/avatar.png'
+ result = user_tokens.serialize_user_token(user_token, user_token.user)
+ assert result == {
+ 'creationTime': datetime(1997, 1, 1, 0, 0),
+ 'enabled': True,
+ 'expirationTime': None,
+ 'lastEditTime': None,
+ 'lastUsageTime': None,
+ 'note': None,
+ 'token': 'dummy',
+ 'user': {
+ 'avatarUrl': 'https://example.com/avatar.png',
+ 'name': user_token.user.name},
+ 'version': 1
+ }
+
+
+def test_serialize_user_token_none():
+ result = user_tokens.serialize_user_token(None, None)
+ assert result is None
+
+
+def test_get_by_user_and_token(user_token_factory):
+ user_token = user_token_factory()
+ db.session.add(user_token)
+ db.session.flush()
+ db.session.commit()
+ result = user_tokens.get_by_user_and_token(
+ user_token.user, user_token.token)
+ assert result == user_token
+
+
+def test_get_user_tokens(user_token_factory):
+ user_token1 = user_token_factory()
+ user_token2 = user_token_factory(user=user_token1.user)
+ db.session.add(user_token1)
+ db.session.add(user_token2)
+ db.session.flush()
+ db.session.commit()
+ result = user_tokens.get_user_tokens(user_token1.user)
+ assert result == [user_token1, user_token2]
+
+
+def test_create_user_token(user_factory):
+ user = user_factory()
+ db.session.add(user)
+ db.session.flush()
+ db.session.commit()
+ with patch('szurubooru.func.auth.generate_authorization_token'):
+ auth.generate_authorization_token.return_value = 'test'
+ result = user_tokens.create_user_token(user, True)
+ assert result.token == 'test'
+ assert result.user == user
+
+
+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):
+ user_token = user_token_factory()
+ assert user_token.last_edit_time is None
+ user_tokens.update_user_token_edit_time(user_token)
+ assert user_token.last_edit_time is not None
+
+
+def test_update_user_token_note(user_token_factory):
+ user_token = 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):
+ user_token = user_token_factory()
+ assert user_token.note is None
+ note_max_length = util.get_column_size(model.UserToken.note) + 1
+ note = ''.join(
+ random.choice(string.ascii_letters) for _ in range(note_max_length))
+ with pytest.raises(user_tokens.InvalidNoteError):
+ user_tokens.update_user_token_note(user_token, note)
+
+
+def test_update_user_token_expiration_time(user_token_factory):
+ user_token = user_token_factory()
+ assert user_token.expiration_time is None
+ expiration_time_str = (
+ (datetime.utcnow() + timedelta(days=1))
+ .replace(tzinfo=pytz.utc)
+ ).isoformat()
+ 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):
+ user_token = user_token_factory()
+ assert user_token.expiration_time is None
+ expiration_time_str = (
+ (datetime.utcnow() - timedelta(days=1))
+ .replace(tzinfo=pytz.utc)
+ ).isoformat()
+ with pytest.raises(
+ user_tokens.InvalidExpirationError,
+ match='Expiration cannot happen in the past'):
+ user_tokens.update_user_token_expiration_time(
+ user_token, expiration_time_str)
+
+
+@pytest.mark.parametrize('expiration_time_str', [
+ datetime.utcnow().isoformat(),
+ (datetime.utcnow() - timedelta(days=1)).ctime(),
+ '1970/01/01 00:00:01.0000Z',
+ '70/01/01 00:00:01.0000Z',
+ ''.join(random.choice(string.ascii_letters) for _ in range(15)),
+ ''.join(random.choice(string.digits) for _ in range(8))
+])
+def test_update_user_token_expiration_time_invalid_format(
+ expiration_time_str, user_token_factory):
+ user_token = user_token_factory()
+ assert user_token.expiration_time is None
+
+ with pytest.raises(
+ user_tokens.InvalidExpirationError,
+ match='Expiration is in an invalid format %s'
+ % 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/__init__.py b/server/szurubooru/tests/middleware/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/server/szurubooru/tests/middleware/__init__.py
diff --git a/server/szurubooru/tests/middleware/test_authenticator.py b/server/szurubooru/tests/middleware/test_authenticator.py
new file mode 100644
index 0000000..be21a93
--- /dev/null
+++ b/server/szurubooru/tests/middleware/test_authenticator.py
@@ -0,0 +1,93 @@
+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
+
+
+def test_process_request_no_header(context_factory):
+ ctx = context_factory()
+ authenticator.process_request(ctx)
+ 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(
+ headers={
+ 'Authorization': 'Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk'
+ })
+ 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 ctx.user == user
+
+
+def test_process_request_token_auth_valid(context_factory, user_token_factory):
+ user_token = user_token_factory()
+ ctx = context_factory(
+ headers={
+ 'Authorization': 'Token dGVzdFVzZXI6dGVzdFRva2Vu'
+ })
+ 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 ctx.user == user_token.user
+
+
+def test_process_request_bad_header(context_factory):
+ ctx = context_factory(
+ headers={
+ 'Authorization': 'Secret SuperSecretValue'
+ })
+ with pytest.raises(errors.HttpBadRequest):
+ authenticator.process_request(ctx)
diff --git a/server/szurubooru/tests/model/test_user_token.py b/server/szurubooru/tests/model/test_user_token.py
new file mode 100644
index 0000000..0280082
--- /dev/null
+++ b/server/szurubooru/tests/model/test_user_token.py
@@ -0,0 +1,14 @@
+from datetime import datetime
+from szurubooru import db
+
+
+def test_saving_user_token(user_token_factory):
+ user_token = user_token_factory()
+ db.session.add(user_token)
+ db.session.flush()
+ db.session.refresh(user_token)
+ assert not db.session.dirty
+ assert user_token.user is not None
+ assert user_token.token == 'dummy'
+ assert user_token.enabled is True
+ assert user_token.creation_time == datetime(1997, 1, 1)