Merge branch 'master' into github-master

This commit is contained in:
ReAnzu 2018-02-27 22:40:35 -06:00
commit 5c929356bf
44 changed files with 1244 additions and 63 deletions

4
.gitignore vendored
View file

@ -2,3 +2,7 @@ config.yaml
*/*_modules/ */*_modules/
.coverage .coverage
.cache .cache
__pycache__
.idea/
*.iml
data/

160
API.md
View file

@ -7,6 +7,7 @@
1. [General rules](#general-rules) 1. [General rules](#general-rules)
- [Authentication](#authentication) - [Authentication](#authentication)
- [User token authentication](#user-token-authentication)
- [Basic requests](#basic-requests) - [Basic requests](#basic-requests)
- [File uploads](#file-uploads) - [File uploads](#file-uploads)
- [Error handling](#error-handling) - [Error handling](#error-handling)
@ -56,6 +57,11 @@
- [Updating user](#updating-user) - [Updating user](#updating-user)
- [Getting user](#getting-user) - [Getting user](#getting-user)
- [Deleting user](#deleting-user) - [Deleting user](#deleting-user)
- User Tokens
- [Listing tokens](#listing-tokens)
- [Creating token](#creating-token)
- [Updating token](#updating-token)
- [Deleting token](#deleting-token)
- Password reset - Password reset
- [Password reset - step 1: mail request](#password-reset---step-2-confirmation) - [Password reset - step 1: mail request](#password-reset---step-2-confirmation)
- [Password reset - step 2: confirmation](#password-reset---step-2-confirmation) - [Password reset - step 2: confirmation](#password-reset---step-2-confirmation)
@ -70,6 +76,7 @@
- [User](#user) - [User](#user)
- [Micro user](#micro-user) - [Micro user](#micro-user)
- [User token](#user-token)
- [Tag category](#tag-category) - [Tag category](#tag-category)
- [Tag](#tag) - [Tag](#tag)
- [Micro tag](#micro-tag) - [Micro tag](#micro-tag)
@ -91,16 +98,35 @@
## Authentication ## Authentication
Authentication is achieved by means of [basic HTTP 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
reason, it is recommended to connect through HTTPS. There are no sessions, so use of [user token authentication](#user-token-authentication). For this reason,
every privileged request must be authenticated. Available privileges depend on it is recommended to connect through HTTPS. There are no sessions, so every
the user's rank. The way how rank translates to privileges is defined in the privileged request must be authenticated. Available privileges depend on the
server's configuration. user's rank. The way how rank translates to privileges is defined in the server's
configuration.
It is recommended to add `?bump-login` GET parameter to the first request in a 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 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. 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 ## Basic requests
Every request must use `Content-Type: application/json` and `Accept: Every request must use `Content-Type: application/json` and `Accept:
@ -1469,6 +1495,104 @@ data.
Deletes existing user. Deletes existing user.
## Listing tokens
- **Request**
`GET /user-tokens/`
- **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 users tokens for the currently logged in user.
## Creating token
- **Request**
`POST /user-token`
- **Input**
```json5
{}
```
- **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
- **Request**
`PUT /user-token/<token>`
- **Input**
```json5
{
"version": <version>,
"enabled": <enabled>, // 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 token
- **Request**
`DELETE /user-token/<token>`
- **Input**
```json5
{}
```
- **Output**
```json5
{}
```
- **Errors**
- the token does not exist
- privileges are too low
- **Description**
Deletes existing user token.
## Password reset - step 1: mail request ## Password reset - step 1: mail request
- **Request** - **Request**
@ -1701,6 +1825,32 @@ A single user.
A [user resource](#user) stripped down to `name` and `avatarUrl` fields. A [user resource](#user) stripped down to `name` and `avatarUrl` fields.
## User token
**Description**
A single user token.
**Structure**
```json5
{
"user": <user>,
"token": <token>,
"enabled": <enabled>,
"version": <version>,
"creationTime": <creation-time>,
"lastEditTime": <last-edit-time>,
}
```
**Field meaning**
- `<user>`: micro user. See [micro user](#micro-user).
- `<token>`: the token that can be used to authenticate the user.
- `<enabled>`: whether the token is still valid for authentication.
- `<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.
## Tag category ## Tag category
**Description** **Description**

View file

@ -1,6 +1,6 @@
#user #user
width: 100% width: 100%
max-width: 35em max-width: 45em
nav.text-nav nav.text-nav
margin-bottom: 1.5em margin-bottom: 1.5em
@ -37,6 +37,24 @@
height: 1px height: 1px
clear: both clear: both
#user-tokens
.token-flex-container
width: 100%
display: flex;
flex-direction column;
padding-bottom: 0.5em;
.token-flex-row
display: flex;
flex-direction: row;
justify-content: space-between;
padding-top: 0.25em;
padding-bottom: 0.25em;
border-bottom: black solid 1px;
form
width: auto;
#user-delete form #user-delete form
width: 100% width: 100%

View file

@ -6,6 +6,9 @@
--><% if (ctx.canEditAnything) { %><!-- --><% if (ctx.canEditAnything) { %><!--
--><li data-name='edit'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'edit') %>'>Account settings</a></li><!-- --><li data-name='edit'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'edit') %>'>Account settings</a></li><!--
--><% } %><!-- --><% } %><!--
--><% if (ctx.canListTokens) { %><!--
--><li data-name='list-tokens'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'list-tokens') %>'>Manage tokens</a></li><!--
--><% } %><!--
--><% if (ctx.canDelete) { %><!-- --><% if (ctx.canDelete) { %><!--
--><li data-name='delete'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'delete') %>'>Account deletion</a></li><!-- --><li data-name='delete'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'delete') %>'>Account deletion</a></li><!--
--><% } %><!-- --><% } %><!--

View file

@ -0,0 +1,29 @@
<div id='user-tokens'>
<div class='messages'></div>
<% if (ctx.tokens.length > 0) { %>
<div class="token-flex-container">
<div class="token-flex-row">
<div>Token</div>
<div>Actions</div>
</div>
<% _.each(ctx.tokens, function(token, index) { %>
<div class="token-flex-row">
<div><%= token.token %></div>
<div>
<form id='token<%= index %>'>
<input type='hidden' name='token' value='<%= token.token %>'/>
<input type='submit' value='Delete token'/>
</form>
</div>
</div>
<% }); %>
</div>
<% } else { %>
<h2>No Registered Tokens</h2>
<% } %>
<form id='create-token-form'>
<div class='buttons'>
<input type='submit' value='Create token'/>
</div>
</form>
</div>

View file

@ -15,6 +15,7 @@ class Api extends events.EventTarget {
this.user = null; this.user = null;
this.userName = null; this.userName = null;
this.userPassword = null; this.userPassword = null;
this.userToken = null;
this.cache = {}; this.cache = {};
this.allRanks = [ this.allRanks = [
'anonymous', 'anonymous',
@ -87,11 +88,69 @@ class Api extends events.EventTarget {
loginFromCookies() { loginFromCookies() {
const auth = cookies.getJSON('auth'); const auth = cookies.getJSON('auth');
return auth && auth.user && auth.password ? return auth && auth.user && auth.token ?
this.login(auth.user, auth.password, true) : this.login_with_token(auth.user, auth.token, true) :
Promise.resolve(); Promise.resolve();
} }
login_with_token(userName, token, doRemember) {
this.cache = {};
return new Promise((resolve, reject) => {
this.userName = userName;
this.userToken = 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();
});
});
}
create_token(userName, options) {
return new Promise((resolve, reject) => {
this.post('/user-token/' + userName, {})
.then(response => {
cookies.set(
'auth',
{'user': userName, 'token': response.token},
options);
this.userName = userName;
this.userToken = response.token;
this.userPassword = null;
}, error => {
reject(error);
});
});
}
delete_token(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) { login(userName, userPassword, doRemember) {
this.cache = {}; this.cache = {};
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -103,10 +162,7 @@ class Api extends events.EventTarget {
if (doRemember) { if (doRemember) {
options.expires = 365; options.expires = 365;
} }
cookies.set( this.create_token(this.userName, options);
'auth',
{'user': userName, 'password': userPassword},
options);
this.user = response; this.user = response;
resolve(); resolve();
this.dispatchEvent(new CustomEvent('login')); this.dispatchEvent(new CustomEvent('login'));
@ -118,9 +174,20 @@ class Api extends events.EventTarget {
} }
logout() { logout() {
let self = this;
this.delete_token(this.userName, this.userToken)
.then(response => {
self._logout();
}, error => {
self._logout();
});
}
_logout() {
this.user = null; this.user = null;
this.userName = null; this.userName = null;
this.userPassword = null; this.userPassword = null;
this.userToken = null;
this.dispatchEvent(new CustomEvent('logout')); this.dispatchEvent(new CustomEvent('logout'));
} }
@ -258,7 +325,11 @@ class Api extends events.EventTarget {
} }
try { try {
if (this.userName && this.userPassword) { if (this.userName && this.userToken) {
req.auth = null;
req.set('Authorization', 'Token ' + new Buffer(this.userName + ":" + this.userToken).toString('base64'))
}
else if (this.userName && this.userPassword) {
req.auth( req.auth(
this.userName, this.userName,
encodeURIComponent(this.userPassword) encodeURIComponent(this.userPassword)

View file

@ -7,6 +7,7 @@ const misc = require('../util/misc.js');
const config = require('../config.js'); const config = require('../config.js');
const views = require('../util/views.js'); const views = require('../util/views.js');
const User = require('../models/user.js'); const User = require('../models/user.js');
const UserToken = require('../models/user_token.js');
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require('../models/top_navigation.js');
const UserView = require('../views/user_view.js'); const UserView = require('../views/user_view.js');
const EmptyView = require('../views/empty_view.js'); const EmptyView = require('../views/empty_view.js');
@ -21,8 +22,11 @@ class UserController {
return; return;
} }
this._successMessages = [];
this._errorMessages = [];
topNavigation.setTitle('User ' + userName); topNavigation.setTitle('User ' + userName);
User.get(userName).then(user => { User.get(userName).then(async user => {
const isLoggedIn = api.isLoggedIn(user); const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any'; const infix = isLoggedIn ? 'self' : 'any';
@ -48,6 +52,17 @@ class UserController {
} else { } else {
topNavigation.activate('users'); topNavigation.activate('users');
} }
let userTokens = [];
if (section === 'list-tokens') {
userTokens = await UserToken.get(userName)
.then(response => {
return response;
}, error => {
return [];
});
}
this._view = new UserView({ this._view = new UserView({
user: user, user: user,
section: section, section: section,
@ -58,18 +73,50 @@ class UserController {
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`), canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`), canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`),
canEditAnything: api.hasPrivilege(`users:edit:${infix}`), 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}`), canDelete: api.hasPrivilege(`users:delete:${infix}`),
ranks: ranks, ranks: ranks,
tokens: userTokens,
}); });
this._view.addEventListener('change', e => this._evtChange(e)); this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtUpdate(e)); this._view.addEventListener('submit', e => this._evtUpdate(e));
this._view.addEventListener('delete', e => this._evtDelete(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));
for (let i = 0; i < this._successMessages.length; i++) {
this.showSuccess(this._successMessages[i]);
}
for (let i = 0; i < this._errorMessages.length; i++) {
this.showError(this._errorMessages[i]);
}
}, error => { }, error => {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError(error.message); 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) { _evtChange(e) {
misc.enableExitConfirmation(); misc.enableExitConfirmation();
} }
@ -148,6 +195,32 @@ class UserController {
this._view.enableForm(); this._view.enableForm();
}); });
} }
_evtCreateToken(e) {
this._view.clearMessages();
this._view.disableForm();
UserToken.create(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 + ' created.');
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
_evtDeleteToken(e) {
this._view.clearMessages();
this._view.disableForm();
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();
});
}
} }
module.exports = router => { module.exports = router => {
@ -157,6 +230,9 @@ module.exports = router => {
router.enter(['user', ':name', 'edit'], (ctx, next) => { router.enter(['user', ':name', 'edit'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'edit'); 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) => { router.enter(['user', ':name', 'delete'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'delete'); ctx.controller = new UserController(ctx, 'delete');
}); });

View file

@ -29,13 +29,22 @@ class UserRegistrationController {
user.name = e.detail.name; user.name = e.detail.name;
user.email = e.detail.email; user.email = e.detail.email;
user.password = e.detail.password; user.password = e.detail.password;
const isLoggedIn = api.isLoggedIn();
user.save().then(() => { user.save().then(() => {
// TODO: Support the flow where an admin creates a user. Don't log them out... if (isLoggedIn) {
return Promise.resolve();
} else {
api.forget(); api.forget();
return api.login(e.detail.name, e.detail.password, false); return api.login(e.detail.name, e.detail.password, false);
}
}).then(() => { }).then(() => {
if (isLoggedIn) {
const ctx = router.show(uri.formatClientLink('users'));
ctx.controller.showSuccess('User added!');
} else {
const ctx = router.show(uri.formatClientLink()); const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Welcome aboard!'); ctx.controller.showSuccess('Welcome aboard!');
}
}, error => { }, error => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();

View file

@ -0,0 +1,76 @@
'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 enabled() { return this._enabled; }
get version() { return this._version; }
get creation_time() { return this._creation_time; }
static fromResponse(response) {
if (typeof response.results !== 'undefined') {
let token_list = [];
for (let i = 0; i < response.results.length; i++) {
const token = new UserToken();
token._updateFromResponse(response.results[i]);
token_list.push(token)
}
return token_list;
} 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) {
return api.post(uri.formatApiLink('user-token', userName))
.then(response => {
return Promise.resolve(UserToken.fromResponse(response))
});
}
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,
_enabled: response.enabled,
_version: response.version,
_creation_time: response.creationTime,
};
Object.assign(this, map);
Object.assign(this._orig, map);
}
}
module.exports = UserToken;

View file

@ -0,0 +1,86 @@
'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()
}
_decorateTokenForms() {
for (let i = 0; i < this._tokens.length; i++) {
let formNode = this._hostNode.querySelector('#token' + i);
views.decorateValidator(formNode);
formNode.addEventListener('submit', e => this._evtDelete(e));
this._tokenFormNodes.push(formNode)
}
}
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 i = 0; i < this._tokenFormNodes.length; i++) {
let formNode = this._tokenFormNodes[i];
views.enableForm(formNode);
}
}
disableForm() {
views.disableForm(this._formNode);
for (let i = 0; i < this._tokenFormNodes.length; i++) {
let formNode = this._tokenFormNodes[i];
views.disableForm(formNode);
}
}
_evtDelete(e) {
e.preventDefault();
const userToken = this._tokens[parseInt(e.target.id.replace('token', ''))];
this.dispatchEvent(new CustomEvent('delete', {
detail: {
user: this._user,
userToken: userToken,
},
}));
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
user: this._user
},
}));
}
get _formNode() {
return this._hostNode.querySelector('#create-token-form');
}
}
module.exports = UserTokenView;

View file

@ -3,6 +3,7 @@
const events = require('../events.js'); const events = require('../events.js');
const views = require('../util/views.js'); const views = require('../util/views.js');
const UserDeleteView = require('./user_delete_view.js'); const UserDeleteView = require('./user_delete_view.js');
const UserTokensView = require('./user_tokens_view.js');
const UserSummaryView = require('./user_summary_view.js'); const UserSummaryView = require('./user_summary_view.js');
const UserEditView = require('./user_edit_view.js'); const UserEditView = require('./user_edit_view.js');
const EmptyView = require('../views/empty_view.js'); const EmptyView = require('../views/empty_view.js');
@ -42,7 +43,16 @@ class UserView extends events.EventTarget {
this._view = new UserEditView(ctx); this._view = new UserEditView(ctx);
events.proxyEvent(this._view, this, 'submit'); 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');
}
} else if (ctx.section == 'delete') { } else if (ctx.section == 'delete') {
if (!this._ctx.canDelete) { if (!this._ctx.canDelete) {
this._view = new EmptyView(); this._view = new EmptyView();

View file

@ -68,7 +68,8 @@ default_rank: regular
privileges: privileges:
'users:create': anonymous 'users:create:self': anonymous # Registration permission
'users:create:any': administrator
'users:list': regular 'users:list': regular
'users:view': regular 'users:view': regular
'users:edit:any:name': moderator 'users:edit:any:name': moderator
@ -84,6 +85,15 @@ privileges:
'users:delete:any': administrator 'users:delete:any': administrator
'users:delete:self': regular '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:anonymous': regular
'posts:create:identified': regular 'posts:create:identified': regular
'posts:list': anonymous 'posts:list': anonymous

View file

@ -11,3 +11,4 @@ scipy>=0.18.1
elasticsearch>=5.0.0 elasticsearch>=5.0.0
elasticsearch-dsl>=5.0.0 elasticsearch-dsl>=5.0.0
scikit-image>=0.12 scikit-image>=0.12
pynacl>=1.2.1

View file

@ -1,5 +1,6 @@
import szurubooru.api.info_api import szurubooru.api.info_api
import szurubooru.api.user_api import szurubooru.api.user_api
import szurubooru.api.user_token_api
import szurubooru.api.post_api import szurubooru.api.post_api
import szurubooru.api.tag_api import szurubooru.api.tag_api
import szurubooru.api.tag_category_api import szurubooru.api.tag_category_api

View file

@ -1,5 +1,5 @@
from typing import Any, Dict from typing import Any, Dict
from szurubooru import model, search, rest, config, errors from szurubooru import model, search, rest
from szurubooru.func import auth, users, serialization, versions from szurubooru.func import auth, users, serialization, versions

View file

@ -0,0 +1,60 @@
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)
user_token = user_tokens.create_user_token(user)
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_user_token_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'))
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_user_token_by_user_and_token(user, params['user_token'])
if user_token is not None:
ctx.session.delete(user_token)
ctx.session.commit()
return {}

View file

@ -1,8 +1,12 @@
import hashlib import hashlib
import random import random
from collections import OrderedDict from collections import OrderedDict
from szurubooru import config, model, errors from nacl.exceptions import InvalidkeyError
from szurubooru import config, model, errors, db
from szurubooru.func import util from szurubooru.func import util
from nacl.pwhash import argon2id, verify
import uuid
RANK_MAP = OrderedDict([ RANK_MAP = OrderedDict([
@ -17,7 +21,14 @@ RANK_MAP = OrderedDict([
def get_password_hash(salt: str, password: str) -> str: def get_password_hash(salt: str, password: str) -> str:
''' Retrieve new-style password hash. ''' """ Retrieve argon2id password hash."""
return argon2id.str(
(config.config['secret'] + salt + password).encode('utf8')
).decode('utf8')
def get_sha256_legacy_password_hash(salt: str, password: str) -> str:
""" Retrieve old-style sha256 password hash."""
digest = hashlib.sha256() digest = hashlib.sha256()
digest.update(config.config['secret'].encode('utf8')) digest.update(config.config['secret'].encode('utf8'))
digest.update(salt.encode('utf8')) digest.update(salt.encode('utf8'))
@ -25,8 +36,8 @@ def get_password_hash(salt: str, password: str) -> str:
return digest.hexdigest() return digest.hexdigest()
def get_legacy_password_hash(salt: str, password: str) -> str: def get_sha1_legacy_password_hash(salt: str, password: str) -> str:
''' Retrieve old-style password hash. ''' """ Retrieve old-style sha1 password hash."""
digest = hashlib.sha1() digest = hashlib.sha1()
digest.update(b'1A2/$_4xVa') digest.update(b'1A2/$_4xVa')
digest.update(salt.encode('utf8')) digest.update(salt.encode('utf8'))
@ -47,11 +58,26 @@ def create_password() -> str:
def is_valid_password(user: model.User, password: str) -> bool: def is_valid_password(user: model.User, password: str) -> bool:
assert user assert user
salt, valid_hash = user.password_salt, user.password_hash salt, valid_hash = user.password_salt, user.password_hash
try:
return verify(user.password_hash.encode('utf8'),
(config.config['secret'] + salt + password).encode('utf8'))
except InvalidkeyError:
possible_hashes = [ possible_hashes = [
get_password_hash(salt, password), get_sha256_legacy_password_hash(salt, password),
get_legacy_password_hash(salt, password) get_sha1_legacy_password_hash(salt, password)
] ]
return valid_hash in possible_hashes if valid_hash in possible_hashes:
# Convert the user password hash to the new hash
user.password_hash = get_password_hash(salt, password)
db.session.commit()
return True
return False
def is_valid_token(user_token: model.UserToken) -> bool:
return user_token is not None and user_token.enabled
def has_privilege(user: model.User, privilege_name: str) -> bool: def has_privilege(user: model.User, privilege_name: str) -> bool:
@ -78,3 +104,7 @@ def generate_authentication_token(user: model.User) -> str:
digest.update(config.config['secret'].encode('utf8')) digest.update(config.config['secret'].encode('utf8'))
digest.update(user.password_salt.encode('utf8')) digest.update(user.password_salt.encode('utf8'))
return digest.hexdigest() return digest.hexdigest()
def generate_authorization_token() -> str:
return uuid.uuid4().__str__()

View file

@ -722,12 +722,14 @@ def merge_posts(
merge_favorites(source_post.post_id, target_post.post_id) merge_favorites(source_post.post_id, target_post.post_id)
merge_relations(source_post.post_id, target_post.post_id) merge_relations(source_post.post_id, target_post.post_id)
delete(source_post) content = None
db.session.flush()
if replace_content: if replace_content:
content = files.get(get_post_content_path(source_post)) content = files.get(get_post_content_path(source_post))
delete(source_post)
db.session.flush()
if content is not None:
update_post_content(target_post, content) update_post_content(target_post, content)

View file

@ -0,0 +1,86 @@
from datetime import datetime
from typing import Any, Optional, List, Dict, Callable
from szurubooru import db, model, rest
from szurubooru.func import auth, serialization, users
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,
'enabled': self.serialize_enabled,
'version': self.serialize_version,
'creationTime': self.serialize_creation_time,
'lastEditTime': self.serialize_last_edit_time,
}
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_token(self) -> Any:
return self.user_token.token
def serialize_enabled(self) -> Any:
return self.user_token.enabled
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_user_token_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, 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) -> model.UserToken:
assert user
user_token = model.UserToken()
user_token.user = user
user_token.token = auth.generate_authorization_token()
user_token.enabled = True
user_token.creation_time = datetime.utcnow()
db.session.add(user_token)
db.session.commit()
return user_token
def update_user_token_enabled(user_token: model.UserToken, enabled: bool) -> None:
assert user_token
user_token.enabled = enabled if enabled is not None else True
def update_user_token_edit_time(user_token: model.UserToken) -> None:
assert user_token
user_token.last_edit_time = datetime.utcnow()

View file

@ -1,7 +1,9 @@
import re
from typing import Any, Optional, Union, List, Dict, Callable
from datetime import datetime from datetime import datetime
from typing import Any, Optional, Union, List, Dict, Callable
import re
import sqlalchemy as sa import sqlalchemy as sa
from szurubooru import config, db, model, errors, rest from szurubooru import config, db, model, errors, rest
from szurubooru.func import auth, util, serialization, files, images from szurubooru.func import auth, util, serialization, files, images

View file

@ -1,7 +1,7 @@
import base64 import base64
from typing import Optional from typing import Optional
from szurubooru import db, model, errors, rest from szurubooru import db, model, errors, rest
from szurubooru.func import auth, users from szurubooru.func import auth, users, user_tokens
from szurubooru.rest.errors import HttpBadRequest from szurubooru.rest.errors import HttpBadRequest
@ -13,19 +13,33 @@ def _authenticate(username: str, password: str) -> model.User:
return user return user
def _authenticate_token(username: str, token: str) -> model.User:
"""Try to authenticate user. Throw AuthError for invalid users."""
user = users.get_user_by_name(username)
user_token = user_tokens.get_user_token_by_user_and_token(user, token)
if not auth.is_valid_token(user_token):
raise errors.AuthError('Invalid token.')
return user
def _get_user(ctx: rest.Context) -> Optional[model.User]: def _get_user(ctx: rest.Context) -> Optional[model.User]:
if not ctx.has_header('Authorization'): if not ctx.has_header('Authorization'):
return None return None
try: try:
auth_type, credentials = ctx.get_header('Authorization').split(' ', 1) auth_type, credentials = ctx.get_header('Authorization').split(' ', 1)
if auth_type.lower() != 'basic': if auth_type.lower() == 'basic':
raise HttpBadRequest(
'ValidationError',
'Only basic HTTP authentication is supported.')
username, password = base64.decodebytes( username, password = base64.decodebytes(
credentials.encode('ascii')).decode('utf8').split(':', 1) credentials.encode('ascii')).decode('utf8').split(':', 1)
return _authenticate(username, password) return _authenticate(username, password)
elif auth_type.lower() == 'token':
username, token = base64.decodebytes(
credentials.encode('ascii')).decode('utf8').split(':', 1)
return _authenticate_token(username, token)
else:
raise HttpBadRequest(
'ValidationError',
'Only basic HTTP authentication is supported.')
except ValueError as err: except ValueError as err:
msg = ( msg = (
'Basic authentication header value are not properly formed. ' 'Basic authentication header value are not properly formed. '
@ -35,7 +49,6 @@ def _get_user(ctx: rest.Context) -> Optional[model.User]:
msg.format(ctx.get_header('Authorization'), str(err))) msg.format(ctx.get_header('Authorization'), str(err)))
@rest.middleware.pre_hook
def process_request(ctx: rest.Context) -> None: def process_request(ctx: rest.Context) -> None:
''' Bind the user to request. Update last login time if needed. ''' ''' Bind the user to request. Update last login time if needed. '''
auth_user = _get_user(ctx) auth_user = _get_user(ctx)
@ -44,3 +57,8 @@ def process_request(ctx: rest.Context) -> None:
if ctx.get_param_as_bool('bump-login', default=False) and ctx.user.user_id: if ctx.get_param_as_bool('bump-login', default=False) and ctx.user.user_id:
users.bump_user_login_time(ctx.user) users.bump_user_login_time(ctx.user)
ctx.session.commit() ctx.session.commit()
@rest.middleware.pre_hook
def process_request_hook(ctx: rest.Context) -> None:
process_request(ctx)

View file

@ -35,7 +35,11 @@ def run_migrations_offline():
''' '''
url = alembic_config.get_main_option('sqlalchemy.url') url = alembic_config.get_main_option('sqlalchemy.url')
alembic.context.configure( alembic.context.configure(
url=url, target_metadata=target_metadata, literal_binds=True) url=url,
target_metadata=target_metadata,
literal_binds=True,
compare_type=True
)
with alembic.context.begin_transaction(): with alembic.context.begin_transaction():
alembic.context.run_migrations() alembic.context.run_migrations()
@ -56,7 +60,9 @@ def run_migrations_online():
with connectable.connect() as connection: with connectable.connect() as connection:
alembic.context.configure( alembic.context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata) target_metadata=target_metadata,
compare_type=True
)
with alembic.context.begin_transaction(): with alembic.context.begin_transaction():
alembic.context.run_migrations() alembic.context.run_migrations()

View file

@ -0,0 +1,30 @@
'''
Alter the password_hash field to work with larger output. Particularly libsodium output for greater password security.
Revision ID: 9ef1a1643c2a
Created at: 2018-02-24 23:00:32.848575
'''
import sqlalchemy as sa
from alembic import op
revision = '9ef1a1643c2a'
down_revision = '02ef5f73f4ab'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column('user', 'password_hash',
existing_type=sa.VARCHAR(length=64),
type_=sa.Unicode(length=128),
existing_nullable=False)
def downgrade():
op.alter_column('user', 'password_hash',
existing_type=sa.Unicode(length=128),
type_=sa.VARCHAR(length=64),
existing_nullable=False)

View file

@ -0,0 +1,35 @@
'''
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('enabled', sa.Boolean(), nullable=False),
sa.Column('creation_time', sa.DateTime(), nullable=False),
sa.Column('last_edit_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')

View file

@ -1,5 +1,7 @@
from szurubooru.model.base import Base 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_category import TagCategory
from szurubooru.model.tag import Tag, TagName, TagSuggestion, TagImplication from szurubooru.model.tag import Tag, TagName, TagSuggestion, TagImplication
from szurubooru.model.post import ( from szurubooru.model.post import (

View file

@ -23,7 +23,7 @@ class User(Base):
last_login_time = sa.Column('last_login_time', sa.DateTime) last_login_time = sa.Column('last_login_time', sa.DateTime)
version = sa.Column('version', sa.Integer, default=1, nullable=False) version = sa.Column('version', sa.Integer, default=1, nullable=False)
name = sa.Column('name', sa.Unicode(50), nullable=False, unique=True) name = sa.Column('name', sa.Unicode(50), nullable=False, unique=True)
password_hash = sa.Column('password_hash', sa.Unicode(64), nullable=False) password_hash = sa.Column('password_hash', sa.Unicode(128), nullable=False)
password_salt = sa.Column('password_salt', sa.Unicode(32)) password_salt = sa.Column('password_salt', sa.Unicode(32))
email = sa.Column('email', sa.Unicode(64), nullable=True) email = sa.Column('email', sa.Unicode(64), nullable=True)
rank = sa.Column('rank', sa.Unicode(32), nullable=False) rank = sa.Column('rank', sa.Unicode(32), nullable=False)
@ -84,3 +84,22 @@ class User(Base):
'version_id_col': version, 'version_id_col': version,
'version_id_generator': False, '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)
enabled = sa.Column('enabled', sa.Boolean, nullable=False, default=True)
creation_time = sa.Column('creation_time', sa.DateTime, nullable=False)
last_edit_time = sa.Column('last_edit_time', sa.DateTime)
version = sa.Column('version', sa.Integer, default=1, nullable=False)
user = sa.orm.relationship('User')

View file

@ -1,2 +1,3 @@
from szurubooru.rest.app import application from szurubooru.rest.app import application
from szurubooru.rest.context import Context, Response from szurubooru.rest.context import Context, Response
import szurubooru.rest.routes

View file

@ -7,9 +7,9 @@ pre_hooks = [] # type: List[Callable[[Context], None]]
post_hooks = [] # type: List[Callable[[Context], None]] post_hooks = [] # type: List[Callable[[Context], None]]
def pre_hook(handler: Callable) -> None: def pre_hook(handler: Callable) -> Callable:
pre_hooks.append(handler) pre_hooks.append(handler)
def post_hook(handler: Callable) -> None: def post_hook(handler: Callable) -> Callable:
post_hooks.insert(0, handler) post_hooks.insert(0, handler)

View file

@ -1,8 +1,12 @@
import logging
from typing import Callable, Dict from typing import Callable, Dict
from collections import defaultdict from collections import defaultdict
from szurubooru.rest.context import Context, Response from szurubooru.rest.context import Context, Response
logger = logging.getLogger(__name__)
# pylint: disable=invalid-name # pylint: disable=invalid-name
RouteHandler = Callable[[Context, Dict[str, str]], Response] RouteHandler = Callable[[Context, Dict[str, str]], Response]
routes = defaultdict(dict) # type: Dict[str, Dict[str, RouteHandler]] routes = defaultdict(dict) # type: Dict[str, Dict[str, RouteHandler]]
@ -11,6 +15,9 @@ routes = defaultdict(dict) # type: Dict[str, Dict[str, RouteHandler]]
def get(url: str) -> Callable[[RouteHandler], RouteHandler]: def get(url: str) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler: def wrapper(handler: RouteHandler) -> RouteHandler:
routes[url]['GET'] = handler routes[url]['GET'] = handler
logger.info(
'Registered [GET] %s (user=%s, queries=%d)',
url)
return handler return handler
return wrapper return wrapper
@ -18,6 +25,9 @@ def get(url: str) -> Callable[[RouteHandler], RouteHandler]:
def put(url: str) -> Callable[[RouteHandler], RouteHandler]: def put(url: str) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler: def wrapper(handler: RouteHandler) -> RouteHandler:
routes[url]['PUT'] = handler routes[url]['PUT'] = handler
logger.info(
'Registered [PUT] %s (user=%s, queries=%d)',
url)
return handler return handler
return wrapper return wrapper
@ -25,6 +35,9 @@ def put(url: str) -> Callable[[RouteHandler], RouteHandler]:
def post(url: str) -> Callable[[RouteHandler], RouteHandler]: def post(url: str) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler: def wrapper(handler: RouteHandler) -> RouteHandler:
routes[url]['POST'] = handler routes[url]['POST'] = handler
logger.info(
'Registered [POST] %s (user=%s, queries=%d)',
url)
return handler return handler
return wrapper return wrapper
@ -32,5 +45,8 @@ def post(url: str) -> Callable[[RouteHandler], RouteHandler]:
def delete(url: str) -> Callable[[RouteHandler], RouteHandler]: def delete(url: str) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler: def wrapper(handler: RouteHandler) -> RouteHandler:
routes[url]['DELETE'] = handler routes[url]['DELETE'] = handler
logger.info(
'Registered [DELETE] %s (user=%s, queries=%d)',
url)
return handler return handler
return wrapper return wrapper

View file

@ -6,7 +6,7 @@ from szurubooru.func import posts, tags, snapshots
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def inject_config(config_injector): def inject_config(config_injector):
config_injector({'privileges': {'posts:delete': model.User.RANK_REGULAR}}) config_injector({'secret': 'test', 'data_dir': 'test', 'privileges': {'posts:delete': model.User.RANK_REGULAR}})
def test_deleting(user_factory, post_factory, context_factory): def test_deleting(user_factory, post_factory, context_factory):

View file

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

View file

@ -0,0 +1,29 @@
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_user_token_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_user_token_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_user_token_by_user_and_token.assert_called_once_with(
user_token.user, user_token.token)

View file

@ -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', 'serialized user token', 'serialized user token']}
user_tokens.get_user_tokens.assert_called_once_with(
user_token1.user)

View file

@ -0,0 +1,41 @@
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_user_token_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_user_token_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_user_token_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)

View file

@ -93,11 +93,11 @@ def session(query_logger): # pylint: disable=unused-argument
@pytest.fixture @pytest.fixture
def context_factory(session): 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( ctx = rest.Context(
method=None, method=None,
url=None, url=None,
headers={}, headers=headers or {},
params=params or {}, params=params or {},
files=files or {}) files=files or {})
ctx.session = session ctx.session = session
@ -115,11 +115,11 @@ def config_injector():
@pytest.fixture @pytest.fixture
def user_factory(): def user_factory():
def factory(name=None, rank=model.User.RANK_REGULAR, email='dummy'): def factory(name=None, rank=model.User.RANK_REGULAR, email='dummy', password_salt=None, password=None):
user = model.User() user = model.User()
user.name = name or get_unique_name() user.name = name or get_unique_name()
user.password_salt = 'dummy' user.password_salt = password_salt or 'dummy'
user.password_hash = 'dummy' user.password_hash = password or 'dummy'
user.email = email user.email = email
user.rank = rank user.rank = rank
user.creation_time = datetime(1997, 1, 1) user.creation_time = datetime(1997, 1, 1)
@ -128,6 +128,21 @@ def user_factory():
return factory return factory
@pytest.fixture
def user_token_factory(user_factory):
def factory(user=None, token=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.enabled = enabled or True
user_token.creation_time = creation_time or datetime(1997, 1, 1)
return user_token
return factory
@pytest.fixture @pytest.fixture
def tag_category_factory(): def tag_category_factory():
def factory(name=None, color='dummy', default=False): def factory(name=None, color='dummy', default=False):

View file

@ -0,0 +1,39 @@
from szurubooru.func import auth
import pytest
@pytest.fixture(autouse=True)
def inject_config(config_injector):
config_injector({'secret': 'testSecret'})
def test_get_sha256_legacy_password_hash():
salt, password = ('testSalt', 'pass')
result = auth.get_sha256_legacy_password_hash(salt, password)
assert result == '2031ac9631353ac9303719a7f808a24f79aa1d71712c98523e4bb4cce579428a'
def test_get_sha1_legacy_password_hash():
salt, password = ('testSalt', 'pass')
result = auth.get_sha1_legacy_password_hash(salt, password)
assert result == '1eb1f953d9be303a1b54627e903e6124cfb1245b'
def test_is_valid_password(user_factory):
salt, password = ('testSalt', 'pass')
user = user_factory(password_salt=salt, password=password)
legacy_password_hash = auth.get_sha256_legacy_password_hash(salt, password)
user.password_hash = legacy_password_hash
result = auth.is_valid_password(user, password)
assert result is True
assert user.password_hash != legacy_password_hash
def test_is_valid_token(user_token_factory):
user_token = user_token_factory()
assert auth.is_valid_token(user_token)
def test_generate_authorization_token():
result = auth.generate_authorization_token()
assert result != auth.generate_authorization_token()

View file

@ -936,6 +936,6 @@ def test_merge_posts_replaces_content(
assert posts.try_get_post_by_id(source_post.post_id) is None assert posts.try_get_post_by_id(source_post.post_id) is None
post = posts.get_post_by_id(target_post.post_id) post = posts.get_post_by_id(target_post.post_id)
assert post is not None assert post is not None
assert os.path.exists(source_path) assert not os.path.exists(source_path)
assert os.path.exists(target_path1) assert os.path.exists(target_path1)
assert not os.path.exists(target_path2) assert not os.path.exists(target_path2)

View file

@ -116,7 +116,7 @@ def test_update_category_color_with_too_long_string(tag_category_factory):
def test_update_category_color_with_invalid_string(tag_category_factory): def test_update_category_color_with_invalid_string(tag_category_factory):
category = tag_category_factory() category = tag_category_factory()
with pytest.raises(tag_categories.InvalidTagCategoryColorError): with pytest.raises(tag_categories.InvalidTagCategoryColorError):
tag_categories.update_category_color(category, 'NOPE') tag_categories.update_category_color(category, 'NOPE#')
@pytest.mark.parametrize('attempt', ['#aaaaaa', '#012345', '012345', 'red']) @pytest.mark.parametrize('attempt', ['#aaaaaa', '#012345', '012345', 'red'])

View file

@ -0,0 +1,72 @@
from datetime import datetime
from unittest.mock import patch
from szurubooru import db
from szurubooru.func import user_tokens, users, auth
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,
'lastEditTime': 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_user_token_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_user_token_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)
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
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

View file

@ -0,0 +1,48 @@
from unittest.mock import patch
from szurubooru.func import auth, users, user_tokens
from szurubooru.middleware import authenticator
from szurubooru.rest import errors
import pytest
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_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_user_token_by_user_and_token'):
users.get_user_by_name.return_value = user_token.user
user_tokens.get_user_token_by_user_and_token.return_value = user_token
auth.is_valid_password.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)

View file

@ -1,5 +1,11 @@
from datetime import datetime from datetime import datetime
from szurubooru import db, model from szurubooru import db, model
import pytest
@pytest.fixture(autouse=True)
def inject_config(config_injector):
config_injector({'secret': 'test', 'data_dir': 'test'})
def test_saving_post(post_factory, user_factory, tag_factory): def test_saving_post(post_factory, user_factory, tag_factory):

View file

@ -1,5 +1,11 @@
from datetime import datetime from datetime import datetime
from szurubooru import db, model from szurubooru import db, model
import pytest
@pytest.fixture(autouse=True)
def inject_config(config_injector):
config_injector({'secret': 'test', 'data_dir': 'test'})
def test_saving_tag(tag_factory): def test_saving_tag(tag_factory):

View file

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