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/
.coverage
.cache
__pycache__
.idea/
*.iml
data/

160
API.md
View file

@ -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 tokens](#listing-tokens)
- [Creating token](#creating-token)
- [Updating token](#updating-token)
- [Deleting token](#deleting-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,16 +98,35 @@
## Authentication
Authentication is achieved by means of [basic HTTP
auth](https://en.wikipedia.org/wiki/Basic_access_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
server's configuration.
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 server's
configuration.
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,104 @@ data.
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
- **Request**
@ -1701,6 +1825,32 @@ A single user.
A [user resource](#user) stripped down to `name` and `avatarUrl` fields.
## User token
**Description**
A single user token.
**Structure**
```json5
{
"user": <user>,
"token": <token>,
"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
**Description**

View file

@ -1,6 +1,6 @@
#user
width: 100%
max-width: 35em
max-width: 45em
nav.text-nav
margin-bottom: 1.5em
@ -37,6 +37,24 @@
height: 1px
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
width: 100%

View file

@ -6,6 +6,9 @@
--><% if (ctx.canEditAnything) { %><!--
--><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) { %><!--
--><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.userName = null;
this.userPassword = null;
this.userToken = null;
this.cache = {};
this.allRanks = [
'anonymous',
@ -87,11 +88,69 @@ 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.login_with_token(auth.user, auth.token, true) :
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) {
this.cache = {};
return new Promise((resolve, reject) => {
@ -103,10 +162,7 @@ class Api extends events.EventTarget {
if (doRemember) {
options.expires = 365;
}
cookies.set(
'auth',
{'user': userName, 'password': userPassword},
options);
this.create_token(this.userName, options);
this.user = response;
resolve();
this.dispatchEvent(new CustomEvent('login'));
@ -118,9 +174,20 @@ class Api extends events.EventTarget {
}
logout() {
let self = this;
this.delete_token(this.userName, this.userToken)
.then(response => {
self._logout();
}, error => {
self._logout();
});
}
_logout() {
this.user = null;
this.userName = null;
this.userPassword = null;
this.userToken = null;
this.dispatchEvent(new CustomEvent('logout'));
}
@ -258,7 +325,11 @@ class Api extends events.EventTarget {
}
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(
this.userName,
encodeURIComponent(this.userPassword)

View file

@ -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,11 @@ class UserController {
return;
}
this._successMessages = [];
this._errorMessages = [];
topNavigation.setTitle('User ' + userName);
User.get(userName).then(user => {
User.get(userName).then(async user => {
const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any';
@ -48,6 +52,17 @@ class UserController {
} else {
topNavigation.activate('users');
}
let userTokens = [];
if (section === 'list-tokens') {
userTokens = await UserToken.get(userName)
.then(response => {
return response;
}, error => {
return [];
});
}
this._view = new UserView({
user: user,
section: section,
@ -58,18 +73,50 @@ 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));
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 => {
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 +195,32 @@ class UserController {
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 => {
@ -157,6 +230,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');
});

View file

@ -29,13 +29,22 @@ class UserRegistrationController {
user.name = e.detail.name;
user.email = e.detail.email;
user.password = e.detail.password;
const isLoggedIn = api.isLoggedIn();
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();
return api.login(e.detail.name, e.detail.password, false);
}
}).then(() => {
if (isLoggedIn) {
const ctx = router.show(uri.formatClientLink('users'));
ctx.controller.showSuccess('User added!');
} else {
const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Welcome aboard!');
}
}, error => {
this._view.showError(error.message);
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 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');
@ -42,7 +43,16 @@ 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');
}
} else if (ctx.section == 'delete') {
if (!this._ctx.canDelete) {
this._view = new EmptyView();

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
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

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 random
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 nacl.pwhash import argon2id, verify
import uuid
RANK_MAP = OrderedDict([
@ -17,7 +21,14 @@ RANK_MAP = OrderedDict([
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.update(config.config['secret'].encode('utf8'))
digest.update(salt.encode('utf8'))
@ -25,8 +36,8 @@ def get_password_hash(salt: str, password: str) -> str:
return digest.hexdigest()
def get_legacy_password_hash(salt: str, password: str) -> str:
''' Retrieve old-style password hash. '''
def get_sha1_legacy_password_hash(salt: str, password: str) -> str:
""" Retrieve old-style sha1 password hash."""
digest = hashlib.sha1()
digest.update(b'1A2/$_4xVa')
digest.update(salt.encode('utf8'))
@ -47,11 +58,26 @@ def create_password() -> str:
def is_valid_password(user: model.User, password: str) -> bool:
assert user
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 = [
get_password_hash(salt, password),
get_legacy_password_hash(salt, password)
get_sha256_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:
@ -78,3 +104,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__()

View file

@ -722,12 +722,14 @@ def merge_posts(
merge_favorites(source_post.post_id, target_post.post_id)
merge_relations(source_post.post_id, target_post.post_id)
delete(source_post)
db.session.flush()
content = None
if replace_content:
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)

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

View file

@ -1,7 +1,7 @@
import base64
from typing import Optional
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
@ -13,19 +13,33 @@ def _authenticate(username: str, password: str) -> model.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]:
if not ctx.has_header('Authorization'):
return None
try:
auth_type, credentials = ctx.get_header('Authorization').split(' ', 1)
if auth_type.lower() != 'basic':
raise HttpBadRequest(
'ValidationError',
'Only basic HTTP authentication is supported.')
if auth_type.lower() == 'basic':
username, password = base64.decodebytes(
credentials.encode('ascii')).decode('utf8').split(':', 1)
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:
msg = (
'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)))
@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)
@ -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:
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)

View file

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

View file

@ -23,7 +23,7 @@ class User(Base):
last_login_time = sa.Column('last_login_time', sa.DateTime)
version = sa.Column('version', sa.Integer, default=1, nullable=False)
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))
email = sa.Column('email', sa.Unicode(64), nullable=True)
rank = sa.Column('rank', sa.Unicode(32), nullable=False)
@ -84,3 +84,22 @@ 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)
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.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]]
def pre_hook(handler: Callable) -> None:
def pre_hook(handler: Callable) -> Callable:
pre_hooks.append(handler)
def post_hook(handler: Callable) -> None:
def post_hook(handler: Callable) -> Callable:
post_hooks.insert(0, handler)

View file

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

View file

@ -6,7 +6,7 @@ from szurubooru.func import posts, tags, snapshots
@pytest.fixture(autouse=True)
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):

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
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
@ -115,11 +115,11 @@ def config_injector():
@pytest.fixture
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.name = name or get_unique_name()
user.password_salt = 'dummy'
user.password_hash = 'dummy'
user.password_salt = password_salt or 'dummy'
user.password_hash = password or 'dummy'
user.email = email
user.rank = rank
user.creation_time = datetime(1997, 1, 1)
@ -128,6 +128,21 @@ def user_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
def tag_category_factory():
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
post = posts.get_post_by_id(target_post.post_id)
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 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):
category = tag_category_factory()
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'])

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

View file

@ -1,5 +1,11 @@
from datetime import datetime
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):

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)