server/auth: add token authentication
* Users are only authenticated against their password on login, and to retrieve a token * Passwords are wiped from the GUI frontend and cookies after login and token retrieval * Tokens are revoked at the end of the session/logout * If the user chooses the "remember me" option, the token is stored in the cookie * Tokens correctly delete themselves on logout * Tokens can expire at user-specified date * Tokens have their last usage time * Tokens can have user defined descriptions * Users can manage login tokens in their account settings
This commit is contained in:
parent
e35e709927
commit
2a69f0193f
36 changed files with 1609 additions and 40 deletions
166
API.md
166
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 user tokens](#listing-user-tokens)
|
||||||
|
- [Creating user token](#creating-user-token)
|
||||||
|
- [Updating user token](#updating-user-token)
|
||||||
|
- [Deleting user token](#deleting-user-token)
|
||||||
- Password reset
|
- Password reset
|
||||||
- [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,7 +98,8 @@
|
||||||
## 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
|
||||||
|
use of [user token authentication](#user-token-authentication). For this
|
||||||
reason, it is recommended to connect through HTTPS. There are no sessions, so
|
reason, it is recommended to connect through HTTPS. There are no sessions, so
|
||||||
every privileged request must be authenticated. Available privileges depend on
|
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
|
the user's rank. The way how rank translates to privileges is defined in the
|
||||||
|
@ -101,6 +109,24 @@ It is recommended to add `?bump-login` GET parameter to the first request in a
|
||||||
client "session" (where the definition of a session is up to the client), so
|
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,112 @@ data.
|
||||||
|
|
||||||
Deletes existing user.
|
Deletes existing user.
|
||||||
|
|
||||||
|
## Listing user tokens
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`GET /user-tokens/<user_name>`
|
||||||
|
|
||||||
|
- **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 user tokens for the given user.
|
||||||
|
|
||||||
|
## Creating user token
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`POST /user-token/<user_name>`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"enabled": <enabled>, // optional
|
||||||
|
"note": <note>, // optional
|
||||||
|
"expirationTime": <expiration-time> // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [user token resource](#user-token).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Creates a new user token that can be used for authentication of API
|
||||||
|
endpoints instead of a password.
|
||||||
|
|
||||||
|
## Updating user token
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`PUT /user-token/<user_name>/<token>`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"version": <version>,
|
||||||
|
"enabled": <enabled>, // optional
|
||||||
|
"note": <note>, // optional
|
||||||
|
"expirationTime": <expiration-time> // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [user token resource](#user-token).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- the version is outdated
|
||||||
|
- the user token does not exist
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Updates an existing user token using specified parameters. All fields
|
||||||
|
except the [`version`](#versioning) are optional - update concerns only
|
||||||
|
provided fields.
|
||||||
|
|
||||||
|
## Deleting user token
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`DELETE /user-token/<user_name>/<token>`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"version": <version>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **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 +1833,38 @@ 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>,
|
||||||
|
"note": <token>,
|
||||||
|
"enabled": <enabled>,
|
||||||
|
"expirationTime": <expiration-time>,
|
||||||
|
"version": <version>,
|
||||||
|
"creationTime": <creation-time>,
|
||||||
|
"lastEditTime": <last-edit-time>,
|
||||||
|
"lastUsageTime": <last-usage-time>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field meaning**
|
||||||
|
- `<user>`: micro user. See [micro user](#micro-user).
|
||||||
|
- `<token>`: the token that can be used to authenticate the user.
|
||||||
|
- `<note>`: a note that describes the token.
|
||||||
|
- `<enabled>`: whether the token is still valid for authentication.
|
||||||
|
- `<expiration-time>`: time when the token expires. It must include the timezone as per RFC 3339.
|
||||||
|
- `<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.
|
||||||
|
- `<last-usage-time>`: the last time this token was used during a login involving `?bump-login`, 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
|
||||||
|
|
|
@ -137,6 +137,38 @@ input[type=checkbox]:focus + .checkbox:before
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Date and time inputs
|
||||||
|
*/
|
||||||
|
|
||||||
|
input[type=date],
|
||||||
|
input[type=time]
|
||||||
|
vertical-align: top
|
||||||
|
font-family: 'Droid Sans', sans-serif
|
||||||
|
font-size: 100%
|
||||||
|
padding: 0.2em 0.3em
|
||||||
|
box-sizing: border-box
|
||||||
|
border: 2px solid $input-enabled-border-color
|
||||||
|
background: $input-enabled-background-color
|
||||||
|
color: $input-enabled-text-color
|
||||||
|
box-shadow: none /* :-moz-submit-invalid on FF */
|
||||||
|
transition: border-color 0.1s linear, background-color 0.1s linear
|
||||||
|
|
||||||
|
&:disabled
|
||||||
|
border: 2px solid $input-disabled-border-color
|
||||||
|
background: $input-disabled-background-color
|
||||||
|
color: $input-disabled-text-color
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
border-color: $main-color
|
||||||
|
|
||||||
|
&[readonly]
|
||||||
|
border: 2px solid $input-disabled-border-color
|
||||||
|
background: $input-disabled-background-color
|
||||||
|
color: $input-disabled-text-color
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Regular inputs
|
* Regular inputs
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
@import colors
|
||||||
|
$token-border-color = $active-tab-background-color
|
||||||
|
|
||||||
#user
|
#user
|
||||||
width: 100%
|
width: 100%
|
||||||
max-width: 35em
|
max-width: 35em
|
||||||
|
@ -37,7 +40,43 @@
|
||||||
height: 1px
|
height: 1px
|
||||||
clear: both
|
clear: both
|
||||||
|
|
||||||
|
#user-tokens
|
||||||
|
|
||||||
|
.token-flex-container
|
||||||
|
width: 100%
|
||||||
|
display: flex;
|
||||||
|
flex-direction column;
|
||||||
|
padding-bottom: 0.5em;
|
||||||
|
|
||||||
|
.full-width
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
.token-flex-row
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.2em;
|
||||||
|
|
||||||
|
.no-wrap
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.token-input
|
||||||
|
min-height: 2em;
|
||||||
|
line-height: 2em;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.token-flex-column
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.token-flex-labels
|
||||||
|
padding-right: 0.5em
|
||||||
|
|
||||||
|
hr
|
||||||
|
border-top: 3px solid $token-border-color
|
||||||
|
|
||||||
|
form
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
#user-delete form
|
#user-delete form
|
||||||
width: 100%
|
width: 100%
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -4,10 +4,13 @@
|
||||||
--><ul><!--
|
--><ul><!--
|
||||||
--><li data-name='summary'><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>Summary</a></li><!--
|
--><li data-name='summary'><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>Summary</a></li><!--
|
||||||
--><% 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') %>'>Settings</a></li><!--
|
||||||
|
--><% } %><!--
|
||||||
|
--><% if (ctx.canListTokens) { %><!--
|
||||||
|
--><li data-name='list-tokens'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'list-tokens') %>'>Login 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') %>'>Delete</a></li><!--
|
||||||
--><% } %><!--
|
--><% } %><!--
|
||||||
--></ul><!--
|
--></ul><!--
|
||||||
--></nav>
|
--></nav>
|
||||||
|
|
74
client/html/user_tokens.tpl
Normal file
74
client/html/user_tokens.tpl
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<div id='user-tokens'>
|
||||||
|
<div class='messages'></div>
|
||||||
|
<% if (ctx.tokens.length > 0) { %>
|
||||||
|
<div class='token-flex-container'>
|
||||||
|
<% _.each(ctx.tokens, function(token, index) { %>
|
||||||
|
<div class='token-flex-row'>
|
||||||
|
<div class='token-flex-column token-flex-labels'>
|
||||||
|
<div class='token-flex-row'>Token:</div>
|
||||||
|
<div class='token-flex-row'>Note:</div>
|
||||||
|
<div class='token-flex-row'>Created:</div>
|
||||||
|
<div class='token-flex-row'>Expires:</div>
|
||||||
|
<div class='token-flex-row no-wrap'>Last used:</div>
|
||||||
|
</div>
|
||||||
|
<div class='token-flex-column full-width'>
|
||||||
|
<div class='token-flex-row'><%= token.token %></div>
|
||||||
|
<div class='token-flex-row'>
|
||||||
|
<% if (token.note !== null) { %>
|
||||||
|
<%= token.note %>
|
||||||
|
<% } else { %>
|
||||||
|
No note
|
||||||
|
<% } %>
|
||||||
|
<a class='token-change-note' data-token-id='<%= index %>' href='#'>(change)</a>
|
||||||
|
</div>
|
||||||
|
<div class='token-flex-row'><%= ctx.makeRelativeTime(token.creationTime) %></div>
|
||||||
|
<div class='token-flex-row'>
|
||||||
|
<% if (token.expirationTime) { %>
|
||||||
|
<%= ctx.makeRelativeTime(token.expirationTime) %>
|
||||||
|
<% } else { %>
|
||||||
|
No expiration
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class='token-flex-row'><%= ctx.makeRelativeTime(token.lastUsageTime) %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='token-flex-row'>
|
||||||
|
<div class='token-flex-column full-width'>
|
||||||
|
<div class='token-flex-row'>
|
||||||
|
<form class='token' data-token-id='<%= index %>'>
|
||||||
|
<% if (token.isCurrentAuthToken) { %>
|
||||||
|
<input type='submit' value='Delete and logout'
|
||||||
|
title='This token is used to authenticate this client, deleting it will force a logout.'/>
|
||||||
|
<% } else { %>
|
||||||
|
<input type='submit' value='Delete'/>
|
||||||
|
<% } %>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr/>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<h2>No Registered Tokens</h2>
|
||||||
|
<% } %>
|
||||||
|
<form id='create-token-form'>
|
||||||
|
<ul class='input'>
|
||||||
|
<li class='note'>
|
||||||
|
<%= ctx.makeTextInput({
|
||||||
|
text: 'Note',
|
||||||
|
id: 'note',
|
||||||
|
}) %>
|
||||||
|
</li>
|
||||||
|
<li class='expirationTime'>
|
||||||
|
<%= ctx.makeDateInput({
|
||||||
|
text: 'Expires',
|
||||||
|
id: 'expirationTime',
|
||||||
|
}) %>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<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.token = null;
|
||||||
this.cache = {};
|
this.cache = {};
|
||||||
this.allRanks = [
|
this.allRanks = [
|
||||||
'anonymous',
|
'anonymous',
|
||||||
|
@ -87,11 +88,76 @@ 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.loginWithToken(auth.user, auth.token, true) :
|
||||||
Promise.resolve();
|
Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginWithToken(userName, token, doRemember) {
|
||||||
|
this.cache = {};
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.userName = userName;
|
||||||
|
this.token = token;
|
||||||
|
this.get('/user/' + userName + '?bump-login=true')
|
||||||
|
.then(response => {
|
||||||
|
const options = {};
|
||||||
|
if (doRemember) {
|
||||||
|
options.expires = 365;
|
||||||
|
}
|
||||||
|
cookies.set(
|
||||||
|
'auth',
|
||||||
|
{'user': userName, 'token': token},
|
||||||
|
options);
|
||||||
|
this.user = response;
|
||||||
|
resolve();
|
||||||
|
this.dispatchEvent(new CustomEvent('login'));
|
||||||
|
}, error => {
|
||||||
|
reject(error);
|
||||||
|
this.logout();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createToken(userName, options) {
|
||||||
|
let userTokenRequest = {
|
||||||
|
enabled: true,
|
||||||
|
note: 'Web Login Token'
|
||||||
|
};
|
||||||
|
if (typeof options.expires !== 'undefined') {
|
||||||
|
userTokenRequest.expirationTime = new Date().addDays(options.expires).toISOString()
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.post('/user-token/' + userName, userTokenRequest)
|
||||||
|
.then(response => {
|
||||||
|
cookies.set(
|
||||||
|
'auth',
|
||||||
|
{'user': userName, 'token': response.token},
|
||||||
|
options);
|
||||||
|
this.userName = userName;
|
||||||
|
this.token = response.token;
|
||||||
|
this.userPassword = null;
|
||||||
|
}, error => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteToken(userName, userToken) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.delete('/user-token/' + userName + '/' + userToken, {})
|
||||||
|
.then(response => {
|
||||||
|
const options = {};
|
||||||
|
cookies.set(
|
||||||
|
'auth',
|
||||||
|
{'user': userName, 'token': null},
|
||||||
|
options);
|
||||||
|
resolve();
|
||||||
|
}, error => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
login(userName, userPassword, doRemember) {
|
login(userName, userPassword, doRemember) {
|
||||||
this.cache = {};
|
this.cache = {};
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
@ -103,10 +169,7 @@ class Api extends events.EventTarget {
|
||||||
if (doRemember) {
|
if (doRemember) {
|
||||||
options.expires = 365;
|
options.expires = 365;
|
||||||
}
|
}
|
||||||
cookies.set(
|
this.createToken(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 +181,20 @@ class Api extends events.EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
|
let self = this;
|
||||||
|
this.deleteToken(this.userName, this.token)
|
||||||
|
.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.token = null;
|
||||||
this.dispatchEvent(new CustomEvent('logout'));
|
this.dispatchEvent(new CustomEvent('logout'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,6 +211,10 @@ class Api extends events.EventTarget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isCurrentAuthToken(userToken) {
|
||||||
|
return userToken.token === this.token;
|
||||||
|
}
|
||||||
|
|
||||||
_getFullUrl(url) {
|
_getFullUrl(url) {
|
||||||
const fullUrl =
|
const fullUrl =
|
||||||
(config.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/');
|
(config.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/');
|
||||||
|
@ -258,7 +336,11 @@ class Api extends events.EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.userName && this.userPassword) {
|
if (this.userName && this.token) {
|
||||||
|
req.auth = null;
|
||||||
|
req.set('Authorization', 'Token '
|
||||||
|
+ new Buffer(this.userName + ":" + this.token).toString('base64'))
|
||||||
|
} else if (this.userName && this.userPassword) {
|
||||||
req.auth(
|
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,28 @@ class UserController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._successMessages = [];
|
||||||
|
this._errorMessages = [];
|
||||||
|
|
||||||
|
let userTokenPromise = Promise.resolve([]);
|
||||||
|
if (section === 'list-tokens') {
|
||||||
|
userTokenPromise = UserToken.get(userName)
|
||||||
|
.then(userTokens => {
|
||||||
|
return userTokens.map(token => {
|
||||||
|
token.isCurrentAuthToken = api.isCurrentAuthToken(token);
|
||||||
|
return token;
|
||||||
|
});
|
||||||
|
}, error => {
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
topNavigation.setTitle('User ' + userName);
|
topNavigation.setTitle('User ' + userName);
|
||||||
User.get(userName).then(user => {
|
Promise.all([
|
||||||
|
userTokenPromise,
|
||||||
|
User.get(userName)
|
||||||
|
]).then(responses => {
|
||||||
|
const [userTokens, user] = responses;
|
||||||
const isLoggedIn = api.isLoggedIn(user);
|
const isLoggedIn = api.isLoggedIn(user);
|
||||||
const infix = isLoggedIn ? 'self' : 'any';
|
const infix = isLoggedIn ? 'self' : 'any';
|
||||||
|
|
||||||
|
@ -48,6 +69,7 @@ class UserController {
|
||||||
} else {
|
} else {
|
||||||
topNavigation.activate('users');
|
topNavigation.activate('users');
|
||||||
}
|
}
|
||||||
|
|
||||||
this._view = new UserView({
|
this._view = new UserView({
|
||||||
user: user,
|
user: user,
|
||||||
section: section,
|
section: section,
|
||||||
|
@ -58,18 +80,51 @@ 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));
|
||||||
|
this._view.addEventListener('update-token', e => this._evtUpdateToken(e));
|
||||||
|
|
||||||
|
for (let message of this._successMessages) {
|
||||||
|
this.showSuccess(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let message of this._errorMessages) {
|
||||||
|
this.showError(message);
|
||||||
|
}
|
||||||
|
|
||||||
}, error => {
|
}, 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 +203,53 @@ class UserController {
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_evtCreateToken(e) {
|
||||||
|
this._view.clearMessages();
|
||||||
|
this._view.disableForm();
|
||||||
|
UserToken.create(e.detail.user.name, e.detail.note, e.detail.expirationTime)
|
||||||
|
.then(response => {
|
||||||
|
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
|
||||||
|
ctx.controller.showSuccess('Token ' + response.token + ' created.');
|
||||||
|
}, error => {
|
||||||
|
this._view.showError(error.message);
|
||||||
|
this._view.enableForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtDeleteToken(e) {
|
||||||
|
this._view.clearMessages();
|
||||||
|
this._view.disableForm();
|
||||||
|
if (api.isCurrentAuthToken(e.detail.userToken)) {
|
||||||
|
router.show(uri.formatClientLink('logout'));
|
||||||
|
} else {
|
||||||
|
e.detail.userToken.delete(e.detail.user.name)
|
||||||
|
.then(() => {
|
||||||
|
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
|
||||||
|
ctx.controller.showSuccess('Token ' + e.detail.userToken.token + ' deleted.');
|
||||||
|
}, error => {
|
||||||
|
this._view.showError(error.message);
|
||||||
|
this._view.enableForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtUpdateToken(e) {
|
||||||
|
this._view.clearMessages();
|
||||||
|
this._view.disableForm();
|
||||||
|
|
||||||
|
if (e.detail.note !== undefined) {
|
||||||
|
e.detail.userToken.note = e.detail.note;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.detail.userToken.save(e.detail.user.name).then(response => {
|
||||||
|
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
|
||||||
|
ctx.controller.showSuccess('Token ' + response.token + ' updated.');
|
||||||
|
}, error => {
|
||||||
|
this._view.showError(error.message);
|
||||||
|
this._view.enableForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = router => {
|
module.exports = router => {
|
||||||
|
@ -157,6 +259,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');
|
||||||
});
|
});
|
||||||
|
|
116
client/js/models/user_token.js
Normal file
116
client/js/models/user_token.js
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const api = require('../api.js');
|
||||||
|
const uri = require('../util/uri.js');
|
||||||
|
const events = require('../events.js');
|
||||||
|
|
||||||
|
class UserToken extends events.EventTarget {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._orig = {};
|
||||||
|
this._updateFromResponse({});
|
||||||
|
}
|
||||||
|
|
||||||
|
get token() { return this._token; }
|
||||||
|
get note() { return this._note; }
|
||||||
|
get enabled() { return this._enabled; }
|
||||||
|
get version() { return this._version; }
|
||||||
|
get expirationTime() { return this._expirationTime; }
|
||||||
|
get creationTime() { return this._creationTime; }
|
||||||
|
get lastEditTime() { return this._lastEditTime; }
|
||||||
|
get lastUsageTime() { return this._lastUsageTime; }
|
||||||
|
|
||||||
|
set note(value) { this._note = value; }
|
||||||
|
|
||||||
|
static fromResponse(response) {
|
||||||
|
if (typeof response.results !== 'undefined') {
|
||||||
|
let tokenList = [];
|
||||||
|
for (let responseToken of response.results) {
|
||||||
|
const token = new UserToken();
|
||||||
|
token._updateFromResponse(responseToken);
|
||||||
|
tokenList.push(token)
|
||||||
|
}
|
||||||
|
return tokenList;
|
||||||
|
} else {
|
||||||
|
const ret = new UserToken();
|
||||||
|
ret._updateFromResponse(response);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get(userName) {
|
||||||
|
return api.get(uri.formatApiLink('user-tokens', userName))
|
||||||
|
.then(response => {
|
||||||
|
return Promise.resolve(UserToken.fromResponse(response));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(userName, note, expirationTime) {
|
||||||
|
let userTokenRequest = {
|
||||||
|
enabled: true
|
||||||
|
};
|
||||||
|
if (note) {
|
||||||
|
userTokenRequest.note = note;
|
||||||
|
}
|
||||||
|
if (expirationTime) {
|
||||||
|
userTokenRequest.expirationTime = expirationTime;
|
||||||
|
}
|
||||||
|
return api.post(uri.formatApiLink('user-token', userName), userTokenRequest)
|
||||||
|
.then(response => {
|
||||||
|
return Promise.resolve(UserToken.fromResponse(response))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
save(userName) {
|
||||||
|
const detail = {version: this._version};
|
||||||
|
|
||||||
|
if (this._note !== this._orig._note) {
|
||||||
|
detail.note = this._note;
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.put(
|
||||||
|
uri.formatApiLink('user-token', userName, this._orig._token),
|
||||||
|
detail)
|
||||||
|
.then(response => {
|
||||||
|
this._updateFromResponse(response);
|
||||||
|
this.dispatchEvent(new CustomEvent('change', {
|
||||||
|
detail: {
|
||||||
|
userToken: this,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return Promise.resolve(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(userName) {
|
||||||
|
return api.delete(
|
||||||
|
uri.formatApiLink('user-token', userName, this._orig._token),
|
||||||
|
{version: this._version})
|
||||||
|
.then(response => {
|
||||||
|
this.dispatchEvent(new CustomEvent('delete', {
|
||||||
|
detail: {
|
||||||
|
userToken: this,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateFromResponse(response) {
|
||||||
|
const map = {
|
||||||
|
_token: response.token,
|
||||||
|
_note: response.note,
|
||||||
|
_enabled: response.enabled,
|
||||||
|
_expirationTime: response.expirationTime,
|
||||||
|
_version: response.version,
|
||||||
|
_creationTime: response.creationTime,
|
||||||
|
_lastEditTime: response.lastEditTime,
|
||||||
|
_lastUsageTime: response.lastUsageTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(this, map);
|
||||||
|
Object.assign(this._orig, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UserToken;
|
|
@ -59,3 +59,10 @@ Number.prototype.between = function(a, b, inclusive) {
|
||||||
|
|
||||||
// non standard
|
// non standard
|
||||||
Promise.prototype.abort = () => {};
|
Promise.prototype.abort = () => {};
|
||||||
|
|
||||||
|
// non standard
|
||||||
|
Date.prototype.addDays = function(days) {
|
||||||
|
let dat = new Date(this.valueOf());
|
||||||
|
dat.setDate(dat.getDate() + days);
|
||||||
|
return dat;
|
||||||
|
};
|
||||||
|
|
|
@ -168,6 +168,11 @@ function makeNumericInput(options) {
|
||||||
return makeInput(options);
|
return makeInput(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeDateInput(options) {
|
||||||
|
options.type = 'date';
|
||||||
|
return makeInput(options)
|
||||||
|
}
|
||||||
|
|
||||||
function getPostUrl(id, parameters) {
|
function getPostUrl(id, parameters) {
|
||||||
return uri.formatClientLink(
|
return uri.formatClientLink(
|
||||||
'post', id,
|
'post', id,
|
||||||
|
@ -392,6 +397,7 @@ function getTemplate(templatePath) {
|
||||||
makePasswordInput: makePasswordInput,
|
makePasswordInput: makePasswordInput,
|
||||||
makeEmailInput: makeEmailInput,
|
makeEmailInput: makeEmailInput,
|
||||||
makeColorInput: makeColorInput,
|
makeColorInput: makeColorInput,
|
||||||
|
makeDateInput: makeDateInput,
|
||||||
makePostLink: makePostLink,
|
makePostLink: makePostLink,
|
||||||
makeTagLink: makeTagLink,
|
makeTagLink: makeTagLink,
|
||||||
makeUserLink: makeUserLink,
|
makeUserLink: makeUserLink,
|
||||||
|
|
134
client/js/views/user_tokens_view.js
Normal file
134
client/js/views/user_tokens_view.js
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const events = require('../events.js');
|
||||||
|
const views = require('../util/views.js');
|
||||||
|
|
||||||
|
const template = views.getTemplate('user-tokens');
|
||||||
|
|
||||||
|
class UserTokenView extends events.EventTarget {
|
||||||
|
constructor(ctx) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this._user = ctx.user;
|
||||||
|
this._tokens = ctx.tokens;
|
||||||
|
this._hostNode = ctx.hostNode;
|
||||||
|
this._tokenFormNodes = [];
|
||||||
|
views.replaceContent(this._hostNode, template(ctx));
|
||||||
|
views.decorateValidator(this._formNode);
|
||||||
|
|
||||||
|
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
|
||||||
|
|
||||||
|
this._decorateTokenForms();
|
||||||
|
this._decorateTokenNoteChangeLinks();
|
||||||
|
}
|
||||||
|
|
||||||
|
_decorateTokenForms() {
|
||||||
|
this._tokenFormNodes = [];
|
||||||
|
for (let i = 0; i < this._tokens.length; i++) {
|
||||||
|
let formNode = this._hostNode.querySelector(
|
||||||
|
'.token[data-token-id=\"' + i + '\"]');
|
||||||
|
formNode.addEventListener('submit', e => this._evtDelete(e));
|
||||||
|
this._tokenFormNodes.push(formNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_decorateTokenNoteChangeLinks() {
|
||||||
|
for (let i = 0; i < this._tokens.length; i++) {
|
||||||
|
let linkNode = this._hostNode.querySelector(
|
||||||
|
'.token-change-note[data-token-id=\"' + i + '\"]');
|
||||||
|
linkNode.addEventListener(
|
||||||
|
'click', e => this._evtChangeNoteClick(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMessages() {
|
||||||
|
views.clearMessages(this._hostNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(message) {
|
||||||
|
views.showSuccess(this._hostNode, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
views.showError(this._hostNode, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
enableForm() {
|
||||||
|
views.enableForm(this._formNode);
|
||||||
|
for (let formNode of this._tokenFormNodes) {
|
||||||
|
views.enableForm(formNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disableForm() {
|
||||||
|
views.disableForm(this._formNode);
|
||||||
|
for (let formNode of this._tokenFormNodes) {
|
||||||
|
views.disableForm(formNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtDelete(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const userToken = this._tokens[parseInt(
|
||||||
|
e.target.getAttribute('data-token-id'))];
|
||||||
|
this.dispatchEvent(new CustomEvent('delete', {
|
||||||
|
detail: {
|
||||||
|
user: this._user,
|
||||||
|
userToken: userToken,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dispatchEvent(new CustomEvent('submit', {
|
||||||
|
detail: {
|
||||||
|
user: this._user,
|
||||||
|
|
||||||
|
note: this._userTokenNoteInputNode ?
|
||||||
|
this._userTokenNoteInputNode.value :
|
||||||
|
undefined,
|
||||||
|
|
||||||
|
expirationTime:
|
||||||
|
(this._userTokenExpirationTimeInputNode
|
||||||
|
&& this._userTokenExpirationTimeInputNode.value) ?
|
||||||
|
new Date(this._userTokenExpirationTimeInputNode.value)
|
||||||
|
.toISOString() :
|
||||||
|
undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtChangeNoteClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const userToken = this._tokens[
|
||||||
|
parseInt(e.target.getAttribute('data-token-id'))];
|
||||||
|
const text = window.prompt(
|
||||||
|
'Please enter the new name:',
|
||||||
|
userToken.note !== null ? userToken.note : undefined);
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new CustomEvent('update', {
|
||||||
|
detail: {
|
||||||
|
user: this._user,
|
||||||
|
userToken: userToken,
|
||||||
|
note: text ? text : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
get _formNode() {
|
||||||
|
return this._hostNode.querySelector('#create-token-form');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _userTokenNoteInputNode() {
|
||||||
|
return this._formNode.querySelector('.note input');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _userTokenExpirationTimeInputNode() {
|
||||||
|
return this._formNode.querySelector('.expirationTime input');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UserTokenView;
|
|
@ -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,17 @@ 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');
|
||||||
|
events.proxyEvent(this._view, this, 'update', 'update-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
|
||||||
|
|
|
@ -12,3 +12,5 @@ elasticsearch>=5.0.0
|
||||||
elasticsearch-dsl>=5.0.0
|
elasticsearch-dsl>=5.0.0
|
||||||
scikit-image>=0.12
|
scikit-image>=0.12
|
||||||
pynacl>=1.2.1
|
pynacl>=1.2.1
|
||||||
|
pytz>=2018.3
|
||||||
|
pyRFC3339>=1.0
|
|
@ -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
|
||||||
|
|
83
server/szurubooru/api/user_token_api.py
Normal file
83
server/szurubooru/api/user_token_api.py
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
from typing import Dict
|
||||||
|
from szurubooru import model, rest
|
||||||
|
from szurubooru.func import auth, users, user_tokens, serialization, versions
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(
|
||||||
|
ctx: rest.Context, user_token: model.UserToken) -> rest.Response:
|
||||||
|
return user_tokens.serialize_user_token(
|
||||||
|
user_token,
|
||||||
|
ctx.user,
|
||||||
|
options=serialization.get_serialization_options(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
@rest.routes.get('/user-tokens/(?P<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)
|
||||||
|
enabled = ctx.get_param_as_bool('enabled', True)
|
||||||
|
user_token = user_tokens.create_user_token(user, enabled)
|
||||||
|
if ctx.has_param('note'):
|
||||||
|
note = ctx.get_param_as_string('note')
|
||||||
|
user_tokens.update_user_token_note(user_token, note)
|
||||||
|
if ctx.has_param('expirationTime'):
|
||||||
|
expiration_time = ctx.get_param_as_string('expirationTime')
|
||||||
|
user_tokens.update_user_token_expiration_time(
|
||||||
|
user_token, expiration_time)
|
||||||
|
ctx.session.add(user_token)
|
||||||
|
ctx.session.commit()
|
||||||
|
return _serialize(ctx, user_token)
|
||||||
|
|
||||||
|
|
||||||
|
@rest.routes.put('/user-token/(?P<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_by_user_and_token(user, params['user_token'])
|
||||||
|
versions.verify_version(user_token, ctx)
|
||||||
|
versions.bump_version(user_token)
|
||||||
|
if ctx.has_param('enabled'):
|
||||||
|
auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix)
|
||||||
|
user_tokens.update_user_token_enabled(
|
||||||
|
user_token, ctx.get_param_as_bool('enabled'))
|
||||||
|
if ctx.has_param('note'):
|
||||||
|
auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix)
|
||||||
|
note = ctx.get_param_as_string('note')
|
||||||
|
user_tokens.update_user_token_note(user_token, note)
|
||||||
|
if ctx.has_param('expirationTime'):
|
||||||
|
auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix)
|
||||||
|
expiration_time = ctx.get_param_as_string('expirationTime')
|
||||||
|
user_tokens.update_user_token_expiration_time(
|
||||||
|
user_token, expiration_time)
|
||||||
|
user_tokens.update_user_token_edit_time(user_token)
|
||||||
|
ctx.session.commit()
|
||||||
|
return _serialize(ctx, user_token)
|
||||||
|
|
||||||
|
|
||||||
|
@rest.routes.delete('/user-token/(?P<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_by_user_and_token(user, params['user_token'])
|
||||||
|
if user_token is not None:
|
||||||
|
ctx.session.delete(user_token)
|
||||||
|
ctx.session.commit()
|
||||||
|
return {}
|
|
@ -1,10 +1,12 @@
|
||||||
from typing import Tuple
|
from typing import Tuple, Optional
|
||||||
import hashlib
|
import hashlib
|
||||||
import random
|
import random
|
||||||
|
import uuid
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from datetime import datetime
|
||||||
from nacl import pwhash
|
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, db, model, errors
|
||||||
from szurubooru.func import util
|
from szurubooru.func import util
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,7 +28,8 @@ def get_password_hash(salt: str, password: str) -> Tuple[str, int]:
|
||||||
).decode('utf8'), 3
|
).decode('utf8'), 3
|
||||||
|
|
||||||
|
|
||||||
def get_sha256_legacy_password_hash(salt: str, password: str) -> Tuple[str, int]:
|
def get_sha256_legacy_password_hash(
|
||||||
|
salt: str, password: str) -> Tuple[str, int]:
|
||||||
''' Retrieve old-style sha256 password hash. '''
|
''' Retrieve old-style sha256 password hash. '''
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
digest.update(config.config['secret'].encode('utf8'))
|
digest.update(config.config['secret'].encode('utf8'))
|
||||||
|
@ -78,6 +81,21 @@ def is_valid_password(user: model.User, password: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_token(user_token: Optional[model.UserToken]) -> bool:
|
||||||
|
'''
|
||||||
|
Token must be enabled and if it has an expiration, it must be
|
||||||
|
greater than now.
|
||||||
|
'''
|
||||||
|
if user_token is None:
|
||||||
|
return False
|
||||||
|
if not user_token.enabled:
|
||||||
|
return False
|
||||||
|
if (user_token.expiration_time is not None
|
||||||
|
and user_token.expiration_time < datetime.utcnow()):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def has_privilege(user: model.User, privilege_name: str) -> bool:
|
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 +120,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__()
|
||||||
|
|
146
server/szurubooru/func/user_tokens.py
Normal file
146
server/szurubooru/func/user_tokens.py
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional, List, Dict, Callable
|
||||||
|
from pyrfc3339 import parser as rfc3339_parser
|
||||||
|
import pytz
|
||||||
|
from szurubooru import db, model, rest, errors
|
||||||
|
from szurubooru.func import auth, serialization, users, util
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidExpirationError(errors.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidNoteError(errors.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenSerializer(serialization.BaseSerializer):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_token: model.UserToken,
|
||||||
|
auth_user: model.User) -> None:
|
||||||
|
self.user_token = user_token
|
||||||
|
self.auth_user = auth_user
|
||||||
|
|
||||||
|
def _serializers(self) -> Dict[str, Callable[[], Any]]:
|
||||||
|
return {
|
||||||
|
'user': self.serialize_user,
|
||||||
|
'token': self.serialize_token,
|
||||||
|
'note': self.serialize_note,
|
||||||
|
'enabled': self.serialize_enabled,
|
||||||
|
'expirationTime': self.serialize_expiration_time,
|
||||||
|
'creationTime': self.serialize_creation_time,
|
||||||
|
'lastEditTime': self.serialize_last_edit_time,
|
||||||
|
'lastUsageTime': self.serialize_last_usage_time,
|
||||||
|
'version': self.serialize_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
def serialize_user(self) -> Any:
|
||||||
|
return users.serialize_micro_user(self.user_token.user, self.auth_user)
|
||||||
|
|
||||||
|
def serialize_creation_time(self) -> Any:
|
||||||
|
return self.user_token.creation_time
|
||||||
|
|
||||||
|
def serialize_last_edit_time(self) -> Any:
|
||||||
|
return self.user_token.last_edit_time
|
||||||
|
|
||||||
|
def serialize_last_usage_time(self) -> Any:
|
||||||
|
return self.user_token.last_usage_time
|
||||||
|
|
||||||
|
def serialize_token(self) -> Any:
|
||||||
|
return self.user_token.token
|
||||||
|
|
||||||
|
def serialize_note(self) -> Any:
|
||||||
|
return self.user_token.note
|
||||||
|
|
||||||
|
def serialize_enabled(self) -> Any:
|
||||||
|
return self.user_token.enabled
|
||||||
|
|
||||||
|
def serialize_expiration_time(self) -> Any:
|
||||||
|
return self.user_token.expiration_time
|
||||||
|
|
||||||
|
def serialize_version(self) -> Any:
|
||||||
|
return self.user_token.version
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_user_token(
|
||||||
|
user_token: Optional[model.UserToken],
|
||||||
|
auth_user: model.User,
|
||||||
|
options: List[str] = []) -> Optional[rest.Response]:
|
||||||
|
if not user_token:
|
||||||
|
return None
|
||||||
|
return UserTokenSerializer(user_token, auth_user).serialize(options)
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_user_and_token(
|
||||||
|
user: model.User, token: str) -> model.UserToken:
|
||||||
|
return (
|
||||||
|
db.session
|
||||||
|
.query(model.UserToken)
|
||||||
|
.filter(model.UserToken.user_id == user.user_id)
|
||||||
|
.filter(model.UserToken.token == token)
|
||||||
|
.one_or_none())
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_tokens(user: model.User) -> List[model.UserToken]:
|
||||||
|
assert user
|
||||||
|
return (
|
||||||
|
db.session
|
||||||
|
.query(model.UserToken)
|
||||||
|
.filter(model.UserToken.user_id == user.user_id)
|
||||||
|
.all())
|
||||||
|
|
||||||
|
|
||||||
|
def create_user_token(user: model.User, enabled: bool) -> model.UserToken:
|
||||||
|
assert user
|
||||||
|
user_token = model.UserToken()
|
||||||
|
user_token.user = user
|
||||||
|
user_token.token = auth.generate_authorization_token()
|
||||||
|
user_token.enabled = enabled
|
||||||
|
user_token.creation_time = datetime.utcnow()
|
||||||
|
user_token.last_usage_time = datetime.utcnow()
|
||||||
|
return user_token
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_token_enabled(
|
||||||
|
user_token: model.UserToken, enabled: bool) -> None:
|
||||||
|
assert user_token
|
||||||
|
user_token.enabled = enabled
|
||||||
|
update_user_token_edit_time(user_token)
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_token_edit_time(user_token: model.UserToken) -> None:
|
||||||
|
assert user_token
|
||||||
|
user_token.last_edit_time = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_token_expiration_time(
|
||||||
|
user_token: model.UserToken, expiration_time_str: str) -> None:
|
||||||
|
assert user_token
|
||||||
|
try:
|
||||||
|
expiration_time = rfc3339_parser.parse(expiration_time_str, utc=True)
|
||||||
|
expiration_time = expiration_time.astimezone(pytz.UTC)
|
||||||
|
if expiration_time < datetime.utcnow().replace(tzinfo=pytz.UTC):
|
||||||
|
raise InvalidExpirationError(
|
||||||
|
'Expiration cannot happen in the past')
|
||||||
|
user_token.expiration_time = expiration_time
|
||||||
|
update_user_token_edit_time(user_token)
|
||||||
|
except ValueError:
|
||||||
|
raise InvalidExpirationError(
|
||||||
|
'Expiration is in an invalid format {}'.format(
|
||||||
|
expiration_time_str))
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_token_note(user_token: model.UserToken, note: str) -> None:
|
||||||
|
assert user_token
|
||||||
|
note = note.strip() if note is not None else ''
|
||||||
|
note = None if len(note) == 0 else note
|
||||||
|
if util.value_exceeds_column_size(note, model.UserToken.note):
|
||||||
|
raise InvalidNoteError('Note is too long.')
|
||||||
|
user_token.note = note
|
||||||
|
update_user_token_edit_time(user_token)
|
||||||
|
|
||||||
|
|
||||||
|
def bump_usage_time(user_token: model.UserToken) -> None:
|
||||||
|
assert user_token
|
||||||
|
user_token.last_usage_time = datetime.utcnow()
|
|
@ -1,6 +1,6 @@
|
||||||
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
|
||||||
|
|
|
@ -160,6 +160,12 @@ def value_exceeds_column_size(value: Optional[str], column: Any) -> bool:
|
||||||
return len(value) > max_length
|
return len(value) > max_length
|
||||||
|
|
||||||
|
|
||||||
|
def get_column_size(column: Any) -> Optional[int]:
|
||||||
|
if not column:
|
||||||
|
return None
|
||||||
|
return column.property.columns[0].type.length
|
||||||
|
|
||||||
|
|
||||||
def chunks(source_list: List[Any], part_size: int) -> Generator:
|
def chunks(source_list: List[Any], part_size: int) -> Generator:
|
||||||
for i in range(0, len(source_list), part_size):
|
for i in range(0, len(source_list), part_size):
|
||||||
yield source_list[i:i + part_size]
|
yield source_list[i:i + part_size]
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import base64
|
import base64
|
||||||
from typing import Optional
|
from typing import Optional, Tuple
|
||||||
from szurubooru import db, model, errors, rest
|
from szurubooru import 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
|
||||||
|
|
||||||
|
|
||||||
def _authenticate(username: str, password: str) -> model.User:
|
def _authenticate_basic_auth(username: str, password: str) -> model.User:
|
||||||
''' Try to authenticate user. Throw AuthError for invalid users. '''
|
''' Try to authenticate user. Throw AuthError for invalid users. '''
|
||||||
user = users.get_user_by_name(username)
|
user = users.get_user_by_name(username)
|
||||||
if not auth.is_valid_password(user, password):
|
if not auth.is_valid_password(user, password):
|
||||||
|
@ -13,34 +13,61 @@ def _authenticate(username: str, password: str) -> model.User:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _get_user(ctx: rest.Context) -> Optional[model.User]:
|
def _authenticate_token(
|
||||||
|
username: str, token: str) -> Tuple[model.User, model.UserToken]:
|
||||||
|
''' Try to authenticate user. Throw AuthError for invalid users. '''
|
||||||
|
user = users.get_user_by_name(username)
|
||||||
|
user_token = user_tokens.get_by_user_and_token(user, token)
|
||||||
|
if not auth.is_valid_token(user_token):
|
||||||
|
raise errors.AuthError('Invalid token.')
|
||||||
|
return user, user_token
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user(ctx: rest.Context, bump_login: bool) -> Optional[model.User]:
|
||||||
if not ctx.has_header('Authorization'):
|
if not ctx.has_header('Authorization'):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
auth_token = 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)
|
||||||
|
auth_user = _authenticate_basic_auth(username, password)
|
||||||
|
elif auth_type.lower() == 'token':
|
||||||
|
username, token = base64.decodebytes(
|
||||||
|
credentials.encode('ascii')).decode('utf8').split(':', 1)
|
||||||
|
auth_user, auth_token = _authenticate_token(username, token)
|
||||||
|
else:
|
||||||
raise HttpBadRequest(
|
raise HttpBadRequest(
|
||||||
'ValidationError',
|
'ValidationError',
|
||||||
'Only basic HTTP authentication is supported.')
|
'Only basic or token 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. '
|
'Authorization header values are not properly formed. '
|
||||||
'Supplied header {0}. Got error: {1}')
|
'Supplied header {0}. Got error: {1}')
|
||||||
raise HttpBadRequest(
|
raise HttpBadRequest(
|
||||||
'ValidationError',
|
'ValidationError',
|
||||||
msg.format(ctx.get_header('Authorization'), str(err)))
|
msg.format(ctx.get_header('Authorization'), str(err)))
|
||||||
|
|
||||||
|
if bump_login and auth_user.user_id:
|
||||||
|
users.bump_user_login_time(auth_user)
|
||||||
|
if auth_token is not None:
|
||||||
|
user_tokens.bump_usage_time(auth_token)
|
||||||
|
ctx.session.commit()
|
||||||
|
|
||||||
|
return auth_user
|
||||||
|
|
||||||
|
|
||||||
@rest.middleware.pre_hook
|
|
||||||
def process_request(ctx: rest.Context) -> None:
|
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)
|
bump_login = ctx.get_param_as_bool('bump-login', default=False)
|
||||||
|
auth_user = _get_user(ctx, bump_login)
|
||||||
if auth_user:
|
if auth_user:
|
||||||
ctx.user = auth_user
|
ctx.user = auth_user
|
||||||
if ctx.get_param_as_bool('bump-login', default=False) and ctx.user.user_id:
|
|
||||||
users.bump_user_login_time(ctx.user)
|
|
||||||
ctx.session.commit()
|
@rest.middleware.pre_hook
|
||||||
|
def process_request_hook(ctx: rest.Context) -> None:
|
||||||
|
process_request(ctx)
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
'''
|
||||||
|
Added a user_token table for API authorization
|
||||||
|
|
||||||
|
Revision ID: a39c7f98a7fa
|
||||||
|
Created at: 2018-02-25 01:31:27.345595
|
||||||
|
'''
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'a39c7f98a7fa'
|
||||||
|
down_revision = '9ef1a1643c2a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'user_token',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('token', sa.Unicode(length=36), nullable=False),
|
||||||
|
sa.Column('note', sa.Unicode(length=128), nullable=True),
|
||||||
|
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('expiration_time', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('creation_time', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_edit_time', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('last_usage_time', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('version', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'))
|
||||||
|
op.create_index(
|
||||||
|
op.f('ix_user_token_user_id'), 'user_token', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index(op.f('ix_user_token_user_id'), table_name='user_token')
|
||||||
|
op.drop_table('user_token')
|
|
@ -1,5 +1,5 @@
|
||||||
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,25 @@ 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)
|
||||||
|
note = sa.Column('note', sa.Unicode(128), nullable=True)
|
||||||
|
enabled = sa.Column('enabled', sa.Boolean, nullable=False, default=True)
|
||||||
|
expiration_time = sa.Column('expiration_time', sa.DateTime, nullable=True)
|
||||||
|
creation_time = sa.Column('creation_time', sa.DateTime, nullable=False)
|
||||||
|
last_edit_time = sa.Column('last_edit_time', sa.DateTime)
|
||||||
|
last_usage_time = sa.Column('last_usage_time', sa.DateTime)
|
||||||
|
version = sa.Column('version', sa.Integer, default=1, nullable=False)
|
||||||
|
|
||||||
|
user = sa.orm.relationship('User')
|
||||||
|
|
|
@ -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, True)
|
30
server/szurubooru/tests/api/test_user_token_deleting.py
Normal file
30
server/szurubooru/tests/api/test_user_token_deleting.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db
|
||||||
|
from szurubooru.func import user_tokens, users
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({'privileges': {'user_tokens:delete:self': 'regular'}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_user_token(
|
||||||
|
user_token_factory, context_factory, fake_datetime):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
db.session.add(user_token)
|
||||||
|
db.session.commit()
|
||||||
|
with patch('szurubooru.func.user_tokens.get_by_user_and_token'), \
|
||||||
|
patch('szurubooru.func.users.get_user_by_name'), \
|
||||||
|
fake_datetime('1969-02-12'):
|
||||||
|
users.get_user_by_name.return_value = user_token.user
|
||||||
|
user_tokens.get_by_user_and_token.return_value = user_token
|
||||||
|
result = api.user_token_api.delete_user_token(
|
||||||
|
context_factory(user=user_token.user),
|
||||||
|
{
|
||||||
|
'user_name': user_token.user.name,
|
||||||
|
'user_token': user_token.token
|
||||||
|
})
|
||||||
|
assert result == {}
|
||||||
|
user_tokens.get_by_user_and_token.assert_called_once_with(
|
||||||
|
user_token.user, user_token.token)
|
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'] * 3}
|
||||||
|
user_tokens.get_user_tokens.assert_called_once_with(user_token1.user)
|
42
server/szurubooru/tests/api/test_user_token_updating.py
Normal file
42
server/szurubooru/tests/api/test_user_token_updating.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db
|
||||||
|
from szurubooru.func import user_tokens, users
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({'privileges': {'user_tokens:edit:self': 'regular'}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_user_token(user_token_factory, context_factory, fake_datetime):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
db.session.add(user_token)
|
||||||
|
db.session.commit()
|
||||||
|
with patch('szurubooru.func.user_tokens.get_by_user_and_token'), \
|
||||||
|
patch('szurubooru.func.user_tokens.update_user_token_enabled'), \
|
||||||
|
patch('szurubooru.func.user_tokens.update_user_token_edit_time'), \
|
||||||
|
patch('szurubooru.func.user_tokens.serialize_user_token'), \
|
||||||
|
patch('szurubooru.func.users.get_user_by_name'), \
|
||||||
|
fake_datetime('1969-02-12'):
|
||||||
|
users.get_user_by_name.return_value = user_token.user
|
||||||
|
user_tokens.serialize_user_token.return_value = 'serialized user token'
|
||||||
|
user_tokens.get_by_user_and_token.return_value = user_token
|
||||||
|
result = api.user_token_api.update_user_token(
|
||||||
|
context_factory(
|
||||||
|
params={
|
||||||
|
'version': user_token.version,
|
||||||
|
'enabled': False,
|
||||||
|
},
|
||||||
|
user=user_token.user),
|
||||||
|
{
|
||||||
|
'user_name': user_token.user.name,
|
||||||
|
'user_token': user_token.token
|
||||||
|
})
|
||||||
|
assert result == 'serialized user token'
|
||||||
|
user_tokens.get_by_user_and_token.assert_called_once_with(
|
||||||
|
user_token.user, user_token.token)
|
||||||
|
user_tokens.update_user_token_enabled.assert_called_once_with(
|
||||||
|
user_token, False)
|
||||||
|
user_tokens.update_user_token_edit_time.assert_called_once_with(
|
||||||
|
user_token)
|
|
@ -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,27 @@ def user_factory():
|
||||||
return factory
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_token_factory(user_factory):
|
||||||
|
def factory(
|
||||||
|
user=None,
|
||||||
|
token=None,
|
||||||
|
expiration_time=None,
|
||||||
|
enabled=None,
|
||||||
|
creation_time=None):
|
||||||
|
if user is None:
|
||||||
|
user = user_factory()
|
||||||
|
db.session.add(user)
|
||||||
|
user_token = model.UserToken()
|
||||||
|
user_token.user = user
|
||||||
|
user_token.token = token or 'dummy'
|
||||||
|
user_token.expiration_time = expiration_time
|
||||||
|
user_token.enabled = enabled if enabled is not None else True
|
||||||
|
user_token.creation_time = creation_time or datetime(1997, 1, 1)
|
||||||
|
return user_token
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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):
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from szurubooru.func import auth
|
from datetime import datetime, timedelta
|
||||||
import pytest
|
import pytest
|
||||||
|
from szurubooru.func import auth
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
@ -41,3 +42,24 @@ def test_is_valid_password_auto_upgrades_user_password_hash(user_factory):
|
||||||
assert result is True
|
assert 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_expired_token_is_invalid(user_token_factory):
|
||||||
|
past_expiration = (datetime.utcnow() - timedelta(minutes=30))
|
||||||
|
user_token = user_token_factory(expiration_time=past_expiration)
|
||||||
|
assert not auth.is_valid_token(user_token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_token_is_invalid(user_token_factory):
|
||||||
|
user_token = user_token_factory(enabled=False)
|
||||||
|
assert not auth.is_valid_token(user_token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_authorization_token():
|
||||||
|
result = auth.generate_authorization_token()
|
||||||
|
assert result != auth.generate_authorization_token()
|
||||||
|
|
155
server/szurubooru/tests/func/test_user_tokens.py
Normal file
155
server/szurubooru/tests/func/test_user_tokens.py
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
import pytz
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from szurubooru import db, model
|
||||||
|
from szurubooru.func import user_tokens, users, auth, util
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_user_token(user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
db.session.add(user_token)
|
||||||
|
db.session.flush()
|
||||||
|
with patch('szurubooru.func.users.get_avatar_url'):
|
||||||
|
users.get_avatar_url.return_value = 'https://example.com/avatar.png'
|
||||||
|
result = user_tokens.serialize_user_token(user_token, user_token.user)
|
||||||
|
assert result == {
|
||||||
|
'creationTime': datetime(1997, 1, 1, 0, 0),
|
||||||
|
'enabled': True,
|
||||||
|
'expirationTime': None,
|
||||||
|
'lastEditTime': None,
|
||||||
|
'lastUsageTime': None,
|
||||||
|
'note': None,
|
||||||
|
'token': 'dummy',
|
||||||
|
'user': {
|
||||||
|
'avatarUrl': 'https://example.com/avatar.png',
|
||||||
|
'name': user_token.user.name},
|
||||||
|
'version': 1
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_user_token_none():
|
||||||
|
result = user_tokens.serialize_user_token(None, None)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_by_user_and_token(user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
db.session.add(user_token)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.commit()
|
||||||
|
result = user_tokens.get_by_user_and_token(
|
||||||
|
user_token.user, user_token.token)
|
||||||
|
assert result == user_token
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_tokens(user_token_factory):
|
||||||
|
user_token1 = user_token_factory()
|
||||||
|
user_token2 = user_token_factory(user=user_token1.user)
|
||||||
|
db.session.add(user_token1)
|
||||||
|
db.session.add(user_token2)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.commit()
|
||||||
|
result = user_tokens.get_user_tokens(user_token1.user)
|
||||||
|
assert result == [user_token1, user_token2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_token(user_factory):
|
||||||
|
user = user_factory()
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.commit()
|
||||||
|
with patch('szurubooru.func.auth.generate_authorization_token'):
|
||||||
|
auth.generate_authorization_token.return_value = 'test'
|
||||||
|
result = user_tokens.create_user_token(user, True)
|
||||||
|
assert result.token == 'test'
|
||||||
|
assert result.user == user
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_token_enabled(user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
user_tokens.update_user_token_enabled(user_token, False)
|
||||||
|
assert user_token.enabled is False
|
||||||
|
assert user_token.last_edit_time is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_token_edit_time(user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
assert user_token.last_edit_time is None
|
||||||
|
user_tokens.update_user_token_edit_time(user_token)
|
||||||
|
assert user_token.last_edit_time is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_token_note(user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
assert user_token.note is None
|
||||||
|
user_tokens.update_user_token_note(user_token, ' Test Note ')
|
||||||
|
assert user_token.note == 'Test Note'
|
||||||
|
assert user_token.last_edit_time is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_token_note_input_too_long(user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
assert user_token.note is None
|
||||||
|
note_max_length = util.get_column_size(model.UserToken.note) + 1
|
||||||
|
note = ''.join(
|
||||||
|
random.choice(string.ascii_letters) for _ in range(note_max_length))
|
||||||
|
with pytest.raises(user_tokens.InvalidNoteError):
|
||||||
|
user_tokens.update_user_token_note(user_token, note)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_token_expiration_time(user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
assert user_token.expiration_time is None
|
||||||
|
expiration_time_str = (
|
||||||
|
(datetime.utcnow() + timedelta(days=1))
|
||||||
|
.replace(tzinfo=pytz.utc)
|
||||||
|
).isoformat()
|
||||||
|
user_tokens.update_user_token_expiration_time(
|
||||||
|
user_token, expiration_time_str)
|
||||||
|
assert user_token.expiration_time.isoformat() == expiration_time_str
|
||||||
|
assert user_token.last_edit_time is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_token_expiration_time_in_past(user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
assert user_token.expiration_time is None
|
||||||
|
expiration_time_str = (
|
||||||
|
(datetime.utcnow() - timedelta(days=1))
|
||||||
|
.replace(tzinfo=pytz.utc)
|
||||||
|
).isoformat()
|
||||||
|
with pytest.raises(
|
||||||
|
user_tokens.InvalidExpirationError,
|
||||||
|
match='Expiration cannot happen in the past'):
|
||||||
|
user_tokens.update_user_token_expiration_time(
|
||||||
|
user_token, expiration_time_str)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('expiration_time_str', [
|
||||||
|
datetime.utcnow().isoformat(),
|
||||||
|
(datetime.utcnow() - timedelta(days=1)).ctime(),
|
||||||
|
'1970/01/01 00:00:01.0000Z',
|
||||||
|
'70/01/01 00:00:01.0000Z',
|
||||||
|
''.join(random.choice(string.ascii_letters) for _ in range(15)),
|
||||||
|
''.join(random.choice(string.digits) for _ in range(8))
|
||||||
|
])
|
||||||
|
def test_update_user_token_expiration_time_invalid_format(
|
||||||
|
expiration_time_str, user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
assert user_token.expiration_time is None
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
user_tokens.InvalidExpirationError,
|
||||||
|
match='Expiration is in an invalid format %s'
|
||||||
|
% expiration_time_str):
|
||||||
|
user_tokens.update_user_token_expiration_time(
|
||||||
|
user_token, expiration_time_str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_usage_time(user_token_factory, fake_datetime):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
with fake_datetime('1997-01-01'):
|
||||||
|
user_tokens.bump_usage_time(user_token)
|
||||||
|
assert user_token.last_usage_time == datetime(1997, 1, 1)
|
0
server/szurubooru/tests/middleware/__init__.py
Normal file
0
server/szurubooru/tests/middleware/__init__.py
Normal file
93
server/szurubooru/tests/middleware/test_authenticator.py
Normal file
93
server/szurubooru/tests/middleware/test_authenticator.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
from szurubooru import db
|
||||||
|
from szurubooru.func import auth, users, user_tokens
|
||||||
|
from szurubooru.middleware import authenticator
|
||||||
|
from szurubooru.rest import errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_request_no_header(context_factory):
|
||||||
|
ctx = context_factory()
|
||||||
|
authenticator.process_request(ctx)
|
||||||
|
assert ctx.user.name is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_request_bump_login(context_factory, user_factory):
|
||||||
|
user = user_factory()
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.flush()
|
||||||
|
ctx = context_factory(
|
||||||
|
headers={
|
||||||
|
'Authorization': 'Basic dGVzdFVzZXI6dGVzdFRva2Vu'
|
||||||
|
},
|
||||||
|
params={
|
||||||
|
'bump-login': 'true'
|
||||||
|
})
|
||||||
|
with patch('szurubooru.func.auth.is_valid_password'), \
|
||||||
|
patch('szurubooru.func.users.get_user_by_name'):
|
||||||
|
users.get_user_by_name.return_value = user
|
||||||
|
auth.is_valid_password.return_value = True
|
||||||
|
authenticator.process_request(ctx)
|
||||||
|
assert user.last_login_time is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_request_bump_login_with_token(
|
||||||
|
context_factory, user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
db.session.add(user_token)
|
||||||
|
db.session.flush()
|
||||||
|
ctx = context_factory(
|
||||||
|
headers={
|
||||||
|
'Authorization': 'Token dGVzdFVzZXI6dGVzdFRva2Vu'
|
||||||
|
},
|
||||||
|
params={
|
||||||
|
'bump-login': 'true'
|
||||||
|
})
|
||||||
|
with patch('szurubooru.func.auth.is_valid_token'), \
|
||||||
|
patch('szurubooru.func.users.get_user_by_name'), \
|
||||||
|
patch('szurubooru.func.user_tokens.get_by_user_and_token'):
|
||||||
|
users.get_user_by_name.return_value = user_token.user
|
||||||
|
user_tokens.get_by_user_and_token.return_value = user_token
|
||||||
|
auth.is_valid_token.return_value = True
|
||||||
|
authenticator.process_request(ctx)
|
||||||
|
assert user_token.user.last_login_time is not None
|
||||||
|
assert user_token.last_usage_time is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_request_basic_auth_valid(context_factory, user_factory):
|
||||||
|
user = user_factory()
|
||||||
|
ctx = context_factory(
|
||||||
|
headers={
|
||||||
|
'Authorization': 'Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk'
|
||||||
|
})
|
||||||
|
with patch('szurubooru.func.auth.is_valid_password'), \
|
||||||
|
patch('szurubooru.func.users.get_user_by_name'):
|
||||||
|
users.get_user_by_name.return_value = user
|
||||||
|
auth.is_valid_password.return_value = True
|
||||||
|
authenticator.process_request(ctx)
|
||||||
|
assert ctx.user == user
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_request_token_auth_valid(context_factory, user_token_factory):
|
||||||
|
user_token = user_token_factory()
|
||||||
|
ctx = context_factory(
|
||||||
|
headers={
|
||||||
|
'Authorization': 'Token dGVzdFVzZXI6dGVzdFRva2Vu'
|
||||||
|
})
|
||||||
|
with patch('szurubooru.func.auth.is_valid_token'), \
|
||||||
|
patch('szurubooru.func.users.get_user_by_name'), \
|
||||||
|
patch('szurubooru.func.user_tokens.get_by_user_and_token'):
|
||||||
|
users.get_user_by_name.return_value = user_token.user
|
||||||
|
user_tokens.get_by_user_and_token.return_value = user_token
|
||||||
|
auth.is_valid_token.return_value = True
|
||||||
|
authenticator.process_request(ctx)
|
||||||
|
assert ctx.user == user_token.user
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_request_bad_header(context_factory):
|
||||||
|
ctx = context_factory(
|
||||||
|
headers={
|
||||||
|
'Authorization': 'Secret SuperSecretValue'
|
||||||
|
})
|
||||||
|
with pytest.raises(errors.HttpBadRequest):
|
||||||
|
authenticator.process_request(ctx)
|
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