User Token Authentication
* Users are only authenticated against their password on login, and to retrieve a token. * Passwords are wiped from the app and cookies after login and token retrieval * Tokens are revoked at the end of the session/logout * If the user chooses the "remember me" option, the token is stored in the cookie * A user interface to revoke tokens will be added * Tokens correctly delete themselves on logout * API documentation updated for the new user-token endpoints * Added a Manage tokens tab to the user panel * Added bullet point about the token authentication for the API * Added tests for new endpoints and tests against authentication middleware
This commit is contained in:
parent
a98ca55391
commit
483c32cfbf
32 changed files with 1086 additions and 32 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -2,3 +2,6 @@ config.yaml
|
||||||
*/*_modules/
|
*/*_modules/
|
||||||
.coverage
|
.coverage
|
||||||
.cache
|
.cache
|
||||||
|
__pycache__
|
||||||
|
.idea/
|
||||||
|
*.iml
|
160
API.md
160
API.md
|
@ -7,6 +7,7 @@
|
||||||
1. [General rules](#general-rules)
|
1. [General rules](#general-rules)
|
||||||
|
|
||||||
- [Authentication](#authentication)
|
- [Authentication](#authentication)
|
||||||
|
- [User token authentication](#user-token-authentication)
|
||||||
- [Basic requests](#basic-requests)
|
- [Basic requests](#basic-requests)
|
||||||
- [File uploads](#file-uploads)
|
- [File uploads](#file-uploads)
|
||||||
- [Error handling](#error-handling)
|
- [Error handling](#error-handling)
|
||||||
|
@ -56,6 +57,11 @@
|
||||||
- [Updating user](#updating-user)
|
- [Updating user](#updating-user)
|
||||||
- [Getting user](#getting-user)
|
- [Getting user](#getting-user)
|
||||||
- [Deleting user](#deleting-user)
|
- [Deleting user](#deleting-user)
|
||||||
|
- User Tokens
|
||||||
|
- [Listing tokens](#listing-tokens)
|
||||||
|
- [Creating token](#creating-token)
|
||||||
|
- [Updating token](#updating-token)
|
||||||
|
- [Deleting token](#deleting-token)
|
||||||
- Password reset
|
- Password reset
|
||||||
- [Password reset - step 1: mail request](#password-reset---step-2-confirmation)
|
- [Password reset - step 1: mail request](#password-reset---step-2-confirmation)
|
||||||
- [Password reset - step 2: confirmation](#password-reset---step-2-confirmation)
|
- [Password reset - step 2: confirmation](#password-reset---step-2-confirmation)
|
||||||
|
@ -70,6 +76,7 @@
|
||||||
|
|
||||||
- [User](#user)
|
- [User](#user)
|
||||||
- [Micro user](#micro-user)
|
- [Micro user](#micro-user)
|
||||||
|
- [User token](#user-token)
|
||||||
- [Tag category](#tag-category)
|
- [Tag category](#tag-category)
|
||||||
- [Tag](#tag)
|
- [Tag](#tag)
|
||||||
- [Micro tag](#micro-tag)
|
- [Micro tag](#micro-tag)
|
||||||
|
@ -91,16 +98,35 @@
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Authentication is achieved by means of [basic HTTP
|
Authentication is achieved by means of [basic HTTP
|
||||||
auth](https://en.wikipedia.org/wiki/Basic_access_authentication). For this
|
auth](https://en.wikipedia.org/wiki/Basic_access_authentication) or through the
|
||||||
reason, it is recommended to connect through HTTPS. There are no sessions, so
|
use of [user token authentication](#user-token-authentication). For this reason,
|
||||||
every privileged request must be authenticated. Available privileges depend on
|
it is recommended to connect through HTTPS. There are no sessions, so every
|
||||||
the user's rank. The way how rank translates to privileges is defined in the
|
privileged request must be authenticated. Available privileges depend on the
|
||||||
server's configuration.
|
user's rank. The way how rank translates to privileges is defined in the server's
|
||||||
|
configuration.
|
||||||
|
|
||||||
It is recommended to add `?bump-login` GET parameter to the first request in a
|
It is recommended to add `?bump-login` GET parameter to the first request in a
|
||||||
client "session" (where the definition of a session is up to the client), so
|
client "session" (where the definition of a session is up to the client), so
|
||||||
that the user's last login time is kept up to date.
|
that the user's last login time is kept up to date.
|
||||||
|
|
||||||
|
## User token authentication
|
||||||
|
|
||||||
|
User token authentication works similarly to [basic HTTP
|
||||||
|
auth](https://en.wikipedia.org/wiki/Basic_access_authentication). Because it
|
||||||
|
operates similarly to ***basic HTTP auth*** it is still recommended to connect
|
||||||
|
through HTTPS. The authorization header uses the type of Token and the username
|
||||||
|
and token are encoded as Base64 and sent as the second parameter.
|
||||||
|
|
||||||
|
Example header for user1:token-is-more-secure
|
||||||
|
```
|
||||||
|
Authorization: Token dXNlcjE6dG9rZW4taXMtbW9yZS1zZWN1cmU=
|
||||||
|
```
|
||||||
|
|
||||||
|
The benefit of token authentication is that beyond the initial login to acquire
|
||||||
|
the first token, there is no need to transmit the user password in plaintext via
|
||||||
|
basic auth. Additionally tokens can be revoked at anytime allowing a cleaner
|
||||||
|
interface for isolating clients from user credentials.
|
||||||
|
|
||||||
## Basic requests
|
## Basic requests
|
||||||
|
|
||||||
Every request must use `Content-Type: application/json` and `Accept:
|
Every request must use `Content-Type: application/json` and `Accept:
|
||||||
|
@ -1469,6 +1495,104 @@ data.
|
||||||
|
|
||||||
Deletes existing user.
|
Deletes existing user.
|
||||||
|
|
||||||
|
## Listing tokens
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`GET /user-tokens/`
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
An [unpaged search result resource](#unpaged-search-result), for which
|
||||||
|
`<resource>` is a [user token resource](#user-token).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Searches for users tokens for the currently logged in user.
|
||||||
|
|
||||||
|
## Creating token
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`POST /user-token`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [user token resource](#user-token).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Creates a new user token that can be used for authentication of api
|
||||||
|
endpoints instead of a password.
|
||||||
|
|
||||||
|
## Updating user
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`PUT /user-token/<token>`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"version": <version>,
|
||||||
|
"enabled": <enabled>, // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [user token resource](#user-token).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the version is outdated
|
||||||
|
- the user token does not exist
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Updates an existing user token using specified parameters. All fields
|
||||||
|
except the [`version`](#versioning) are optional - update concerns only
|
||||||
|
provided fields.
|
||||||
|
|
||||||
|
## Deleting token
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`DELETE /user-token/<token>`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the token does not exist
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Deletes existing user token.
|
||||||
|
|
||||||
## Password reset - step 1: mail request
|
## Password reset - step 1: mail request
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -1701,6 +1825,32 @@ A single user.
|
||||||
|
|
||||||
A [user resource](#user) stripped down to `name` and `avatarUrl` fields.
|
A [user resource](#user) stripped down to `name` and `avatarUrl` fields.
|
||||||
|
|
||||||
|
## User token
|
||||||
|
**Description**
|
||||||
|
|
||||||
|
A single user token.
|
||||||
|
|
||||||
|
**Structure**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"user": <user>,
|
||||||
|
"token": <token>,
|
||||||
|
"enabled": <enabled>,
|
||||||
|
"version": <version>,
|
||||||
|
"creationTime": <creation-time>,
|
||||||
|
"lastEditTime": <last-edit-time>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field meaning**
|
||||||
|
- `<user>`: micro user. See [micro user](#micro-user).
|
||||||
|
- `<token>`: the token that can be used to authenticate the user.
|
||||||
|
- `<enabled>`: whether the token is still valid for authentication.
|
||||||
|
- `<version>`: resource version. See [versioning](#versioning).
|
||||||
|
- `<creation-time>`: time the user token was created , formatted as per RFC 3339.
|
||||||
|
- `<last-edit-time>`: time the user token was edited, formatted as per RFC 3339.
|
||||||
|
|
||||||
## Tag category
|
## Tag category
|
||||||
**Description**
|
**Description**
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
|
||||||
- Post comments
|
- Post comments
|
||||||
- Post notes / annotations, including arbitrary polygons
|
- Post notes / annotations, including arbitrary polygons
|
||||||
- Rich JSON REST API ([see documentation](https://github.com/rr-/szurubooru/blob/master/API.md))
|
- Rich JSON REST API ([see documentation](https://github.com/rr-/szurubooru/blob/master/API.md))
|
||||||
|
- Token based authentication for clients
|
||||||
- Rich search system
|
- Rich search system
|
||||||
- Rich privilege system
|
- Rich privilege system
|
||||||
- Autocomplete in search and while editing tags
|
- Autocomplete in search and while editing tags
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
#user
|
#user
|
||||||
width: 100%
|
width: 100%
|
||||||
max-width: 35em
|
max-width: 45em
|
||||||
nav.text-nav
|
nav.text-nav
|
||||||
margin-bottom: 1.5em
|
margin-bottom: 1.5em
|
||||||
|
|
||||||
|
@ -37,6 +37,24 @@
|
||||||
height: 1px
|
height: 1px
|
||||||
clear: both
|
clear: both
|
||||||
|
|
||||||
|
#user-tokens
|
||||||
|
.token-flex-container
|
||||||
|
width: 100%
|
||||||
|
display: flex;
|
||||||
|
flex-direction column;
|
||||||
|
padding-bottom: 0.5em;
|
||||||
|
|
||||||
|
.token-flex-row
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 0.25em;
|
||||||
|
padding-bottom: 0.25em;
|
||||||
|
border-bottom: black solid 1px;
|
||||||
|
|
||||||
|
form
|
||||||
|
width: auto;
|
||||||
|
|
||||||
#user-delete form
|
#user-delete form
|
||||||
width: 100%
|
width: 100%
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,9 @@
|
||||||
--><% if (ctx.canEditAnything) { %><!--
|
--><% if (ctx.canEditAnything) { %><!--
|
||||||
--><li data-name='edit'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'edit') %>'>Account settings</a></li><!--
|
--><li data-name='edit'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'edit') %>'>Account settings</a></li><!--
|
||||||
--><% } %><!--
|
--><% } %><!--
|
||||||
|
--><% if (ctx.canListTokens) { %><!--
|
||||||
|
--><li data-name='list-tokens'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'list-tokens') %>'>Manage tokens</a></li><!--
|
||||||
|
--><% } %><!--
|
||||||
--><% if (ctx.canDelete) { %><!--
|
--><% if (ctx.canDelete) { %><!--
|
||||||
--><li data-name='delete'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'delete') %>'>Account deletion</a></li><!--
|
--><li data-name='delete'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'delete') %>'>Account deletion</a></li><!--
|
||||||
--><% } %><!--
|
--><% } %><!--
|
||||||
|
|
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.user = null;
|
||||||
this.userName = null;
|
this.userName = null;
|
||||||
this.userPassword = null;
|
this.userPassword = null;
|
||||||
|
this.userToken = null;
|
||||||
this.cache = {};
|
this.cache = {};
|
||||||
this.allRanks = [
|
this.allRanks = [
|
||||||
'anonymous',
|
'anonymous',
|
||||||
|
@ -87,11 +88,69 @@ class Api extends events.EventTarget {
|
||||||
|
|
||||||
loginFromCookies() {
|
loginFromCookies() {
|
||||||
const auth = cookies.getJSON('auth');
|
const auth = cookies.getJSON('auth');
|
||||||
return auth && auth.user && auth.password ?
|
return auth && auth.user && auth.token ?
|
||||||
this.login(auth.user, auth.password, true) :
|
this.login_with_token(auth.user, auth.token, true) :
|
||||||
Promise.resolve();
|
Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
login_with_token(userName, token, doRemember) {
|
||||||
|
this.cache = {};
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.userName = userName;
|
||||||
|
this.userToken = token;
|
||||||
|
this.get('/user/' + userName + '?bump-login=true')
|
||||||
|
.then(response => {
|
||||||
|
const options = {};
|
||||||
|
if (doRemember) {
|
||||||
|
options.expires = 365;
|
||||||
|
}
|
||||||
|
cookies.set(
|
||||||
|
'auth',
|
||||||
|
{'user': userName, 'token': token},
|
||||||
|
options);
|
||||||
|
this.user = response;
|
||||||
|
resolve();
|
||||||
|
this.dispatchEvent(new CustomEvent('login'));
|
||||||
|
}, error => {
|
||||||
|
reject(error);
|
||||||
|
this.logout();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create_token(userName, options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.post('/user-token/' + userName, {})
|
||||||
|
.then(response => {
|
||||||
|
cookies.set(
|
||||||
|
'auth',
|
||||||
|
{'user': userName, 'token': response.token},
|
||||||
|
options);
|
||||||
|
this.userName = userName;
|
||||||
|
this.userToken = response.token;
|
||||||
|
this.userPassword = null;
|
||||||
|
}, error => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_token(userName, userToken) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.delete('/user-token/' + userName + '/' + userToken, {})
|
||||||
|
.then(response => {
|
||||||
|
const options = {};
|
||||||
|
cookies.set(
|
||||||
|
'auth',
|
||||||
|
{'user': userName, 'token': null},
|
||||||
|
options);
|
||||||
|
resolve();
|
||||||
|
}, error => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
login(userName, userPassword, doRemember) {
|
login(userName, userPassword, doRemember) {
|
||||||
this.cache = {};
|
this.cache = {};
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
@ -103,10 +162,7 @@ class Api extends events.EventTarget {
|
||||||
if (doRemember) {
|
if (doRemember) {
|
||||||
options.expires = 365;
|
options.expires = 365;
|
||||||
}
|
}
|
||||||
cookies.set(
|
this.create_token(this.userName, options);
|
||||||
'auth',
|
|
||||||
{'user': userName, 'password': userPassword},
|
|
||||||
options);
|
|
||||||
this.user = response;
|
this.user = response;
|
||||||
resolve();
|
resolve();
|
||||||
this.dispatchEvent(new CustomEvent('login'));
|
this.dispatchEvent(new CustomEvent('login'));
|
||||||
|
@ -118,9 +174,20 @@ class Api extends events.EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
|
let self = this;
|
||||||
|
this.delete_token(this.userName, this.userToken)
|
||||||
|
.then(response => {
|
||||||
|
self._logout();
|
||||||
|
}, error => {
|
||||||
|
self._logout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_logout() {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
this.userName = null;
|
this.userName = null;
|
||||||
this.userPassword = null;
|
this.userPassword = null;
|
||||||
|
this.userToken = null;
|
||||||
this.dispatchEvent(new CustomEvent('logout'));
|
this.dispatchEvent(new CustomEvent('logout'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -258,7 +325,11 @@ class Api extends events.EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.userName && this.userPassword) {
|
if (this.userName && this.userToken) {
|
||||||
|
req.auth = null;
|
||||||
|
req.set('Authorization', 'Token ' + new Buffer(this.userName + ":" + this.userToken).toString('base64'))
|
||||||
|
}
|
||||||
|
else if (this.userName && this.userPassword) {
|
||||||
req.auth(
|
req.auth(
|
||||||
this.userName,
|
this.userName,
|
||||||
encodeURIComponent(this.userPassword)
|
encodeURIComponent(this.userPassword)
|
||||||
|
|
|
@ -7,6 +7,7 @@ const misc = require('../util/misc.js');
|
||||||
const config = require('../config.js');
|
const config = require('../config.js');
|
||||||
const views = require('../util/views.js');
|
const views = require('../util/views.js');
|
||||||
const User = require('../models/user.js');
|
const User = require('../models/user.js');
|
||||||
|
const UserToken = require('../models/user_token.js');
|
||||||
const topNavigation = require('../models/top_navigation.js');
|
const topNavigation = require('../models/top_navigation.js');
|
||||||
const UserView = require('../views/user_view.js');
|
const UserView = require('../views/user_view.js');
|
||||||
const EmptyView = require('../views/empty_view.js');
|
const EmptyView = require('../views/empty_view.js');
|
||||||
|
@ -21,8 +22,11 @@ class UserController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._successMessages = [];
|
||||||
|
this._errorMessages = [];
|
||||||
|
|
||||||
topNavigation.setTitle('User ' + userName);
|
topNavigation.setTitle('User ' + userName);
|
||||||
User.get(userName).then(user => {
|
User.get(userName).then(async user => {
|
||||||
const isLoggedIn = api.isLoggedIn(user);
|
const isLoggedIn = api.isLoggedIn(user);
|
||||||
const infix = isLoggedIn ? 'self' : 'any';
|
const infix = isLoggedIn ? 'self' : 'any';
|
||||||
|
|
||||||
|
@ -48,6 +52,17 @@ class UserController {
|
||||||
} else {
|
} else {
|
||||||
topNavigation.activate('users');
|
topNavigation.activate('users');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let userTokens = [];
|
||||||
|
if (section === 'list-tokens') {
|
||||||
|
userTokens = await UserToken.get(userName)
|
||||||
|
.then(response => {
|
||||||
|
return response;
|
||||||
|
}, error => {
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this._view = new UserView({
|
this._view = new UserView({
|
||||||
user: user,
|
user: user,
|
||||||
section: section,
|
section: section,
|
||||||
|
@ -58,18 +73,50 @@ class UserController {
|
||||||
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
|
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
|
||||||
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`),
|
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`),
|
||||||
canEditAnything: api.hasPrivilege(`users:edit:${infix}`),
|
canEditAnything: api.hasPrivilege(`users:edit:${infix}`),
|
||||||
|
canListTokens: api.hasPrivilege(`userTokens:list:${infix}`),
|
||||||
|
canCreateToken: api.hasPrivilege(`userTokens:create:${infix}`),
|
||||||
|
canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`),
|
||||||
|
canDeleteToken: api.hasPrivilege(`userTokens:delete:${infix}`),
|
||||||
canDelete: api.hasPrivilege(`users:delete:${infix}`),
|
canDelete: api.hasPrivilege(`users:delete:${infix}`),
|
||||||
ranks: ranks,
|
ranks: ranks,
|
||||||
|
tokens: userTokens,
|
||||||
});
|
});
|
||||||
this._view.addEventListener('change', e => this._evtChange(e));
|
this._view.addEventListener('change', e => this._evtChange(e));
|
||||||
this._view.addEventListener('submit', e => this._evtUpdate(e));
|
this._view.addEventListener('submit', e => this._evtUpdate(e));
|
||||||
this._view.addEventListener('delete', e => this._evtDelete(e));
|
this._view.addEventListener('delete', e => this._evtDelete(e));
|
||||||
|
this._view.addEventListener('create-token', e => this._evtCreateToken(e));
|
||||||
|
this._view.addEventListener('delete-token', e => this._evtDeleteToken(e));
|
||||||
|
|
||||||
|
for (let i = 0; i < this._successMessages.length; i++) {
|
||||||
|
this.showSuccess(this._successMessages[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < this._errorMessages.length; i++) {
|
||||||
|
this.showError(this._errorMessages[i]);
|
||||||
|
}
|
||||||
|
|
||||||
}, error => {
|
}, error => {
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
this._view.showError(error.message);
|
this._view.showError(error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showSuccess(message) {
|
||||||
|
if (typeof this._view === 'undefined') {
|
||||||
|
this._successMessages.push(message)
|
||||||
|
} else {
|
||||||
|
this._view.showSuccess(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
if (typeof this._view === 'undefined') {
|
||||||
|
this._errorMessages.push(message)
|
||||||
|
} else {
|
||||||
|
this._view.showError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_evtChange(e) {
|
_evtChange(e) {
|
||||||
misc.enableExitConfirmation();
|
misc.enableExitConfirmation();
|
||||||
}
|
}
|
||||||
|
@ -148,6 +195,32 @@ class UserController {
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_evtCreateToken(e) {
|
||||||
|
this._view.clearMessages();
|
||||||
|
this._view.disableForm();
|
||||||
|
UserToken.create(e.detail.user.name)
|
||||||
|
.then(response => {
|
||||||
|
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
|
||||||
|
ctx.controller.showSuccess('Token ' + response.token + ' created.');
|
||||||
|
}, error => {
|
||||||
|
this._view.showError(error.message);
|
||||||
|
this._view.enableForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtDeleteToken(e) {
|
||||||
|
this._view.clearMessages();
|
||||||
|
this._view.disableForm();
|
||||||
|
e.detail.userToken.delete(e.detail.user.name)
|
||||||
|
.then(() => {
|
||||||
|
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
|
||||||
|
ctx.controller.showSuccess('Token ' + e.detail.userToken.token + ' deleted.');
|
||||||
|
}, error => {
|
||||||
|
this._view.showError(error.message);
|
||||||
|
this._view.enableForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = router => {
|
module.exports = router => {
|
||||||
|
@ -157,6 +230,9 @@ module.exports = router => {
|
||||||
router.enter(['user', ':name', 'edit'], (ctx, next) => {
|
router.enter(['user', ':name', 'edit'], (ctx, next) => {
|
||||||
ctx.controller = new UserController(ctx, 'edit');
|
ctx.controller = new UserController(ctx, 'edit');
|
||||||
});
|
});
|
||||||
|
router.enter(['user', ':name', 'list-tokens'], (ctx, next) => {
|
||||||
|
ctx.controller = new UserController(ctx, 'list-tokens');
|
||||||
|
});
|
||||||
router.enter(['user', ':name', 'delete'], (ctx, next) => {
|
router.enter(['user', ':name', 'delete'], (ctx, next) => {
|
||||||
ctx.controller = new UserController(ctx, 'delete');
|
ctx.controller = new UserController(ctx, 'delete');
|
||||||
});
|
});
|
||||||
|
|
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 events = require('../events.js');
|
||||||
const views = require('../util/views.js');
|
const views = require('../util/views.js');
|
||||||
const UserDeleteView = require('./user_delete_view.js');
|
const UserDeleteView = require('./user_delete_view.js');
|
||||||
|
const UserTokensView = require('./user_tokens_view.js');
|
||||||
const UserSummaryView = require('./user_summary_view.js');
|
const UserSummaryView = require('./user_summary_view.js');
|
||||||
const UserEditView = require('./user_edit_view.js');
|
const UserEditView = require('./user_edit_view.js');
|
||||||
const EmptyView = require('../views/empty_view.js');
|
const EmptyView = require('../views/empty_view.js');
|
||||||
|
@ -45,7 +46,16 @@ class UserView extends events.EventTarget {
|
||||||
this._view = new UserEditView(ctx);
|
this._view = new UserEditView(ctx);
|
||||||
events.proxyEvent(this._view, this, 'submit');
|
events.proxyEvent(this._view, this, 'submit');
|
||||||
}
|
}
|
||||||
|
} else if (ctx.section == 'list-tokens') {
|
||||||
|
if (!this._ctx.canListTokens) {
|
||||||
|
this._view = new EmptyView();
|
||||||
|
this._view.showError(
|
||||||
|
'You don\'t have privileges to view user tokens.');
|
||||||
|
} else {
|
||||||
|
this._view = new UserTokensView(ctx);
|
||||||
|
events.proxyEvent(this._view, this, 'delete', 'delete-token');
|
||||||
|
events.proxyEvent(this._view, this, 'submit', 'create-token');
|
||||||
|
}
|
||||||
} else if (ctx.section == 'delete') {
|
} else if (ctx.section == 'delete') {
|
||||||
if (!this._ctx.canDelete) {
|
if (!this._ctx.canDelete) {
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
|
|
|
@ -91,6 +91,15 @@ privileges:
|
||||||
'users:delete:any': administrator
|
'users:delete:any': administrator
|
||||||
'users:delete:self': regular
|
'users:delete:self': regular
|
||||||
|
|
||||||
|
'user_tokens:list:any': administrator
|
||||||
|
'user_tokens:list:self': regular
|
||||||
|
'user_tokens:create:any': administrator
|
||||||
|
'user_tokens:create:self': regular
|
||||||
|
'user_tokens:edit:any': administrator
|
||||||
|
'user_tokens:edit:self': regular
|
||||||
|
'user_tokens:delete:any': administrator
|
||||||
|
'user_tokens:delete:self': regular
|
||||||
|
|
||||||
'posts:create:anonymous': regular
|
'posts:create:anonymous': regular
|
||||||
'posts:create:identified': regular
|
'posts:create:identified': regular
|
||||||
'posts:list': anonymous
|
'posts:list': anonymous
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import szurubooru.api.info_api
|
import szurubooru.api.info_api
|
||||||
import szurubooru.api.user_api
|
import szurubooru.api.user_api
|
||||||
|
import szurubooru.api.user_token_api
|
||||||
import szurubooru.api.post_api
|
import szurubooru.api.post_api
|
||||||
import szurubooru.api.tag_api
|
import szurubooru.api.tag_api
|
||||||
import szurubooru.api.tag_category_api
|
import szurubooru.api.tag_category_api
|
||||||
|
|
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 {}
|
|
@ -6,6 +6,7 @@ from nacl import pwhash
|
||||||
from nacl.exceptions import InvalidkeyError
|
from nacl.exceptions import InvalidkeyError
|
||||||
from szurubooru import config, model, errors, db
|
from szurubooru import config, model, errors, db
|
||||||
from szurubooru.func import util
|
from szurubooru.func import util
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
RANK_MAP = OrderedDict([
|
RANK_MAP = OrderedDict([
|
||||||
|
@ -78,6 +79,10 @@ def is_valid_password(user: model.User, password: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_token(user_token: model.UserToken) -> bool:
|
||||||
|
return user_token is not None and user_token.enabled
|
||||||
|
|
||||||
|
|
||||||
def has_privilege(user: model.User, privilege_name: str) -> bool:
|
def has_privilege(user: model.User, privilege_name: str) -> bool:
|
||||||
assert user
|
assert user
|
||||||
all_ranks = list(RANK_MAP.keys())
|
all_ranks = list(RANK_MAP.keys())
|
||||||
|
@ -102,3 +107,7 @@ def generate_authentication_token(user: model.User) -> str:
|
||||||
digest.update(config.config['secret'].encode('utf8'))
|
digest.update(config.config['secret'].encode('utf8'))
|
||||||
digest.update(user.password_salt.encode('utf8'))
|
digest.update(user.password_salt.encode('utf8'))
|
||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_authorization_token() -> str:
|
||||||
|
return uuid.uuid4().__str__()
|
||||||
|
|
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 datetime import datetime
|
||||||
|
from typing import Any, Optional, Union, List, Dict, Callable
|
||||||
|
|
||||||
|
import re
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from szurubooru import config, db, model, errors, rest
|
from szurubooru import config, db, model, errors, rest
|
||||||
from szurubooru.func import auth, util, serialization, files, images
|
from szurubooru.func import auth, util, serialization, files, images
|
||||||
|
|
||||||
|
@ -172,9 +174,9 @@ def get_user_count() -> int:
|
||||||
def try_get_user_by_name(name: str) -> Optional[model.User]:
|
def try_get_user_by_name(name: str) -> Optional[model.User]:
|
||||||
return (
|
return (
|
||||||
db.session
|
db.session
|
||||||
.query(model.User)
|
.query(model.User)
|
||||||
.filter(sa.func.lower(model.User.name) == sa.func.lower(name))
|
.filter(sa.func.lower(model.User.name) == sa.func.lower(name))
|
||||||
.one_or_none())
|
.one_or_none())
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_name(name: str) -> model.User:
|
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]:
|
def try_get_user_by_name_or_email(name_or_email: str) -> Optional[model.User]:
|
||||||
return (
|
return (
|
||||||
db.session
|
db.session
|
||||||
.query(model.User)
|
.query(model.User)
|
||||||
.filter(
|
.filter(
|
||||||
(sa.func.lower(model.User.name) == sa.func.lower(name_or_email)) |
|
(sa.func.lower(model.User.name) == sa.func.lower(name_or_email)) |
|
||||||
(sa.func.lower(model.User.email) == 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:
|
def get_user_by_name_or_email(name_or_email: str) -> model.User:
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import base64
|
import base64
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from szurubooru import db, model, errors, rest
|
from szurubooru import db, model, errors, rest
|
||||||
from szurubooru.func import auth, users
|
from szurubooru.func import auth, users, user_tokens
|
||||||
from szurubooru.rest.errors import HttpBadRequest
|
from szurubooru.rest.errors import HttpBadRequest
|
||||||
|
|
||||||
|
|
||||||
|
@ -13,19 +13,33 @@ def _authenticate(username: str, password: str) -> model.User:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _authenticate_token(username: str, token: str) -> model.User:
|
||||||
|
"""Try to authenticate user. Throw AuthError for invalid users."""
|
||||||
|
user = users.get_user_by_name(username)
|
||||||
|
user_token = user_tokens.get_user_token_by_user_and_token(user, token)
|
||||||
|
if not auth.is_valid_token(user_token):
|
||||||
|
raise errors.AuthError('Invalid token.')
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _get_user(ctx: rest.Context) -> Optional[model.User]:
|
def _get_user(ctx: rest.Context) -> Optional[model.User]:
|
||||||
if not ctx.has_header('Authorization'):
|
if not ctx.has_header('Authorization'):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
auth_type, credentials = ctx.get_header('Authorization').split(' ', 1)
|
auth_type, credentials = ctx.get_header('Authorization').split(' ', 1)
|
||||||
if auth_type.lower() != 'basic':
|
if auth_type.lower() == 'basic':
|
||||||
|
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(
|
raise HttpBadRequest(
|
||||||
'ValidationError',
|
'ValidationError',
|
||||||
'Only basic HTTP authentication is supported.')
|
'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:
|
except ValueError as err:
|
||||||
msg = (
|
msg = (
|
||||||
'Basic authentication header value are not properly formed. '
|
'Basic authentication header value are not properly formed. '
|
||||||
|
@ -35,7 +49,6 @@ def _get_user(ctx: rest.Context) -> Optional[model.User]:
|
||||||
msg.format(ctx.get_header('Authorization'), str(err)))
|
msg.format(ctx.get_header('Authorization'), str(err)))
|
||||||
|
|
||||||
|
|
||||||
@rest.middleware.pre_hook
|
|
||||||
def process_request(ctx: rest.Context) -> None:
|
def process_request(ctx: rest.Context) -> None:
|
||||||
''' Bind the user to request. Update last login time if needed. '''
|
''' Bind the user to request. Update last login time if needed. '''
|
||||||
auth_user = _get_user(ctx)
|
auth_user = _get_user(ctx)
|
||||||
|
@ -44,3 +57,8 @@ def process_request(ctx: rest.Context) -> None:
|
||||||
if ctx.get_param_as_bool('bump-login', default=False) and ctx.user.user_id:
|
if ctx.get_param_as_bool('bump-login', default=False) and ctx.user.user_id:
|
||||||
users.bump_user_login_time(ctx.user)
|
users.bump_user_login_time(ctx.user)
|
||||||
ctx.session.commit()
|
ctx.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@rest.middleware.pre_hook
|
||||||
|
def process_request_hook(ctx: rest.Context) -> None:
|
||||||
|
process_request(ctx)
|
||||||
|
|
|
@ -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.base import Base
|
||||||
from szurubooru.model.user import User
|
from szurubooru.model.user import (
|
||||||
|
User,
|
||||||
|
UserToken)
|
||||||
from szurubooru.model.tag_category import TagCategory
|
from szurubooru.model.tag_category import TagCategory
|
||||||
from szurubooru.model.tag import Tag, TagName, TagSuggestion, TagImplication
|
from szurubooru.model.tag import Tag, TagName, TagSuggestion, TagImplication
|
||||||
from szurubooru.model.post import (
|
from szurubooru.model.post import (
|
||||||
|
|
|
@ -86,3 +86,22 @@ class User(Base):
|
||||||
'version_id_col': version,
|
'version_id_col': version,
|
||||||
'version_id_generator': False,
|
'version_id_generator': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserToken(Base):
|
||||||
|
__tablename__ = 'user_token'
|
||||||
|
|
||||||
|
user_token_id = sa.Column('id', sa.Integer, primary_key=True)
|
||||||
|
user_id = sa.Column(
|
||||||
|
'user_id',
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey('user.id', ondelete='CASCADE'),
|
||||||
|
nullable=False,
|
||||||
|
index=True)
|
||||||
|
token = sa.Column('token', sa.Unicode(36), nullable=False)
|
||||||
|
enabled = sa.Column('enabled', sa.Boolean, nullable=False, default=True)
|
||||||
|
creation_time = sa.Column('creation_time', sa.DateTime, nullable=False)
|
||||||
|
last_edit_time = sa.Column('last_edit_time', sa.DateTime)
|
||||||
|
version = sa.Column('version', sa.Integer, default=1, nullable=False)
|
||||||
|
|
||||||
|
user = sa.orm.relationship('User')
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
from szurubooru.rest.app import application
|
from szurubooru.rest.app import application
|
||||||
from szurubooru.rest.context import Context, Response
|
from szurubooru.rest.context import Context, Response
|
||||||
|
import szurubooru.rest.routes
|
||||||
|
|
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
|
@pytest.fixture
|
||||||
def context_factory(session):
|
def context_factory(session):
|
||||||
def factory(params=None, files=None, user=None):
|
def factory(params=None, files=None, user=None, headers=None):
|
||||||
ctx = rest.Context(
|
ctx = rest.Context(
|
||||||
method=None,
|
method=None,
|
||||||
url=None,
|
url=None,
|
||||||
headers={},
|
headers=headers or {},
|
||||||
params=params or {},
|
params=params or {},
|
||||||
files=files or {})
|
files=files or {})
|
||||||
ctx.session = session
|
ctx.session = session
|
||||||
|
@ -133,6 +133,21 @@ def user_factory():
|
||||||
return factory
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_token_factory(user_factory):
|
||||||
|
def factory(user=None, token=None, enabled=None, creation_time=None):
|
||||||
|
if user is None:
|
||||||
|
user = user_factory()
|
||||||
|
db.session.add(user)
|
||||||
|
user_token = model.UserToken()
|
||||||
|
user_token.user = user
|
||||||
|
user_token.token = token or 'dummy'
|
||||||
|
user_token.enabled = enabled or True
|
||||||
|
user_token.creation_time = creation_time or datetime(1997, 1, 1)
|
||||||
|
return user_token
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def tag_category_factory():
|
def tag_category_factory():
|
||||||
def factory(name=None, color='dummy', default=False):
|
def factory(name=None, color='dummy', default=False):
|
||||||
|
|
|
@ -41,3 +41,13 @@ def test_is_valid_password_auto_upgrades_user_password_hash(user_factory):
|
||||||
assert result is True
|
assert result is True
|
||||||
assert user.password_hash != hash
|
assert user.password_hash != hash
|
||||||
assert user.password_revision > revision
|
assert user.password_revision > revision
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_token(user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
assert auth.is_valid_token(user_token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_authorization_token():
|
||||||
|
result = auth.generate_authorization_token()
|
||||||
|
assert result != auth.generate_authorization_token()
|
||||||
|
|
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)
|
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)
|
Reference in a new issue