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:
ReAnzu 2018-02-25 04:44:02 -06:00 committed by rr-
parent e35e709927
commit 2a69f0193f
36 changed files with 1609 additions and 40 deletions

166
API.md
View file

@ -7,6 +7,7 @@
1. [General rules](#general-rules)
- [Authentication](#authentication)
- [User token authentication](#user-token-authentication)
- [Basic requests](#basic-requests)
- [File uploads](#file-uploads)
- [Error handling](#error-handling)
@ -56,6 +57,11 @@
- [Updating user](#updating-user)
- [Getting user](#getting-user)
- [Deleting user](#deleting-user)
- User Tokens
- [Listing 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 - step 1: mail request](#password-reset---step-2-confirmation)
- [Password reset - step 2: confirmation](#password-reset---step-2-confirmation)
@ -70,6 +76,7 @@
- [User](#user)
- [Micro user](#micro-user)
- [User token](#user-token)
- [Tag category](#tag-category)
- [Tag](#tag)
- [Micro tag](#micro-tag)
@ -91,7 +98,8 @@
## Authentication
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
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
@ -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
that the user's last login time is kept up to date.
## User token authentication
User token authentication works similarly to [basic HTTP
auth](https://en.wikipedia.org/wiki/Basic_access_authentication). Because it
operates similarly to ***basic HTTP auth*** it is still recommended to connect
through HTTPS. The authorization header uses the type of `Token` and the
username and token are encoded as Base64 and sent as the second parameter.
Example header for user1:token-is-more-secure
```
Authorization: Token dXNlcjE6dG9rZW4taXMtbW9yZS1zZWN1cmU=
```
The benefit of token authentication is that beyond the initial login to acquire
the first token, there is no need to transmit the user password in plaintext
via basic auth. Additionally tokens can be revoked at anytime allowing a
cleaner interface for isolating clients from user credentials.
## Basic requests
Every request must use `Content-Type: application/json` and `Accept:
@ -1469,6 +1495,112 @@ data.
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
- **Request**
@ -1701,6 +1833,38 @@ A single user.
A [user resource](#user) stripped down to `name` and `avatarUrl` fields.
## User token
**Description**
A single user token.
**Structure**
```json5
{
"user": <user>,
"token": <token>,
"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
**Description**

View file

@ -11,6 +11,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
- Post comments
- Post notes / annotations, including arbitrary polygons
- Rich JSON REST API ([see documentation](https://github.com/rr-/szurubooru/blob/master/API.md))
- Token based authentication for clients
- Rich search system
- Rich privilege system
- Autocomplete in search and while editing tags

View file

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

View file

@ -1,3 +1,6 @@
@import colors
$token-border-color = $active-tab-background-color
#user
width: 100%
max-width: 35em
@ -37,7 +40,43 @@
height: 1px
clear: both
#user-delete form
#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
width: 100%

View file

@ -4,10 +4,13 @@
--><ul><!--
--><li data-name='summary'><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>Summary</a></li><!--
--><% 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) { %><!--
--><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><!--
--></nav>

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

View file

@ -15,6 +15,7 @@ class Api extends events.EventTarget {
this.user = null;
this.userName = null;
this.userPassword = null;
this.token = null;
this.cache = {};
this.allRanks = [
'anonymous',
@ -87,11 +88,76 @@ class Api extends events.EventTarget {
loginFromCookies() {
const auth = cookies.getJSON('auth');
return auth && auth.user && auth.password ?
this.login(auth.user, auth.password, true) :
return auth && auth.user && auth.token ?
this.loginWithToken(auth.user, auth.token, true) :
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) {
this.cache = {};
return new Promise((resolve, reject) => {
@ -103,10 +169,7 @@ class Api extends events.EventTarget {
if (doRemember) {
options.expires = 365;
}
cookies.set(
'auth',
{'user': userName, 'password': userPassword},
options);
this.createToken(this.userName, options);
this.user = response;
resolve();
this.dispatchEvent(new CustomEvent('login'));
@ -118,9 +181,20 @@ class Api extends events.EventTarget {
}
logout() {
let self = this;
this.deleteToken(this.userName, this.token)
.then(response => {
self._logout();
}, error => {
self._logout();
});
}
_logout() {
this.user = null;
this.userName = null;
this.userPassword = null;
this.token = null;
this.dispatchEvent(new CustomEvent('logout'));
}
@ -137,6 +211,10 @@ class Api extends events.EventTarget {
}
}
isCurrentAuthToken(userToken) {
return userToken.token === this.token;
}
_getFullUrl(url) {
const fullUrl =
(config.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/');
@ -258,7 +336,11 @@ class Api extends events.EventTarget {
}
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(
this.userName,
encodeURIComponent(this.userPassword)

View file

@ -7,6 +7,7 @@ const misc = require('../util/misc.js');
const config = require('../config.js');
const views = require('../util/views.js');
const User = require('../models/user.js');
const UserToken = require('../models/user_token.js');
const topNavigation = require('../models/top_navigation.js');
const UserView = require('../views/user_view.js');
const EmptyView = require('../views/empty_view.js');
@ -21,8 +22,28 @@ class UserController {
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);
User.get(userName).then(user => {
Promise.all([
userTokenPromise,
User.get(userName)
]).then(responses => {
const [userTokens, user] = responses;
const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any';
@ -48,6 +69,7 @@ class UserController {
} else {
topNavigation.activate('users');
}
this._view = new UserView({
user: user,
section: section,
@ -58,18 +80,51 @@ class UserController {
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`),
canEditAnything: api.hasPrivilege(`users:edit:${infix}`),
canListTokens: api.hasPrivilege(`userTokens:list:${infix}`),
canCreateToken: api.hasPrivilege(`userTokens:create:${infix}`),
canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`),
canDeleteToken: api.hasPrivilege(`userTokens:delete:${infix}`),
canDelete: api.hasPrivilege(`users:delete:${infix}`),
ranks: ranks,
tokens: userTokens,
});
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtUpdate(e));
this._view.addEventListener('delete', e => this._evtDelete(e));
this._view.addEventListener('create-token', e => this._evtCreateToken(e));
this._view.addEventListener('delete-token', e => this._evtDeleteToken(e));
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 => {
this._view = new EmptyView();
this._view.showError(error.message);
});
}
showSuccess(message) {
if (typeof this._view === 'undefined') {
this._successMessages.push(message)
} else {
this._view.showSuccess(message);
}
}
showError(message) {
if (typeof this._view === 'undefined') {
this._errorMessages.push(message)
} else {
this._view.showError(message);
}
}
_evtChange(e) {
misc.enableExitConfirmation();
}
@ -148,6 +203,53 @@ class UserController {
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 => {
@ -157,6 +259,9 @@ module.exports = router => {
router.enter(['user', ':name', 'edit'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'edit');
});
router.enter(['user', ':name', 'list-tokens'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'list-tokens');
});
router.enter(['user', ':name', 'delete'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'delete');
});

View file

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

View file

@ -59,3 +59,10 @@ Number.prototype.between = function(a, b, inclusive) {
// non standard
Promise.prototype.abort = () => {};
// non standard
Date.prototype.addDays = function(days) {
let dat = new Date(this.valueOf());
dat.setDate(dat.getDate() + days);
return dat;
};

View file

@ -168,6 +168,11 @@ function makeNumericInput(options) {
return makeInput(options);
}
function makeDateInput(options) {
options.type = 'date';
return makeInput(options)
}
function getPostUrl(id, parameters) {
return uri.formatClientLink(
'post', id,
@ -392,6 +397,7 @@ function getTemplate(templatePath) {
makePasswordInput: makePasswordInput,
makeEmailInput: makeEmailInput,
makeColorInput: makeColorInput,
makeDateInput: makeDateInput,
makePostLink: makePostLink,
makeTagLink: makeTagLink,
makeUserLink: makeUserLink,

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

View file

@ -3,6 +3,7 @@
const events = require('../events.js');
const views = require('../util/views.js');
const UserDeleteView = require('./user_delete_view.js');
const UserTokensView = require('./user_tokens_view.js');
const UserSummaryView = require('./user_summary_view.js');
const UserEditView = require('./user_edit_view.js');
const EmptyView = require('../views/empty_view.js');
@ -45,7 +46,17 @@ class UserView extends events.EventTarget {
this._view = new UserEditView(ctx);
events.proxyEvent(this._view, this, 'submit');
}
} else if (ctx.section == 'list-tokens') {
if (!this._ctx.canListTokens) {
this._view = new EmptyView();
this._view.showError(
'You don\'t have privileges to view user tokens.');
} else {
this._view = new UserTokensView(ctx);
events.proxyEvent(this._view, this, 'delete', 'delete-token');
events.proxyEvent(this._view, this, 'submit', 'create-token');
events.proxyEvent(this._view, this, 'update', 'update-token');
}
} else if (ctx.section == 'delete') {
if (!this._ctx.canDelete) {
this._view = new EmptyView();

View file

@ -91,6 +91,15 @@ privileges:
'users:delete:any': administrator
'users:delete:self': regular
'user_tokens:list:any': administrator
'user_tokens:list:self': regular
'user_tokens:create:any': administrator
'user_tokens:create:self': regular
'user_tokens:edit:any': administrator
'user_tokens:edit:self': regular
'user_tokens:delete:any': administrator
'user_tokens:delete:self': regular
'posts:create:anonymous': regular
'posts:create:identified': regular
'posts:list': anonymous

View file

@ -12,3 +12,5 @@ elasticsearch>=5.0.0
elasticsearch-dsl>=5.0.0
scikit-image>=0.12
pynacl>=1.2.1
pytz>=2018.3
pyRFC3339>=1.0

View file

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

View file

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

View file

@ -1,10 +1,12 @@
from typing import Tuple
from typing import Tuple, Optional
import hashlib
import random
import uuid
from collections import OrderedDict
from datetime import datetime
from nacl import pwhash
from nacl.exceptions import InvalidkeyError
from szurubooru import config, model, errors, db
from szurubooru import config, db, model, errors
from szurubooru.func import util
@ -26,7 +28,8 @@ def get_password_hash(salt: str, password: str) -> Tuple[str, int]:
).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. '''
digest = hashlib.sha256()
digest.update(config.config['secret'].encode('utf8'))
@ -78,6 +81,21 @@ def is_valid_password(user: model.User, password: str) -> bool:
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:
assert user
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(user.password_salt.encode('utf8'))
return digest.hexdigest()
def generate_authorization_token() -> str:
return uuid.uuid4().__str__()

View 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()

View file

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

View file

@ -160,6 +160,12 @@ def value_exceeds_column_size(value: Optional[str], column: Any) -> bool:
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:
for i in range(0, len(source_list), part_size):
yield source_list[i:i + part_size]

View file

@ -1,11 +1,11 @@
import base64
from typing import Optional
from szurubooru import db, model, errors, rest
from szurubooru.func import auth, users
from typing import Optional, Tuple
from szurubooru import model, errors, rest
from szurubooru.func import auth, users, user_tokens
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. '''
user = users.get_user_by_name(username)
if not auth.is_valid_password(user, password):
@ -13,34 +13,61 @@ def _authenticate(username: str, password: str) -> model.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'):
return None
auth_token = None
try:
auth_type, credentials = ctx.get_header('Authorization').split(' ', 1)
if auth_type.lower() != 'basic':
raise HttpBadRequest(
'ValidationError',
'Only basic HTTP authentication is supported.')
if auth_type.lower() == 'basic':
username, password = base64.decodebytes(
credentials.encode('ascii')).decode('utf8').split(':', 1)
return _authenticate(username, password)
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(
'ValidationError',
'Only basic or token HTTP authentication is supported.')
except ValueError as err:
msg = (
'Basic authentication header value are not properly formed. '
'Authorization header values are not properly formed. '
'Supplied header {0}. Got error: {1}')
raise HttpBadRequest(
'ValidationError',
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:
''' 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:
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)

View file

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

View file

@ -1,5 +1,5 @@
from szurubooru.model.base import Base
from szurubooru.model.user import User
from szurubooru.model.user import User, UserToken
from szurubooru.model.tag_category import TagCategory
from szurubooru.model.tag import Tag, TagName, TagSuggestion, TagImplication
from szurubooru.model.post import (

View file

@ -86,3 +86,25 @@ class User(Base):
'version_id_col': version,
'version_id_generator': False,
}
class UserToken(Base):
__tablename__ = 'user_token'
user_token_id = sa.Column('id', sa.Integer, primary_key=True)
user_id = sa.Column(
'user_id',
sa.Integer,
sa.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
index=True)
token = sa.Column('token', sa.Unicode(36), nullable=False)
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')

View file

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

View file

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

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

View file

@ -0,0 +1,31 @@
from unittest.mock import patch
import pytest
from szurubooru import api
from szurubooru.func import user_tokens, users
@pytest.fixture(autouse=True)
def inject_config(config_injector):
config_injector({'privileges': {'user_tokens:list:self': 'regular'}})
def test_retrieving_user_tokens(
user_token_factory, context_factory, fake_datetime):
user_token1 = user_token_factory()
user_token2 = user_token_factory(user=user_token1.user)
user_token3 = user_token_factory(user=user_token1.user)
with patch('szurubooru.func.user_tokens.get_user_tokens'), \
patch('szurubooru.func.user_tokens.serialize_user_token'), \
patch('szurubooru.func.users.get_user_by_name'), \
fake_datetime('1969-02-12'):
users.get_user_by_name.return_value = user_token1.user
user_tokens.serialize_user_token.return_value = 'serialized user token'
user_tokens.get_user_tokens.return_value = [user_token1, user_token2,
user_token3]
result = api.user_token_api.get_user_tokens(
context_factory(user=user_token1.user),
{
'user_name': user_token1.user.name
})
assert result == {'results': ['serialized user token'] * 3}
user_tokens.get_user_tokens.assert_called_once_with(user_token1.user)

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

View file

@ -93,11 +93,11 @@ def session(query_logger): # pylint: disable=unused-argument
@pytest.fixture
def context_factory(session):
def factory(params=None, files=None, user=None):
def factory(params=None, files=None, user=None, headers=None):
ctx = rest.Context(
method=None,
url=None,
headers={},
headers=headers or {},
params=params or {},
files=files or {})
ctx.session = session
@ -133,6 +133,27 @@ def user_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
def tag_category_factory():
def factory(name=None, color='dummy', default=False):

View file

@ -1,5 +1,6 @@
from szurubooru.func import auth
from datetime import datetime, timedelta
import pytest
from szurubooru.func import auth
@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 user.password_hash != hash
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()

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

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

View file

@ -0,0 +1,14 @@
from datetime import datetime
from szurubooru import db
def test_saving_user_token(user_token_factory):
user_token = user_token_factory()
db.session.add(user_token)
db.session.flush()
db.session.refresh(user_token)
assert not db.session.dirty
assert user_token.user is not None
assert user_token.token == 'dummy'
assert user_token.enabled is True
assert user_token.creation_time == datetime(1997, 1, 1)