User Token Authentication

* Users are only authenticated against their password on login, and to retrieve a token.
* Passwords are wiped from the app and cookies after login and token retrieval
* Tokens are revoked at the end of the session/logout
* If the user chooses the "remember me" option, the token is stored in the cookie
* A user interface to revoke tokens will be added
* Tokens correctly delete themselves on logout
* API documentation updated for the new user-token endpoints
* Added a Manage tokens tab to the user panel
* Added bullet point about the token authentication for the API
* Added tests for new endpoints and tests against authentication middleware
This commit is contained in:
ReAnzu 2018-02-25 04:44:02 -06:00
parent a98ca55391
commit 483c32cfbf
32 changed files with 1086 additions and 32 deletions

3
.gitignore vendored
View file

@ -2,3 +2,6 @@ config.yaml
*/*_modules/ */*_modules/
.coverage .coverage
.cache .cache
__pycache__
.idea/
*.iml

160
API.md
View file

@ -7,6 +7,7 @@
1. [General rules](#general-rules) 1. [General rules](#general-rules)
- [Authentication](#authentication) - [Authentication](#authentication)
- [User token authentication](#user-token-authentication)
- [Basic requests](#basic-requests) - [Basic requests](#basic-requests)
- [File uploads](#file-uploads) - [File uploads](#file-uploads)
- [Error handling](#error-handling) - [Error handling](#error-handling)
@ -56,6 +57,11 @@
- [Updating user](#updating-user) - [Updating user](#updating-user)
- [Getting user](#getting-user) - [Getting user](#getting-user)
- [Deleting user](#deleting-user) - [Deleting user](#deleting-user)
- User Tokens
- [Listing tokens](#listing-tokens)
- [Creating token](#creating-token)
- [Updating token](#updating-token)
- [Deleting token](#deleting-token)
- Password reset - Password reset
- [Password reset - step 1: mail request](#password-reset---step-2-confirmation) - [Password reset - step 1: mail request](#password-reset---step-2-confirmation)
- [Password reset - step 2: confirmation](#password-reset---step-2-confirmation) - [Password reset - step 2: confirmation](#password-reset---step-2-confirmation)
@ -70,6 +76,7 @@
- [User](#user) - [User](#user)
- [Micro user](#micro-user) - [Micro user](#micro-user)
- [User token](#user-token)
- [Tag category](#tag-category) - [Tag category](#tag-category)
- [Tag](#tag) - [Tag](#tag)
- [Micro tag](#micro-tag) - [Micro tag](#micro-tag)
@ -91,16 +98,35 @@
## Authentication ## Authentication
Authentication is achieved by means of [basic HTTP Authentication is achieved by means of [basic HTTP
auth](https://en.wikipedia.org/wiki/Basic_access_authentication). For this auth](https://en.wikipedia.org/wiki/Basic_access_authentication) or through the
reason, it is recommended to connect through HTTPS. There are no sessions, so use of [user token authentication](#user-token-authentication). For this reason,
every privileged request must be authenticated. Available privileges depend on it is recommended to connect through HTTPS. There are no sessions, so every
the user's rank. The way how rank translates to privileges is defined in the privileged request must be authenticated. Available privileges depend on the
server's configuration. user's rank. The way how rank translates to privileges is defined in the server's
configuration.
It is recommended to add `?bump-login` GET parameter to the first request in a It is recommended to add `?bump-login` GET parameter to the first request in a
client "session" (where the definition of a session is up to the client), so client "session" (where the definition of a session is up to the client), so
that the user's last login time is kept up to date. that the user's last login time is kept up to date.
## User token authentication
User token authentication works similarly to [basic HTTP
auth](https://en.wikipedia.org/wiki/Basic_access_authentication). Because it
operates similarly to ***basic HTTP auth*** it is still recommended to connect
through HTTPS. The authorization header uses the type of Token and the username
and token are encoded as Base64 and sent as the second parameter.
Example header for user1:token-is-more-secure
```
Authorization: Token dXNlcjE6dG9rZW4taXMtbW9yZS1zZWN1cmU=
```
The benefit of token authentication is that beyond the initial login to acquire
the first token, there is no need to transmit the user password in plaintext via
basic auth. Additionally tokens can be revoked at anytime allowing a cleaner
interface for isolating clients from user credentials.
## Basic requests ## Basic requests
Every request must use `Content-Type: application/json` and `Accept: Every request must use `Content-Type: application/json` and `Accept:
@ -1469,6 +1495,104 @@ data.
Deletes existing user. Deletes existing user.
## Listing tokens
- **Request**
`GET /user-tokens/`
- **Output**
An [unpaged search result resource](#unpaged-search-result), for which
`<resource>` is a [user token resource](#user-token).
- **Errors**
- privileges are too low
- **Description**
Searches for users tokens for the currently logged in user.
## Creating token
- **Request**
`POST /user-token`
- **Input**
```json5
{}
```
- **Output**
A [user token resource](#user-token).
- **Errors**
- privileges are too low
- **Description**
Creates a new user token that can be used for authentication of api
endpoints instead of a password.
## Updating user
- **Request**
`PUT /user-token/<token>`
- **Input**
```json5
{
"version": <version>,
"enabled": <enabled>, // optional
}
```
- **Output**
A [user token resource](#user-token).
- **Errors**
- the version is outdated
- the user token does not exist
- privileges are too low
- **Description**
Updates an existing user token using specified parameters. All fields
except the [`version`](#versioning) are optional - update concerns only
provided fields.
## Deleting token
- **Request**
`DELETE /user-token/<token>`
- **Input**
```json5
{}
```
- **Output**
```json5
{}
```
- **Errors**
- the token does not exist
- privileges are too low
- **Description**
Deletes existing user token.
## Password reset - step 1: mail request ## Password reset - step 1: mail request
- **Request** - **Request**
@ -1701,6 +1825,32 @@ A single user.
A [user resource](#user) stripped down to `name` and `avatarUrl` fields. A [user resource](#user) stripped down to `name` and `avatarUrl` fields.
## User token
**Description**
A single user token.
**Structure**
```json5
{
"user": <user>,
"token": <token>,
"enabled": <enabled>,
"version": <version>,
"creationTime": <creation-time>,
"lastEditTime": <last-edit-time>,
}
```
**Field meaning**
- `<user>`: micro user. See [micro user](#micro-user).
- `<token>`: the token that can be used to authenticate the user.
- `<enabled>`: whether the token is still valid for authentication.
- `<version>`: resource version. See [versioning](#versioning).
- `<creation-time>`: time the user token was created , formatted as per RFC 3339.
- `<last-edit-time>`: time the user token was edited, formatted as per RFC 3339.
## Tag category ## Tag category
**Description** **Description**

View file

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

View file

@ -1,6 +1,6 @@
#user #user
width: 100% width: 100%
max-width: 35em max-width: 45em
nav.text-nav nav.text-nav
margin-bottom: 1.5em margin-bottom: 1.5em
@ -37,6 +37,24 @@
height: 1px height: 1px
clear: both clear: both
#user-tokens
.token-flex-container
width: 100%
display: flex;
flex-direction column;
padding-bottom: 0.5em;
.token-flex-row
display: flex;
flex-direction: row;
justify-content: space-between;
padding-top: 0.25em;
padding-bottom: 0.25em;
border-bottom: black solid 1px;
form
width: auto;
#user-delete form #user-delete form
width: 100% width: 100%

View file

@ -6,6 +6,9 @@
--><% if (ctx.canEditAnything) { %><!-- --><% if (ctx.canEditAnything) { %><!--
--><li data-name='edit'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'edit') %>'>Account settings</a></li><!-- --><li data-name='edit'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'edit') %>'>Account settings</a></li><!--
--><% } %><!-- --><% } %><!--
--><% if (ctx.canListTokens) { %><!--
--><li data-name='list-tokens'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'list-tokens') %>'>Manage tokens</a></li><!--
--><% } %><!--
--><% if (ctx.canDelete) { %><!-- --><% if (ctx.canDelete) { %><!--
--><li data-name='delete'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'delete') %>'>Account deletion</a></li><!-- --><li data-name='delete'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'delete') %>'>Account deletion</a></li><!--
--><% } %><!-- --><% } %><!--

View file

@ -0,0 +1,29 @@
<div id='user-tokens'>
<div class='messages'></div>
<% if (ctx.tokens.length > 0) { %>
<div class="token-flex-container">
<div class="token-flex-row">
<div>Token</div>
<div>Actions</div>
</div>
<% _.each(ctx.tokens, function(token, index) { %>
<div class="token-flex-row">
<div><%= token.token %></div>
<div>
<form id='token<%= index %>'>
<input type='hidden' name='token' value='<%= token.token %>'/>
<input type='submit' value='Delete token'/>
</form>
</div>
</div>
<% }); %>
</div>
<% } else { %>
<h2>No Registered Tokens</h2>
<% } %>
<form id='create-token-form'>
<div class='buttons'>
<input type='submit' value='Create token'/>
</div>
</form>
</div>

View file

@ -15,6 +15,7 @@ class Api extends events.EventTarget {
this.user = null; this.user = null;
this.userName = null; this.userName = null;
this.userPassword = null; this.userPassword = null;
this.userToken = null;
this.cache = {}; this.cache = {};
this.allRanks = [ this.allRanks = [
'anonymous', 'anonymous',
@ -87,11 +88,69 @@ class Api extends events.EventTarget {
loginFromCookies() { loginFromCookies() {
const auth = cookies.getJSON('auth'); const auth = cookies.getJSON('auth');
return auth && auth.user && auth.password ? return auth && auth.user && auth.token ?
this.login(auth.user, auth.password, true) : this.login_with_token(auth.user, auth.token, true) :
Promise.resolve(); Promise.resolve();
} }
login_with_token(userName, token, doRemember) {
this.cache = {};
return new Promise((resolve, reject) => {
this.userName = userName;
this.userToken = token;
this.get('/user/' + userName + '?bump-login=true')
.then(response => {
const options = {};
if (doRemember) {
options.expires = 365;
}
cookies.set(
'auth',
{'user': userName, 'token': token},
options);
this.user = response;
resolve();
this.dispatchEvent(new CustomEvent('login'));
}, error => {
reject(error);
this.logout();
});
});
}
create_token(userName, options) {
return new Promise((resolve, reject) => {
this.post('/user-token/' + userName, {})
.then(response => {
cookies.set(
'auth',
{'user': userName, 'token': response.token},
options);
this.userName = userName;
this.userToken = response.token;
this.userPassword = null;
}, error => {
reject(error);
});
});
}
delete_token(userName, userToken) {
return new Promise((resolve, reject) => {
this.delete('/user-token/' + userName + '/' + userToken, {})
.then(response => {
const options = {};
cookies.set(
'auth',
{'user': userName, 'token': null},
options);
resolve();
}, error => {
reject(error);
});
});
}
login(userName, userPassword, doRemember) { login(userName, userPassword, doRemember) {
this.cache = {}; this.cache = {};
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -103,10 +162,7 @@ class Api extends events.EventTarget {
if (doRemember) { if (doRemember) {
options.expires = 365; options.expires = 365;
} }
cookies.set( this.create_token(this.userName, options);
'auth',
{'user': userName, 'password': userPassword},
options);
this.user = response; this.user = response;
resolve(); resolve();
this.dispatchEvent(new CustomEvent('login')); this.dispatchEvent(new CustomEvent('login'));
@ -118,9 +174,20 @@ class Api extends events.EventTarget {
} }
logout() { logout() {
let self = this;
this.delete_token(this.userName, this.userToken)
.then(response => {
self._logout();
}, error => {
self._logout();
});
}
_logout() {
this.user = null; this.user = null;
this.userName = null; this.userName = null;
this.userPassword = null; this.userPassword = null;
this.userToken = null;
this.dispatchEvent(new CustomEvent('logout')); this.dispatchEvent(new CustomEvent('logout'));
} }
@ -258,7 +325,11 @@ class Api extends events.EventTarget {
} }
try { try {
if (this.userName && this.userPassword) { if (this.userName && this.userToken) {
req.auth = null;
req.set('Authorization', 'Token ' + new Buffer(this.userName + ":" + this.userToken).toString('base64'))
}
else if (this.userName && this.userPassword) {
req.auth( req.auth(
this.userName, this.userName,
encodeURIComponent(this.userPassword) encodeURIComponent(this.userPassword)

View file

@ -7,6 +7,7 @@ const misc = require('../util/misc.js');
const config = require('../config.js'); const config = require('../config.js');
const views = require('../util/views.js'); const views = require('../util/views.js');
const User = require('../models/user.js'); const User = require('../models/user.js');
const UserToken = require('../models/user_token.js');
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require('../models/top_navigation.js');
const UserView = require('../views/user_view.js'); const UserView = require('../views/user_view.js');
const EmptyView = require('../views/empty_view.js'); const EmptyView = require('../views/empty_view.js');
@ -21,8 +22,11 @@ class UserController {
return; return;
} }
this._successMessages = [];
this._errorMessages = [];
topNavigation.setTitle('User ' + userName); topNavigation.setTitle('User ' + userName);
User.get(userName).then(user => { User.get(userName).then(async user => {
const isLoggedIn = api.isLoggedIn(user); const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any'; const infix = isLoggedIn ? 'self' : 'any';
@ -48,6 +52,17 @@ class UserController {
} else { } else {
topNavigation.activate('users'); topNavigation.activate('users');
} }
let userTokens = [];
if (section === 'list-tokens') {
userTokens = await UserToken.get(userName)
.then(response => {
return response;
}, error => {
return [];
});
}
this._view = new UserView({ this._view = new UserView({
user: user, user: user,
section: section, section: section,
@ -58,18 +73,50 @@ class UserController {
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`), canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`), canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`),
canEditAnything: api.hasPrivilege(`users:edit:${infix}`), canEditAnything: api.hasPrivilege(`users:edit:${infix}`),
canListTokens: api.hasPrivilege(`userTokens:list:${infix}`),
canCreateToken: api.hasPrivilege(`userTokens:create:${infix}`),
canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`),
canDeleteToken: api.hasPrivilege(`userTokens:delete:${infix}`),
canDelete: api.hasPrivilege(`users:delete:${infix}`), canDelete: api.hasPrivilege(`users:delete:${infix}`),
ranks: ranks, ranks: ranks,
tokens: userTokens,
}); });
this._view.addEventListener('change', e => this._evtChange(e)); this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtUpdate(e)); this._view.addEventListener('submit', e => this._evtUpdate(e));
this._view.addEventListener('delete', e => this._evtDelete(e)); this._view.addEventListener('delete', e => this._evtDelete(e));
this._view.addEventListener('create-token', e => this._evtCreateToken(e));
this._view.addEventListener('delete-token', e => this._evtDeleteToken(e));
for (let i = 0; i < this._successMessages.length; i++) {
this.showSuccess(this._successMessages[i]);
}
for (let i = 0; i < this._errorMessages.length; i++) {
this.showError(this._errorMessages[i]);
}
}, error => { }, error => {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError(error.message); this._view.showError(error.message);
}); });
} }
showSuccess(message) {
if (typeof this._view === 'undefined') {
this._successMessages.push(message)
} else {
this._view.showSuccess(message);
}
}
showError(message) {
if (typeof this._view === 'undefined') {
this._errorMessages.push(message)
} else {
this._view.showError(message);
}
}
_evtChange(e) { _evtChange(e) {
misc.enableExitConfirmation(); misc.enableExitConfirmation();
} }
@ -148,6 +195,32 @@ class UserController {
this._view.enableForm(); this._view.enableForm();
}); });
} }
_evtCreateToken(e) {
this._view.clearMessages();
this._view.disableForm();
UserToken.create(e.detail.user.name)
.then(response => {
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
ctx.controller.showSuccess('Token ' + response.token + ' created.');
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
_evtDeleteToken(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.userToken.delete(e.detail.user.name)
.then(() => {
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
ctx.controller.showSuccess('Token ' + e.detail.userToken.token + ' deleted.');
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
} }
module.exports = router => { module.exports = router => {
@ -157,6 +230,9 @@ module.exports = router => {
router.enter(['user', ':name', 'edit'], (ctx, next) => { router.enter(['user', ':name', 'edit'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'edit'); ctx.controller = new UserController(ctx, 'edit');
}); });
router.enter(['user', ':name', 'list-tokens'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'list-tokens');
});
router.enter(['user', ':name', 'delete'], (ctx, next) => { router.enter(['user', ':name', 'delete'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'delete'); ctx.controller = new UserController(ctx, 'delete');
}); });

View file

@ -0,0 +1,76 @@
'use strict';
const api = require('../api.js');
const uri = require('../util/uri.js');
const events = require('../events.js');
class UserToken extends events.EventTarget {
constructor() {
super();
this._orig = {};
this._updateFromResponse({});
}
get token() { return this._token; }
get enabled() { return this._enabled; }
get version() { return this._version; }
get creation_time() { return this._creation_time; }
static fromResponse(response) {
if (typeof response.results !== 'undefined') {
let token_list = [];
for (let i = 0; i < response.results.length; i++) {
const token = new UserToken();
token._updateFromResponse(response.results[i]);
token_list.push(token)
}
return token_list;
} else {
const ret = new UserToken();
ret._updateFromResponse(response);
return ret;
}
}
static get(userName) {
return api.get(uri.formatApiLink('user-tokens', userName))
.then(response => {
return Promise.resolve(UserToken.fromResponse(response));
});
}
static create(userName) {
return api.post(uri.formatApiLink('user-token', userName))
.then(response => {
return Promise.resolve(UserToken.fromResponse(response))
});
}
delete(userName) {
return api.delete(
uri.formatApiLink('user-token', userName, this._orig._token),
{version: this._version})
.then(response => {
this.dispatchEvent(new CustomEvent('delete', {
detail: {
userToken: this,
},
}));
return Promise.resolve();
});
}
_updateFromResponse(response) {
const map = {
_token: response.token,
_enabled: response.enabled,
_version: response.version,
_creation_time: response.creationTime,
};
Object.assign(this, map);
Object.assign(this._orig, map);
}
}
module.exports = UserToken;

View file

@ -0,0 +1,86 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const template = views.getTemplate('user-tokens');
class UserTokenView extends events.EventTarget {
constructor(ctx) {
super();
this._user = ctx.user;
this._tokens = ctx.tokens;
this._hostNode = ctx.hostNode;
this._tokenFormNodes = [];
views.replaceContent(this._hostNode, template(ctx));
views.decorateValidator(this._formNode);
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
this._decorateTokenForms()
}
_decorateTokenForms() {
for (let i = 0; i < this._tokens.length; i++) {
let formNode = this._hostNode.querySelector('#token' + i);
views.decorateValidator(formNode);
formNode.addEventListener('submit', e => this._evtDelete(e));
this._tokenFormNodes.push(formNode)
}
}
clearMessages() {
views.clearMessages(this._hostNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
enableForm() {
views.enableForm(this._formNode);
for (let i = 0; i < this._tokenFormNodes.length; i++) {
let formNode = this._tokenFormNodes[i];
views.enableForm(formNode);
}
}
disableForm() {
views.disableForm(this._formNode);
for (let i = 0; i < this._tokenFormNodes.length; i++) {
let formNode = this._tokenFormNodes[i];
views.disableForm(formNode);
}
}
_evtDelete(e) {
e.preventDefault();
const userToken = this._tokens[parseInt(e.target.id.replace('token', ''))];
this.dispatchEvent(new CustomEvent('delete', {
detail: {
user: this._user,
userToken: userToken,
},
}));
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
user: this._user
},
}));
}
get _formNode() {
return this._hostNode.querySelector('#create-token-form');
}
}
module.exports = UserTokenView;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,60 @@
from typing import Dict
from szurubooru import model, rest
from szurubooru.func import auth, users, user_tokens, serialization, versions
def _serialize(
ctx: rest.Context, user_token: model.UserToken) -> rest.Response:
return user_tokens.serialize_user_token(
user_token,
ctx.user,
options=serialization.get_serialization_options(ctx))
@rest.routes.get('/user-tokens/(?P<user_name>[^/]+)/?')
def get_user_tokens(ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
user = users.get_user_by_name(params['user_name'])
infix = 'self' if ctx.user.user_id == user.user_id else 'any'
auth.verify_privilege(ctx.user, 'user_tokens:list:%s' % infix)
user_token_list = user_tokens.get_user_tokens(user)
return {
"results": [_serialize(ctx, token) for token in user_token_list]
}
@rest.routes.post('/user-token/(?P<user_name>[^/]+)/?')
def create_user_token(ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
user = users.get_user_by_name(params['user_name'])
infix = 'self' if ctx.user.user_id == user.user_id else 'any'
auth.verify_privilege(ctx.user, 'user_tokens:create:%s' % infix)
user_token = user_tokens.create_user_token(user)
return _serialize(ctx, user_token)
@rest.routes.put('/user-token/(?P<user_name>[^/]+)/(?P<user_token>[^/]+)/?')
def update_user_token(ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
user = users.get_user_by_name(params['user_name'])
infix = 'self' if ctx.user.user_id == user.user_id else 'any'
auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix)
user_token = user_tokens.get_user_token_by_user_and_token(user, params['user_token'])
versions.verify_version(user_token, ctx)
versions.bump_version(user_token)
if ctx.has_param('enabled'):
auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix)
user_tokens.update_user_token_enabled(user_token, ctx.get_param_as_bool('enabled'))
user_tokens.update_user_token_edit_time(user_token)
ctx.session.commit()
return _serialize(ctx, user_token)
@rest.routes.delete('/user-token/(?P<user_name>[^/]+)/(?P<user_token>[^/]+)/?')
def delete_user_token(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
user = users.get_user_by_name(params['user_name'])
infix = 'self' if ctx.user.user_id == user.user_id else 'any'
auth.verify_privilege(ctx.user, 'user_tokens:delete:%s' % infix)
user_token = user_tokens.get_user_token_by_user_and_token(user, params['user_token'])
if user_token is not None:
ctx.session.delete(user_token)
ctx.session.commit()
return {}

View file

@ -6,6 +6,7 @@ from nacl import pwhash
from nacl.exceptions import InvalidkeyError from nacl.exceptions import InvalidkeyError
from szurubooru import config, model, errors, db from szurubooru import config, model, errors, db
from szurubooru.func import util from szurubooru.func import util
import uuid
RANK_MAP = OrderedDict([ RANK_MAP = OrderedDict([
@ -78,6 +79,10 @@ def is_valid_password(user: model.User, password: str) -> bool:
return False return False
def is_valid_token(user_token: model.UserToken) -> bool:
return user_token is not None and user_token.enabled
def has_privilege(user: model.User, privilege_name: str) -> bool: def has_privilege(user: model.User, privilege_name: str) -> bool:
assert user assert user
all_ranks = list(RANK_MAP.keys()) all_ranks = list(RANK_MAP.keys())
@ -102,3 +107,7 @@ def generate_authentication_token(user: model.User) -> str:
digest.update(config.config['secret'].encode('utf8')) digest.update(config.config['secret'].encode('utf8'))
digest.update(user.password_salt.encode('utf8')) digest.update(user.password_salt.encode('utf8'))
return digest.hexdigest() return digest.hexdigest()
def generate_authorization_token() -> str:
return uuid.uuid4().__str__()

View file

@ -0,0 +1,86 @@
from datetime import datetime
from typing import Any, Optional, List, Dict, Callable
from szurubooru import db, model, rest
from szurubooru.func import auth, serialization, users
class UserTokenSerializer(serialization.BaseSerializer):
def __init__(
self,
user_token: model.UserToken,
auth_user: model.User) -> None:
self.user_token = user_token
self.auth_user = auth_user
def _serializers(self) -> Dict[str, Callable[[], Any]]:
return {
'user': self.serialize_user,
'token': self.serialize_token,
'enabled': self.serialize_enabled,
'version': self.serialize_version,
'creationTime': self.serialize_creation_time,
'lastEditTime': self.serialize_last_edit_time,
}
def serialize_user(self) -> Any:
return users.serialize_micro_user(self.user_token.user, self.auth_user)
def serialize_creation_time(self) -> Any:
return self.user_token.creation_time
def serialize_last_edit_time(self) -> Any:
return self.user_token.last_edit_time
def serialize_token(self) -> Any:
return self.user_token.token
def serialize_enabled(self) -> Any:
return self.user_token.enabled
def serialize_version(self) -> Any:
return self.user_token.version
def serialize_user_token(
user_token: Optional[model.UserToken],
auth_user: model.User,
options: List[str] = []) -> Optional[rest.Response]:
if not user_token:
return None
return UserTokenSerializer(user_token, auth_user).serialize(options)
def get_user_token_by_user_and_token(user: model.User, token: str) -> model.UserToken:
return (db.session.query(model.UserToken)
.filter(model.UserToken.user_id == user.user_id, model.UserToken.token == token)
.one_or_none())
def get_user_tokens(user: model.User) -> List[model.UserToken]:
assert user
return (db.session.query(model.UserToken)
.filter(model.UserToken.user_id == user.user_id)
.all())
def create_user_token(user: model.User) -> model.UserToken:
assert user
user_token = model.UserToken()
user_token.user = user
user_token.token = auth.generate_authorization_token()
user_token.enabled = True
user_token.creation_time = datetime.utcnow()
db.session.add(user_token)
db.session.commit()
return user_token
def update_user_token_enabled(user_token: model.UserToken, enabled: bool) -> None:
assert user_token
user_token.enabled = enabled if enabled is not None else True
def update_user_token_edit_time(user_token: model.UserToken) -> None:
assert user_token
user_token.last_edit_time = datetime.utcnow()

View file

@ -1,7 +1,9 @@
import re
from typing import Any, Optional, Union, List, Dict, Callable
from datetime import datetime from 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

View file

@ -1,7 +1,7 @@
import base64 import base64
from typing import Optional from typing import Optional
from szurubooru import db, model, errors, rest from szurubooru import db, model, errors, rest
from szurubooru.func import auth, users from szurubooru.func import auth, users, user_tokens
from szurubooru.rest.errors import HttpBadRequest from szurubooru.rest.errors import HttpBadRequest
@ -13,19 +13,33 @@ def _authenticate(username: str, password: str) -> model.User:
return user return user
def _authenticate_token(username: str, token: str) -> model.User:
"""Try to authenticate user. Throw AuthError for invalid users."""
user = users.get_user_by_name(username)
user_token = user_tokens.get_user_token_by_user_and_token(user, token)
if not auth.is_valid_token(user_token):
raise errors.AuthError('Invalid token.')
return user
def _get_user(ctx: rest.Context) -> Optional[model.User]: def _get_user(ctx: rest.Context) -> Optional[model.User]:
if not ctx.has_header('Authorization'): if not ctx.has_header('Authorization'):
return None return None
try: try:
auth_type, credentials = ctx.get_header('Authorization').split(' ', 1) auth_type, credentials = ctx.get_header('Authorization').split(' ', 1)
if auth_type.lower() != 'basic': if auth_type.lower() == 'basic':
raise HttpBadRequest(
'ValidationError',
'Only basic HTTP authentication is supported.')
username, password = base64.decodebytes( username, password = base64.decodebytes(
credentials.encode('ascii')).decode('utf8').split(':', 1) credentials.encode('ascii')).decode('utf8').split(':', 1)
return _authenticate(username, password) return _authenticate(username, password)
elif auth_type.lower() == 'token':
username, token = base64.decodebytes(
credentials.encode('ascii')).decode('utf8').split(':', 1)
return _authenticate_token(username, token)
else:
raise HttpBadRequest(
'ValidationError',
'Only basic HTTP authentication is supported.')
except ValueError as err: except ValueError as err:
msg = ( msg = (
'Basic authentication header value are not properly formed. ' 'Basic authentication header value are not properly formed. '
@ -35,7 +49,6 @@ def _get_user(ctx: rest.Context) -> Optional[model.User]:
msg.format(ctx.get_header('Authorization'), str(err))) msg.format(ctx.get_header('Authorization'), str(err)))
@rest.middleware.pre_hook
def process_request(ctx: rest.Context) -> None: def process_request(ctx: rest.Context) -> None:
''' Bind the user to request. Update last login time if needed. ''' ''' Bind the user to request. Update last login time if needed. '''
auth_user = _get_user(ctx) auth_user = _get_user(ctx)
@ -44,3 +57,8 @@ def process_request(ctx: rest.Context) -> None:
if ctx.get_param_as_bool('bump-login', default=False) and ctx.user.user_id: if ctx.get_param_as_bool('bump-login', default=False) and ctx.user.user_id:
users.bump_user_login_time(ctx.user) users.bump_user_login_time(ctx.user)
ctx.session.commit() ctx.session.commit()
@rest.middleware.pre_hook
def process_request_hook(ctx: rest.Context) -> None:
process_request(ctx)

View file

@ -0,0 +1,35 @@
'''
Added a user_token table for API authorization
Revision ID: a39c7f98a7fa
Created at: 2018-02-25 01:31:27.345595
'''
import sqlalchemy as sa
from alembic import op
revision = 'a39c7f98a7fa'
down_revision = '9ef1a1643c2a'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('user_token',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token', sa.Unicode(length=36), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('creation_time', sa.DateTime(), nullable=False),
sa.Column('last_edit_time', sa.DateTime(), nullable=True),
sa.Column('version', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_token_user_id'), 'user_token', ['user_id'], unique=False)
def downgrade():
op.drop_index(op.f('ix_user_token_user_id'), table_name='user_token')
op.drop_table('user_token')

View file

@ -1,5 +1,7 @@
from szurubooru.model.base import Base from szurubooru.model.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 (

View file

@ -86,3 +86,22 @@ class User(Base):
'version_id_col': version, 'version_id_col': version,
'version_id_generator': False, 'version_id_generator': False,
} }
class UserToken(Base):
__tablename__ = 'user_token'
user_token_id = sa.Column('id', sa.Integer, primary_key=True)
user_id = sa.Column(
'user_id',
sa.Integer,
sa.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
index=True)
token = sa.Column('token', sa.Unicode(36), nullable=False)
enabled = sa.Column('enabled', sa.Boolean, nullable=False, default=True)
creation_time = sa.Column('creation_time', sa.DateTime, nullable=False)
last_edit_time = sa.Column('last_edit_time', sa.DateTime)
version = sa.Column('version', sa.Integer, default=1, nullable=False)
user = sa.orm.relationship('User')

View file

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

View file

@ -0,0 +1,29 @@
from unittest.mock import patch
import pytest
from szurubooru import api
from szurubooru.func import user_tokens, users
@pytest.fixture(autouse=True)
def inject_config(config_injector):
config_injector({'privileges': {'user_tokens:create:self': 'regular'}})
def test_creating_user_token(user_token_factory, context_factory, fake_datetime):
user_token = user_token_factory()
with patch('szurubooru.func.user_tokens.create_user_token'), \
patch('szurubooru.func.user_tokens.serialize_user_token'), \
patch('szurubooru.func.users.get_user_by_name'), \
fake_datetime('1969-02-12'):
users.get_user_by_name.return_value = user_token.user
user_tokens.serialize_user_token.return_value = 'serialized user token'
user_tokens.create_user_token.return_value = user_token
result = api.user_token_api.create_user_token(
context_factory(
user=user_token.user),
{'user_name': user_token.user.name})
assert result == 'serialized user token'
user_tokens.create_user_token.assert_called_once_with(
user_token.user)

View file

@ -0,0 +1,29 @@
from unittest.mock import patch
import pytest
from szurubooru import api, db
from szurubooru.func import user_tokens, users
@pytest.fixture(autouse=True)
def inject_config(config_injector):
config_injector({'privileges': {'user_tokens:delete:self': 'regular'}})
def test_deleting_user_token(user_token_factory, context_factory, fake_datetime):
user_token = user_token_factory()
db.session.add(user_token)
db.session.commit()
with patch('szurubooru.func.user_tokens.get_user_token_by_user_and_token'), \
patch('szurubooru.func.users.get_user_by_name'), \
fake_datetime('1969-02-12'):
users.get_user_by_name.return_value = user_token.user
user_tokens.get_user_token_by_user_and_token.return_value = user_token
result = api.user_token_api.delete_user_token(
context_factory(
user=user_token.user),
{'user_name': user_token.user.name, 'user_token': user_token.token})
assert result == {}
user_tokens.get_user_token_by_user_and_token.assert_called_once_with(
user_token.user, user_token.token)

View file

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

View file

@ -0,0 +1,41 @@
from unittest.mock import patch
import pytest
from szurubooru import api, db
from szurubooru.func import user_tokens, users
@pytest.fixture(autouse=True)
def inject_config(config_injector):
config_injector({'privileges': {'user_tokens:edit:self': 'regular'}})
def test_edit_user_token(user_token_factory, context_factory, fake_datetime):
user_token = user_token_factory()
db.session.add(user_token)
db.session.commit()
with patch('szurubooru.func.user_tokens.get_user_token_by_user_and_token'), \
patch('szurubooru.func.user_tokens.update_user_token_enabled'), \
patch('szurubooru.func.user_tokens.update_user_token_edit_time'), \
patch('szurubooru.func.user_tokens.serialize_user_token'), \
patch('szurubooru.func.users.get_user_by_name'), \
fake_datetime('1969-02-12'):
users.get_user_by_name.return_value = user_token.user
user_tokens.serialize_user_token.return_value = 'serialized user token'
user_tokens.get_user_token_by_user_and_token.return_value = user_token
result = api.user_token_api.update_user_token(
context_factory(
params={
'version': user_token.version,
'enabled': False,
},
user=user_token.user),
{'user_name': user_token.user.name, 'user_token': user_token.token})
assert result == 'serialized user token'
user_tokens.get_user_token_by_user_and_token.assert_called_once_with(
user_token.user, user_token.token)
user_tokens.update_user_token_enabled.assert_called_once_with(
user_token, False)
user_tokens.update_user_token_edit_time.assert_called_once_with(
user_token)

View file

@ -93,11 +93,11 @@ def session(query_logger): # pylint: disable=unused-argument
@pytest.fixture @pytest.fixture
def context_factory(session): def context_factory(session):
def factory(params=None, files=None, user=None): def factory(params=None, files=None, user=None, headers=None):
ctx = rest.Context( ctx = rest.Context(
method=None, method=None,
url=None, url=None,
headers={}, headers=headers or {},
params=params or {}, params=params or {},
files=files or {}) files=files or {})
ctx.session = session ctx.session = session
@ -133,6 +133,21 @@ def user_factory():
return factory return factory
@pytest.fixture
def user_token_factory(user_factory):
def factory(user=None, token=None, enabled=None, creation_time=None):
if user is None:
user = user_factory()
db.session.add(user)
user_token = model.UserToken()
user_token.user = user
user_token.token = token or 'dummy'
user_token.enabled = enabled or True
user_token.creation_time = creation_time or datetime(1997, 1, 1)
return user_token
return factory
@pytest.fixture @pytest.fixture
def tag_category_factory(): def tag_category_factory():
def factory(name=None, color='dummy', default=False): def factory(name=None, color='dummy', default=False):

View file

@ -41,3 +41,13 @@ def test_is_valid_password_auto_upgrades_user_password_hash(user_factory):
assert result is True assert result is True
assert user.password_hash != hash assert user.password_hash != hash
assert user.password_revision > revision assert user.password_revision > revision
def test_is_valid_token(user_token_factory):
user_token = user_token_factory()
assert auth.is_valid_token(user_token)
def test_generate_authorization_token():
result = auth.generate_authorization_token()
assert result != auth.generate_authorization_token()

View file

@ -0,0 +1,72 @@
from datetime import datetime
from unittest.mock import patch
from szurubooru import db
from szurubooru.func import user_tokens, users, auth
def test_serialize_user_token(user_token_factory):
user_token = user_token_factory()
db.session.add(user_token)
db.session.flush()
with patch('szurubooru.func.users.get_avatar_url'):
users.get_avatar_url.return_value = 'https://example.com/avatar.png'
result = user_tokens.serialize_user_token(user_token, user_token.user)
assert result == {'creationTime': datetime(1997, 1, 1, 0, 0),
'enabled': True,
'lastEditTime': None,
'token': 'dummy',
'user': {
'avatarUrl': 'https://example.com/avatar.png',
'name': user_token.user.name},
'version': 1}
def test_serialize_user_token_none():
result = user_tokens.serialize_user_token(None, None)
assert result is None
def test_get_user_token_by_user_and_token(user_token_factory):
user_token = user_token_factory()
db.session.add(user_token)
db.session.flush()
db.session.commit()
result = user_tokens.get_user_token_by_user_and_token(user_token.user, user_token.token)
assert result == user_token
def test_get_user_tokens(user_token_factory):
user_token1 = user_token_factory()
user_token2 = user_token_factory(user=user_token1.user)
db.session.add(user_token1)
db.session.add(user_token2)
db.session.flush()
db.session.commit()
result = user_tokens.get_user_tokens(user_token1.user)
assert result == [user_token1, user_token2]
def test_create_user_token(user_factory):
user = user_factory()
db.session.add(user)
db.session.flush()
db.session.commit()
with patch('szurubooru.func.auth.generate_authorization_token'):
auth.generate_authorization_token.return_value = 'test'
result = user_tokens.create_user_token(user)
assert result.token == 'test'
assert result.user == user
def test_update_user_token_enabled(user_token_factory):
user_token = user_token_factory()
user_tokens.update_user_token_enabled(user_token, False)
assert user_token.enabled is False
def test_update_user_token_edit_time(user_token_factory):
user_token = user_token_factory()
assert user_token.last_edit_time is None
user_tokens.update_user_token_edit_time(user_token)
assert user_token.last_edit_time is not None

View file

@ -0,0 +1,48 @@
from unittest.mock import patch
from szurubooru.func import auth, users, user_tokens
from szurubooru.middleware import authenticator
from szurubooru.rest import errors
import pytest
def test_process_request_no_header(context_factory):
ctx = context_factory()
authenticator.process_request(ctx)
assert ctx.user.name is None
def test_process_request_basic_auth_valid(context_factory, user_factory):
user = user_factory()
ctx = context_factory(headers={
'Authorization': "Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk"
})
with patch('szurubooru.func.auth.is_valid_password'), \
patch('szurubooru.func.users.get_user_by_name'):
users.get_user_by_name.return_value = user
auth.is_valid_password.return_value = True
authenticator.process_request(ctx)
assert ctx.user == user
def test_process_request_token_auth_valid(context_factory, user_token_factory):
user_token = user_token_factory()
ctx = context_factory(headers={
'Authorization': "Token dGVzdFVzZXI6dGVzdFRva2Vu"
})
with patch('szurubooru.func.auth.is_valid_token'), \
patch('szurubooru.func.users.get_user_by_name'), \
patch('szurubooru.func.user_tokens.get_user_token_by_user_and_token'):
users.get_user_by_name.return_value = user_token.user
user_tokens.get_user_token_by_user_and_token.return_value = user_token
auth.is_valid_password.return_value = True
authenticator.process_request(ctx)
assert ctx.user == user_token.user
def test_process_request_bad_header(context_factory):
ctx = context_factory(headers={
'Authorization': "Secret SuperSecretValue"
})
with pytest.raises(errors.HttpBadRequest):
authenticator.process_request(ctx)

View file

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