From 2a69f0193f9e5aa451e0dbcb8c89070f3e49ffa5 Mon Sep 17 00:00:00 2001 From: ReAnzu Date: Sun, 25 Feb 2018 04:44:02 -0600 Subject: [PATCH] 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 --- API.md | 166 +++++++++++++++++- README.md | 1 + client/css/core-forms.styl | 32 ++++ client/css/user-view.styl | 43 ++++- client/html/user.tpl | 7 +- client/html/user_tokens.tpl | 74 ++++++++ client/js/api.js | 96 +++++++++- client/js/controllers/user_controller.js | 107 ++++++++++- client/js/models/user_token.js | 116 ++++++++++++ client/js/util/polyfill.js | 7 + client/js/util/views.js | 6 + client/js/views/user_tokens_view.js | 134 ++++++++++++++ client/js/views/user_view.js | 13 +- config.yaml.dist | 9 + server/requirements.txt | 4 +- server/szurubooru/api/__init__.py | 1 + server/szurubooru/api/user_token_api.py | 83 +++++++++ server/szurubooru/func/auth.py | 28 ++- server/szurubooru/func/user_tokens.py | 146 +++++++++++++++ server/szurubooru/func/users.py | 4 +- server/szurubooru/func/util.py | 6 + server/szurubooru/middleware/authenticator.py | 59 +++++-- .../a39c7f98a7fa_add_user_token_table.py | 39 ++++ server/szurubooru/model/__init__.py | 2 +- server/szurubooru/model/user.py | 22 +++ server/szurubooru/rest/__init__.py | 1 + .../tests/api/test_user_token_creating.py | 29 +++ .../tests/api/test_user_token_deleting.py | 30 ++++ .../tests/api/test_user_token_retrieving.py | 31 ++++ .../tests/api/test_user_token_updating.py | 42 +++++ server/szurubooru/tests/conftest.py | 25 ++- server/szurubooru/tests/func/test_auth.py | 24 ++- .../szurubooru/tests/func/test_user_tokens.py | 155 ++++++++++++++++ .../szurubooru/tests/middleware/__init__.py | 0 .../tests/middleware/test_authenticator.py | 93 ++++++++++ .../szurubooru/tests/model/test_user_token.py | 14 ++ 36 files changed, 1609 insertions(+), 40 deletions(-) create mode 100644 client/html/user_tokens.tpl create mode 100644 client/js/models/user_token.js create mode 100644 client/js/views/user_tokens_view.js create mode 100644 server/szurubooru/api/user_token_api.py create mode 100644 server/szurubooru/func/user_tokens.py create mode 100644 server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py create mode 100644 server/szurubooru/tests/api/test_user_token_creating.py create mode 100644 server/szurubooru/tests/api/test_user_token_deleting.py create mode 100644 server/szurubooru/tests/api/test_user_token_retrieving.py create mode 100644 server/szurubooru/tests/api/test_user_token_updating.py create mode 100644 server/szurubooru/tests/func/test_user_tokens.py create mode 100644 server/szurubooru/tests/middleware/__init__.py create mode 100644 server/szurubooru/tests/middleware/test_authenticator.py create mode 100644 server/szurubooru/tests/model/test_user_token.py diff --git a/API.md b/API.md index 62e0eb22..c23f0454 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/` + +- **Output** + + An [unpaged search result resource](#unpaged-search-result), for which + `` 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/` + +- **Input** + + ```json5 + { + "enabled": , // optional + "note": , // optional + "expirationTime": // 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//` + +- **Input** + + ```json5 + { + "version": , + "enabled": , // optional + "note": , // optional + "expirationTime": // 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//` + +- **Input** + + ```json5 + { + "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": , + "token": , + "note": , + "enabled": , + "expirationTime": , + "version": , + "creationTime": , + "lastEditTime": , + "lastUsageTime": +} +``` + +**Field meaning** +- ``: micro user. See [micro user](#micro-user). +- ``: the token that can be used to authenticate the user. +- ``: a note that describes the token. +- ``: whether the token is still valid for authentication. +- ``: time when the token expires. It must include the timezone as per RFC 3339. +- ``: resource version. See [versioning](#versioning). +- ``: time the user token was created, formatted as per RFC 3339. +- ``: time the user token was edited, formatted as per RFC 3339. +- ``: the last time this token was used during a login involving `?bump-login`, formatted as per RFC 3339. + ## Tag category **Description** diff --git a/README.md b/README.md index ceb91093..3b5c4e44 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 f083ffbe..bed63e3b 100644 --- a/client/css/core-forms.styl +++ b/client/css/core-forms.styl @@ -137,6 +137,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 */ diff --git a/client/css/user-view.styl b/client/css/user-view.styl index 12cba75e..3cdd29cb 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-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 28e34e67..75c721f3 100644 --- a/client/html/user.tpl +++ b/client/html/user.tpl @@ -4,10 +4,13 @@ --> diff --git a/client/html/user_tokens.tpl b/client/html/user_tokens.tpl new file mode 100644 index 00000000..73db7a17 --- /dev/null +++ b/client/html/user_tokens.tpl @@ -0,0 +1,74 @@ +
+
+ <% if (ctx.tokens.length > 0) { %> +
+ <% _.each(ctx.tokens, function(token, index) { %> +
+
+
Token:
+
Note:
+
Created:
+
Expires:
+
Last used:
+
+
+
<%= token.token %>
+
+ <% if (token.note !== null) { %> + <%= token.note %> + <% } else { %> + No note + <% } %> + (change) +
+
<%= ctx.makeRelativeTime(token.creationTime) %>
+
+ <% if (token.expirationTime) { %> + <%= ctx.makeRelativeTime(token.expirationTime) %> + <% } else { %> + No expiration + <% } %> +
+
<%= ctx.makeRelativeTime(token.lastUsageTime) %>
+
+
+
+
+
+
+ <% if (token.isCurrentAuthToken) { %> + + <% } else { %> + + <% } %> +
+
+
+
+
+ <% }); %> +
+ <% } else { %> +

No Registered Tokens

+ <% } %> +
+
    +
  • + <%= ctx.makeTextInput({ + text: 'Note', + id: 'note', + }) %> +
  • +
  • + <%= ctx.makeDateInput({ + text: 'Expires', + id: 'expirationTime', + }) %> +
  • +
+
+ +
+
+
diff --git a/client/js/api.js b/client/js/api.js index abfeb6f0..3623045b 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,11 +88,76 @@ 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(); } + loginWithToken(userName, token, doRemember) { + this.cache = {}; + return new Promise((resolve, reject) => { + this.userName = userName; + this.token = token; + this.get('/user/' + userName + '?bump-login=true') + .then(response => { + const options = {}; + if (doRemember) { + options.expires = 365; + } + cookies.set( + 'auth', + {'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) => { @@ -103,10 +169,7 @@ class Api extends events.EventTarget { if (doRemember) { options.expires = 365; } - cookies.set( - 'auth', - {'user': userName, 'password': userPassword}, - options); + 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 46020f37..d042e41f 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 00000000..6e70a94b --- /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 91186b2a..71ee9724 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 b0b7ccec..9f238b1e 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 00000000..f6c84800 --- /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 d52e8999..75fd154d 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 a8b0a1ff..297dc6fb 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 7cc47868..b11c3b5d 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 2a2d5af7..0d7f75f8 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 00000000..77398239 --- /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[^/]+)/?') +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[^/]+)/?') +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[^/]+)/(?P[^/]+)/?') +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[^/]+)/(?P[^/]+)/?') +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 c9740fe0..65be79ac 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 00000000..c0f4badb --- /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 012debca..e5946dc9 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 ba2d4dc9..5e822866 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 644fe3b3..4340ec94 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 00000000..899eaa70 --- /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 ad2231c2..4892b974 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 39c5a91b..2d599e85 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 14a3e305..d6b3ef28 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 00000000..f550f63f --- /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 00000000..85341522 --- /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 00000000..01b25342 --- /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 00000000..bf725a35 --- /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 db7806e8..f6eeee18 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 @@ -133,6 +133,27 @@ def user_factory(): return 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): diff --git a/server/szurubooru/tests/func/test_auth.py b/server/szurubooru/tests/func/test_auth.py index 5d0955ad..6dc79bb5 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 00000000..8c3577c8 --- /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 00000000..e69de29b diff --git a/server/szurubooru/tests/middleware/test_authenticator.py b/server/szurubooru/tests/middleware/test_authenticator.py new file mode 100644 index 00000000..be21a931 --- /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 00000000..0280082e --- /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)