Merge branch 'develop' into 'master'
Develop See merge request reanzu/szurubooru!1
This commit is contained in:
commit
148ed9700f
44 changed files with 1244 additions and 63 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -2,3 +2,7 @@ config.yaml
|
|||
*/*_modules/
|
||||
.coverage
|
||||
.cache
|
||||
__pycache__
|
||||
.idea/
|
||||
*.iml
|
||||
data/
|
160
API.md
160
API.md
|
@ -7,6 +7,7 @@
|
|||
1. [General rules](#general-rules)
|
||||
|
||||
- [Authentication](#authentication)
|
||||
- [User token authentication](#user-token-authentication)
|
||||
- [Basic requests](#basic-requests)
|
||||
- [File uploads](#file-uploads)
|
||||
- [Error handling](#error-handling)
|
||||
|
@ -56,6 +57,11 @@
|
|||
- [Updating user](#updating-user)
|
||||
- [Getting user](#getting-user)
|
||||
- [Deleting user](#deleting-user)
|
||||
- User Tokens
|
||||
- [Listing 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**
|
||||
|
||||
|
|
|
@ -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%
|
||||
|
||||
|
|
|
@ -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><!--
|
||||
--><% } %><!--
|
||||
|
|
29
client/html/user_tokens.tpl
Normal file
29
client/html/user_tokens.tpl
Normal 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>
|
|
@ -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)
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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...
|
||||
api.forget();
|
||||
return api.login(e.detail.name, e.detail.password, false);
|
||||
if (isLoggedIn) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
api.forget();
|
||||
return api.login(e.detail.name, e.detail.password, false);
|
||||
}
|
||||
}).then(() => {
|
||||
const ctx = router.show(uri.formatClientLink());
|
||||
ctx.controller.showSuccess('Welcome aboard!');
|
||||
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();
|
||||
|
|
76
client/js/models/user_token.js
Normal file
76
client/js/models/user_token.js
Normal 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;
|
86
client/js/views/user_tokens_view.js
Normal file
86
client/js/views/user_tokens_view.js
Normal 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;
|
|
@ -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();
|
||||
|
|
|
@ -70,7 +70,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
|
||||
|
@ -86,6 +87,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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
60
server/szurubooru/api/user_token_api.py
Normal file
60
server/szurubooru/api/user_token_api.py
Normal 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 {}
|
|
@ -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
|
||||
possible_hashes = [
|
||||
get_password_hash(salt, password),
|
||||
get_legacy_password_hash(salt, password)
|
||||
]
|
||||
return valid_hash in possible_hashes
|
||||
|
||||
try:
|
||||
return verify(user.password_hash.encode('utf8'),
|
||||
(config.config['secret'] + salt + password).encode('utf8'))
|
||||
except InvalidkeyError:
|
||||
possible_hashes = [
|
||||
get_sha256_legacy_password_hash(salt, password),
|
||||
get_sha1_legacy_password_hash(salt, password)
|
||||
]
|
||||
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__()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
86
server/szurubooru/func/user_tokens.py
Normal file
86
server/szurubooru/func/user_tokens.py
Normal 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()
|
|
@ -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
|
||||
|
||||
|
@ -172,9 +174,9 @@ def get_user_count() -> int:
|
|||
def try_get_user_by_name(name: str) -> Optional[model.User]:
|
||||
return (
|
||||
db.session
|
||||
.query(model.User)
|
||||
.filter(sa.func.lower(model.User.name) == sa.func.lower(name))
|
||||
.one_or_none())
|
||||
.query(model.User)
|
||||
.filter(sa.func.lower(model.User.name) == sa.func.lower(name))
|
||||
.one_or_none())
|
||||
|
||||
|
||||
def get_user_by_name(name: str) -> model.User:
|
||||
|
@ -187,11 +189,11 @@ def get_user_by_name(name: str) -> model.User:
|
|||
def try_get_user_by_name_or_email(name_or_email: str) -> Optional[model.User]:
|
||||
return (
|
||||
db.session
|
||||
.query(model.User)
|
||||
.filter(
|
||||
.query(model.User)
|
||||
.filter(
|
||||
(sa.func.lower(model.User.name) == sa.func.lower(name_or_email)) |
|
||||
(sa.func.lower(model.User.email) == sa.func.lower(name_or_email)))
|
||||
.one_or_none())
|
||||
.one_or_none())
|
||||
|
||||
|
||||
def get_user_by_name_or_email(name_or_email: str) -> model.User:
|
||||
|
|
|
@ -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':
|
||||
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.')
|
||||
username, password = base64.decodebytes(
|
||||
credentials.encode('ascii')).decode('utf8').split(':', 1)
|
||||
return _authenticate(username, password)
|
||||
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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
|
@ -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')
|
|
@ -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 (
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
from szurubooru.rest.app import application
|
||||
from szurubooru.rest.context import Context, Response
|
||||
import szurubooru.rest.routes
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
29
server/szurubooru/tests/api/test_user_token_creating.py
Normal file
29
server/szurubooru/tests/api/test_user_token_creating.py
Normal 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)
|
29
server/szurubooru/tests/api/test_user_token_deleting.py
Normal file
29
server/szurubooru/tests/api/test_user_token_deleting.py
Normal 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)
|
31
server/szurubooru/tests/api/test_user_token_retrieving.py
Normal file
31
server/szurubooru/tests/api/test_user_token_retrieving.py
Normal 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)
|
41
server/szurubooru/tests/api/test_user_token_updating.py
Normal file
41
server/szurubooru/tests/api/test_user_token_updating.py
Normal 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)
|
|
@ -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):
|
||||
|
|
39
server/szurubooru/tests/func/test_auth.py
Normal file
39
server/szurubooru/tests/func/test_auth.py
Normal 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()
|
|
@ -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)
|
||||
|
|
|
@ -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'])
|
||||
|
|
72
server/szurubooru/tests/func/test_user_tokens.py
Normal file
72
server/szurubooru/tests/func/test_user_tokens.py
Normal 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
|
0
server/szurubooru/tests/middleware/__init__.py
Normal file
0
server/szurubooru/tests/middleware/__init__.py
Normal file
48
server/szurubooru/tests/middleware/test_authenticator.py
Normal file
48
server/szurubooru/tests/middleware/test_authenticator.py
Normal 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)
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
14
server/szurubooru/tests/model/test_user_token.py
Normal file
14
server/szurubooru/tests/model/test_user_token.py
Normal 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)
|
Loading…
Reference in a new issue