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
This commit is contained in:
ReAnzu 2018-02-25 04:44:02 -06:00
parent d6ee744777
commit a526a56767
14 changed files with 298 additions and 22 deletions

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,70 @@ 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();
});
});
}
get_token(userName, options) {
return new Promise((resolve, reject) => {
this.post('/user-tokens', {})
.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-tokens/' + userToken, {})
.then(response => {
const options = {};
cookies.set(
'auth',
{'user': userName, 'token': null},
options);
this.userName = userName;
this.userToken = null;
}, 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 +163,7 @@ class Api extends events.EventTarget {
if (doRemember) { if (doRemember) {
options.expires = 365; options.expires = 365;
} }
cookies.set( this.get_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 +175,11 @@ class Api extends events.EventTarget {
} }
logout() { logout() {
this.delete_token(this.userName, this.userToken);
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 +317,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

@ -86,6 +86,10 @@ privileges:
'users:delete:any': administrator 'users:delete:any': administrator
'users:delete:self': regular 'users:delete:self': regular
'user_token:list': regular
'user_token:create': regular
'user_token:delete': 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

@ -1,5 +1,5 @@
from typing import Any, Dict from typing import Any, Dict
from szurubooru import model, search, rest, config, errors from szurubooru import model, search, rest
from szurubooru.func import auth, users, serialization, versions from szurubooru.func import auth, users, serialization, versions

View file

@ -0,0 +1,38 @@
from typing import Dict
from szurubooru import model, rest
from szurubooru.func import auth, user_tokens, serialization
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/?')
def get_user_tokens(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
auth.verify_privilege(ctx.user, 'user_token:list')
user_token_list = user_tokens.get_user_tokens(ctx.user)
return {
"tokens": [_serialize(ctx, token) for token in user_token_list]
}
@rest.routes.post('/user-tokens/?')
def create_user_token(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
auth.verify_privilege(ctx.user, 'user_token:create')
user_token = user_tokens.create_user_token(ctx.user)
return _serialize(ctx, user_token)
@rest.routes.delete('/user-tokens/(?P<user_token>[^/]+)/?')
def create_user_token(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
user_token = user_tokens.get_user_token_by_user_and_token(ctx.user, params['user_token'])
if user_token is not None:
auth.verify_privilege(ctx.user, 'user_token:delete')
ctx.session.delete(user_token)
ctx.session.commit()
return {}

View file

@ -6,6 +6,7 @@ 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
from nacl.pwhash import argon2id, verify from nacl.pwhash import argon2id, verify
import uuid
RANK_MAP = OrderedDict([ RANK_MAP = OrderedDict([
@ -75,6 +76,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())
@ -99,3 +104,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,74 @@
from datetime import datetime
from typing import Any, Optional, List, Dict, Callable
import sqlalchemy as sa
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,
'creationTime': self.serialize_creation_time,
'lastLoginTime': 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_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(sa.func.lower(model.UserToken.user_id) == sa.func.lower(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

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. '

View file

@ -0,0 +1,34 @@
'''
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.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

@ -84,3 +84,21 @@ 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)
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

@ -1,8 +1,12 @@
import logging
from typing import Callable, Dict from typing import Callable, Dict
from collections import defaultdict from collections import defaultdict
from szurubooru.rest.context import Context, Response from szurubooru.rest.context import Context, Response
logger = logging.getLogger(__name__)
# pylint: disable=invalid-name # pylint: disable=invalid-name
RouteHandler = Callable[[Context, Dict[str, str]], Response] RouteHandler = Callable[[Context, Dict[str, str]], Response]
routes = defaultdict(dict) # type: Dict[str, Dict[str, RouteHandler]] routes = defaultdict(dict) # type: Dict[str, Dict[str, RouteHandler]]
@ -11,6 +15,9 @@ routes = defaultdict(dict) # type: Dict[str, Dict[str, RouteHandler]]
def get(url: str) -> Callable[[RouteHandler], RouteHandler]: def get(url: str) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler: def wrapper(handler: RouteHandler) -> RouteHandler:
routes[url]['GET'] = handler routes[url]['GET'] = handler
logger.info(
'Registered [GET] %s (user=%s, queries=%d)',
url)
return handler return handler
return wrapper return wrapper
@ -18,6 +25,9 @@ def get(url: str) -> Callable[[RouteHandler], RouteHandler]:
def put(url: str) -> Callable[[RouteHandler], RouteHandler]: def put(url: str) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler: def wrapper(handler: RouteHandler) -> RouteHandler:
routes[url]['PUT'] = handler routes[url]['PUT'] = handler
logger.info(
'Registered [PUT] %s (user=%s, queries=%d)',
url)
return handler return handler
return wrapper return wrapper
@ -25,6 +35,9 @@ def put(url: str) -> Callable[[RouteHandler], RouteHandler]:
def post(url: str) -> Callable[[RouteHandler], RouteHandler]: def post(url: str) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler: def wrapper(handler: RouteHandler) -> RouteHandler:
routes[url]['POST'] = handler routes[url]['POST'] = handler
logger.info(
'Registered [POST] %s (user=%s, queries=%d)',
url)
return handler return handler
return wrapper return wrapper
@ -32,5 +45,8 @@ def post(url: str) -> Callable[[RouteHandler], RouteHandler]:
def delete(url: str) -> Callable[[RouteHandler], RouteHandler]: def delete(url: str) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler: def wrapper(handler: RouteHandler) -> RouteHandler:
routes[url]['DELETE'] = handler routes[url]['DELETE'] = handler
logger.info(
'Registered [DELETE] %s (user=%s, queries=%d)',
url)
return handler return handler
return wrapper return wrapper