diff --git a/config.ini.dist b/config.ini.dist index 8e557c6..981ce05 100644 --- a/config.ini.dist +++ b/config.ini.dist @@ -34,8 +34,8 @@ tag_categories = meta, artist, character, copyright, other unique # don't change these regexes, unless you want to annoy people. but if you do # customize them, make sure to update the instructions in the registration form # template as well. -password_regex = ^.{5,}$ -user_name_regex = ^[a-zA-Z0-9_-]{1,32}$ +password_regex = "^.{5,}$" +user_name_regex = "^[a-zA-Z0-9_-]{1,32}$" [privileges] users:create = anonymous diff --git a/szurubooru/api/__init__.py b/szurubooru/api/__init__.py new file mode 100644 index 0000000..7075658 --- /dev/null +++ b/szurubooru/api/__init__.py @@ -0,0 +1,3 @@ +''' Falcon-compatible API facades. ''' + +from szurubooru.api.users import UserListApi, UserDetailApi diff --git a/szurubooru/api/users.py b/szurubooru/api/users.py new file mode 100644 index 0000000..f05d083 --- /dev/null +++ b/szurubooru/api/users.py @@ -0,0 +1,72 @@ +''' Users public API. ''' + +import re +import falcon + +def _serialize_user(user): + return { + 'id': user.user_id, + 'name': user.name, + 'email': user.email, # TODO: secure this + 'accessRank': user.access_rank, + 'creationTime': user.creation_time, + 'lastLoginTime': user.last_login_time, + 'avatarStyle': user.avatar_style + } + +class UserListApi(object): + ''' API for lists of users. ''' + def __init__(self, config, auth_service, user_service): + self._config = config + self._auth_service = auth_service + self._user_service = user_service + + def on_get(self, request, response): + ''' Retrieves a list of users. ''' + self._auth_service.verify_privilege(request.context['user'], 'users:list') + request.context['reuslt'] = {'message': 'Searching for users'} + + def on_post(self, request, response): + ''' Creates a new user. ''' + self._auth_service.verify_privilege(request.context['user'], 'users:create') + name_regex = self._config['service']['user_name_regex'] + password_regex = self._config['service']['password_regex'] + + try: + name = request.context['doc']['user'] + password = request.context['doc']['password'] + email = request.context['doc']['email'].strip() + if not email: + email = None + except KeyError as ex: + raise falcon.HTTPBadRequest( + 'Malformed data', 'Field %r not found' % ex.args[0]) + + if not re.match(name_regex, name): + raise falcon.HTTPBadRequest( + 'Malformed data', + 'Name must validate %r expression' % name_regex) + + if not re.match(password_regex, password): + raise falcon.HTTPBadRequest( + 'Malformed data', + 'Password must validate %r expression' % password_regex) + + user = self._user_service.create_user(name, password, email) + request.context['result'] = {'user': _serialize_user(user)} + +class UserDetailApi(object): + ''' API for individual users. ''' + def __init__(self, config, auth_service): + self._config = config + self._auth_service = auth_service + + def on_get(self, request, response, user_id): + ''' Retrieves an user. ''' + self._auth_service.verify_privilege(request.context['user'], 'users:view') + request.context['result'] = {'message': 'Getting user ' + user_id} + + def on_put(self, request, response, user_id): + ''' Updates an existing user. ''' + self._auth_service.verify_privilege(request.context['user'], 'users:edit') + request.context['result'] = {'message': 'Updating user ' + user_id} diff --git a/szurubooru/app.py b/szurubooru/app.py index c30b9cd..5b1c12b 100644 --- a/szurubooru/app.py +++ b/szurubooru/app.py @@ -1,14 +1,24 @@ +''' Exports create_app. ''' + import os import falcon import sqlalchemy import sqlalchemy.orm -import szurubooru.rest.users -from szurubooru.config import Config -from szurubooru.middleware import Authenticator, JsonTranslator, RequireJson -from szurubooru.services import AuthService, UserService +import szurubooru.api +import szurubooru.config +import szurubooru.db +import szurubooru.middleware +import szurubooru.services + +def _on_auth_error(ex, req, resp, params): + raise falcon.HTTPForbidden('Authentication error', str(ex)) + +def _on_integrity_error(ex, req, resp, params): + raise falcon.HTTPConflict('Integrity violation', ex.args[0]) def create_app(): - config = Config() + ''' Creates a WSGI compatible App object. ''' + config = szurubooru.config.Config() root_dir = os.path.dirname(__file__) static_dir = os.path.join(root_dir, os.pardir, 'static') @@ -20,20 +30,28 @@ def create_app(): host=config['database']['host'], port=config['database']['port'], name=config['database']['name'])) - session = sqlalchemy.orm.sessionmaker(bind=engine)() + session_factory = sqlalchemy.orm.sessionmaker(bind=engine) + transaction_manager = szurubooru.db.TransactionManager(session_factory) - user_service = UserService(session) - auth_service = AuthService(config, user_service) + # TODO: is there a better way? + password_service = szurubooru.services.PasswordService(config) + user_service = szurubooru.services.UserService( + config, transaction_manager, password_service) + auth_service = szurubooru.services.AuthService( + config, user_service, password_service) - user_list = szurubooru.rest.users.UserList(auth_service) - user = szurubooru.rest.users.User(auth_service) + user_list = szurubooru.api.UserListApi(config, auth_service, user_service) + user = szurubooru.api.UserDetailApi(config, auth_service) app = falcon.API(middleware=[ - RequireJson(), - JsonTranslator(), - Authenticator(auth_service), + szurubooru.middleware.RequireJson(), + szurubooru.middleware.JsonTranslator(), + szurubooru.middleware.Authenticator(auth_service), ]) + app.add_error_handler(szurubooru.services.AuthError, _on_auth_error) + app.add_error_handler(szurubooru.services.IntegrityError, _on_integrity_error) + app.add_route('/users/', user_list) app.add_route('/user/{user_id}', user) diff --git a/szurubooru/config.py b/szurubooru/config.py index 1710fa9..8295bca 100644 --- a/szurubooru/config.py +++ b/szurubooru/config.py @@ -1,7 +1,10 @@ +''' Exports Config. ''' + import os import configobj class Config(object): + ''' INI config parser and container. ''' def __init__(self): self.config = configobj.ConfigObj('config.ini.dist') if os.path.exists('config.ini'): diff --git a/szurubooru/db.py b/szurubooru/db.py new file mode 100644 index 0000000..9527485 --- /dev/null +++ b/szurubooru/db.py @@ -0,0 +1,36 @@ +''' Exports TransactionManager. ''' + +from contextlib import contextmanager + +class TransactionManager(object): + ''' Helper class for managing database transactions. ''' + + def __init__(self, session_factory): + self._session_factory = session_factory + + @contextmanager + def transaction(self): + ''' + Provides a transactional scope around a series of DB operations that + might change the database. + ''' + return self._open_transaction(lambda session: session.commit) + + @contextmanager + def read_only_transaction(self): + ''' + Provides a transactional scope around a series of read-only DB + operations. + ''' + return self._open_transaction(lambda session: session.rollback) + + def _open_transaction(self, session_finalizer): + session = self._session_factory() + try: + yield session + session_finalizer(session) + except: + session.rollback() + raise + finally: + session.close() diff --git a/szurubooru/middleware/__init__.py b/szurubooru/middleware/__init__.py index 54a3121..8d7dccb 100644 --- a/szurubooru/middleware/__init__.py +++ b/szurubooru/middleware/__init__.py @@ -1,3 +1,5 @@ +''' Various hooks that get executed for each request. ''' + from szurubooru.middleware.authenticator import Authenticator from szurubooru.middleware.json_translator import JsonTranslator from szurubooru.middleware.require_json import RequireJson diff --git a/szurubooru/middleware/authenticator.py b/szurubooru/middleware/authenticator.py index f3fa26d..d34c52c 100644 --- a/szurubooru/middleware/authenticator.py +++ b/szurubooru/middleware/authenticator.py @@ -1,11 +1,19 @@ +''' Exports Authenticator. ''' + import base64 import falcon class Authenticator(object): + ''' + Authenticates every request and puts information on active user in the + request context. + ''' + def __init__(self, auth_service): self._auth_service = auth_service def process_request(self, request, response): + ''' Executed before passing the request to the API. ''' request.context['user'] = self._get_user(request) def _get_user(self, request): @@ -20,7 +28,7 @@ class Authenticator(object): 'Invalid authentication type', 'Only basic authorization is supported.') - username, password = base64.decodestring( + username, password = base64.decodebytes( user_and_password.encode('ascii')).decode('utf8').split(':') return self._auth_service.authenticate(username, password) diff --git a/szurubooru/middleware/json_translator.py b/szurubooru/middleware/json_translator.py index 0bf5ebb..3dc02a1 100644 --- a/szurubooru/middleware/json_translator.py +++ b/szurubooru/middleware/json_translator.py @@ -1,8 +1,24 @@ +''' Exports JsonTranslator. ''' + import json import falcon +from datetime import datetime + +def json_serial(obj): + ''' JSON serializer for objects not serializable by default JSON code ''' + if isinstance(obj, datetime): + serial = obj.isoformat() + return serial + raise TypeError('Type not serializable') class JsonTranslator(object): + ''' + Translates API requests and API responses to JSON using requests' + context. + ''' + def process_request(self, request, response): + ''' Executed before passing the request to the API. ''' if request.content_length in (None, 0): return @@ -22,6 +38,8 @@ class JsonTranslator(object): 'JSON was incorrect or not encoded as UTF-8.') def process_response(self, request, response, resource): + ''' Executed before passing the response to falcon. ''' if 'result' not in request.context: return - response.body = json.dumps(request.context['result']) + response.body = json.dumps( + request.context['result'], default=json_serial) diff --git a/szurubooru/middleware/require_json.py b/szurubooru/middleware/require_json.py index b128ec1..c593426 100644 --- a/szurubooru/middleware/require_json.py +++ b/szurubooru/middleware/require_json.py @@ -1,7 +1,12 @@ +''' Exports RequireJson. ''' + import falcon class RequireJson(object): + ''' Sanitizes requests so that only JSON is accepted. ''' + def process_request(self, req, resp): + ''' Executed before passing the request to the API. ''' if not req.client_accepts_json: raise falcon.HTTPNotAcceptable( 'This API only supports responses encoded as JSON.') diff --git a/szurubooru/migrations/versions/7032abdf6efd_make_login_time_nullable.py b/szurubooru/migrations/versions/7032abdf6efd_make_login_time_nullable.py new file mode 100644 index 0000000..9d6b4b9 --- /dev/null +++ b/szurubooru/migrations/versions/7032abdf6efd_make_login_time_nullable.py @@ -0,0 +1,25 @@ +''' +Make login time nullable + +Revision ID: 7032abdf6efd +Created at: 2016-03-28 13:35:59.147167 +''' + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = '7032abdf6efd' +down_revision = '89ca368219b6' +branch_labels = None +depends_on = None + +def upgrade(): + op.alter_column( + 'user', 'last_login_time', + existing_type=postgresql.TIMESTAMP(), nullable=True) + +def downgrade(): + op.alter_column( + 'user', 'last_login_time', + existing_type=postgresql.TIMESTAMP(), nullable=False) diff --git a/szurubooru/model/__init__.py b/szurubooru/model/__init__.py index 86a3148..8423e0d 100644 --- a/szurubooru/model/__init__.py +++ b/szurubooru/model/__init__.py @@ -1,2 +1,6 @@ +''' +Database models. +''' + from szurubooru.model.base import Base from szurubooru.model.user import User diff --git a/szurubooru/model/base.py b/szurubooru/model/base.py index 7506f00..35050d9 100644 --- a/szurubooru/model/base.py +++ b/szurubooru/model/base.py @@ -1,2 +1,4 @@ +''' Base model for every database resource. ''' + from sqlalchemy.ext.declarative import declarative_base Base = declarative_base() # pylint: disable=C0103 diff --git a/szurubooru/model/user.py b/szurubooru/model/user.py index e22896d..31186e9 100644 --- a/szurubooru/model/user.py +++ b/szurubooru/model/user.py @@ -1,9 +1,15 @@ +''' Exports User. ''' + import sqlalchemy as sa from szurubooru.model.base import Base class User(Base): + ''' Database representation of an user. ''' __tablename__ = 'user' + AVATAR_GRAVATAR = 1 + AVATAR_MANUAL = 2 + user_id = sa.Column('id', sa.Integer, primary_key=True) name = sa.Column('name', sa.String(50), nullable=False, unique=True) password_hash = sa.Column('password_hash', sa.String(64), nullable=False) @@ -11,8 +17,5 @@ class User(Base): email = sa.Column('email', sa.String(200), nullable=True) access_rank = sa.Column('access_rank', sa.String(32), nullable=False) creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) - last_login_time = sa.Column('last_login_time', sa.DateTime, nullable=False) + last_login_time = sa.Column('last_login_time', sa.DateTime) avatar_style = sa.Column('avatar_style', sa.Integer, nullable=False) - - def has_password(self, password): - return self.password == password diff --git a/szurubooru/rest/__init__.py b/szurubooru/rest/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/szurubooru/rest/users.py b/szurubooru/rest/users.py deleted file mode 100644 index 8eced5a..0000000 --- a/szurubooru/rest/users.py +++ /dev/null @@ -1,23 +0,0 @@ -class UserList(object): - def __init__(self, auth_service): - self._auth_service = auth_service - - def on_get(self, request, response): - self._auth_service.verify_privilege(request.context['user'], 'users:list') - request.context['reuslt'] = {'message': 'Searching for users'} - - def on_post(self, request, response): - self._auth_service.verify_privilege(request.context['user'], 'users:create') - request.context['result'] = {'message': 'Creating user'} - -class User(object): - def __init__(self, auth_service): - self._auth_service = auth_service - - def on_get(self, request, response, user_id): - self._auth_service.verify_privilege(request.context['user'], 'users:view') - request.context['result'] = {'message': 'Getting user ' + user_id} - - def on_put(self, request, response, user_id): - self._auth_service.verify_privilege(request.context['user'], 'users:edit') - request.context['result'] = {'message': 'Updating user ' + user_id} diff --git a/szurubooru/services/__init__.py b/szurubooru/services/__init__.py index 23489ba..efd5e5f 100644 --- a/szurubooru/services/__init__.py +++ b/szurubooru/services/__init__.py @@ -1,2 +1,9 @@ -from szurubooru.services.auth_service import AuthService +''' +Middle layer between REST API and database. +All the business logic goes here. +''' + +from szurubooru.services.auth_service import AuthService, AuthError from szurubooru.services.user_service import UserService +from szurubooru.services.password_service import PasswordService +from szurubooru.services.errors import AuthError, IntegrityError diff --git a/szurubooru/services/auth_service.py b/szurubooru/services/auth_service.py index a907c78..a9328f2 100644 --- a/szurubooru/services/auth_service.py +++ b/szurubooru/services/auth_service.py @@ -1,24 +1,40 @@ -import falcon +''' Exports AuthService. ''' + from szurubooru.model.user import User +from szurubooru.services.errors import AuthError class AuthService(object): - def __init__(self, config, user_service): + ''' Services related to user authentication ''' + + def __init__(self, config, user_service, password_service): self._config = config self._user_service = user_service + self._password_service = password_service def authenticate(self, username, password): + ''' Tries to authenticate user. Throws AuthError for invalid users. ''' if not username: return self._create_anonymous_user() user = self._user_service.get_by_name(username) if not user: - raise falcon.HTTPForbidden( - 'Authentication failed', 'No such user.') - if not user.has_password(password): - raise falcon.HTTPForbidden( - 'Authentication failed', 'Invalid password.') + raise AuthError('No such user.') + if not self.is_valid_password(user, password): + raise AuthError('Invalid password.') return user + def is_valid_password(self, user, password): + ''' Returns whether the given password for a given user is valid. ''' + salt, valid_hash = user.password_salt, user.password_hash + possible_hashes = [ + self._password_service.get_password_hash(salt, password), + self._password_service.get_legacy_password_hash(salt, password) + ] + return valid_hash in possible_hashes + def verify_privilege(self, user, privilege_name): + ''' + Throws an AuthError if the given user doesn't have given privilege. + ''' all_ranks = ['anonymous'] \ + self._config['service']['user_ranks'] \ + ['admin', 'nobody'] @@ -28,8 +44,7 @@ class AuthService(object): minimal_rank = self._config['privileges'][privilege_name] good_ranks = all_ranks[all_ranks.index(minimal_rank):] if user.rank not in good_ranks: - raise falcon.HTTPForbidden( - 'Authentication failed', 'Insufficient privileges to do this.') + raise AuthError('Insufficient privileges to do this.') def _create_anonymous_user(self): user = User() diff --git a/szurubooru/services/errors.py b/szurubooru/services/errors.py new file mode 100644 index 0000000..09fbe51 --- /dev/null +++ b/szurubooru/services/errors.py @@ -0,0 +1,9 @@ +''' Exports custom errors. ''' + +class AuthError(RuntimeError): + ''' Generic authentication error ''' + pass + +class IntegrityError(RuntimeError): + ''' Database integrity error ''' + pass diff --git a/szurubooru/services/password_service.py b/szurubooru/services/password_service.py new file mode 100644 index 0000000..d330dd9 --- /dev/null +++ b/szurubooru/services/password_service.py @@ -0,0 +1,36 @@ +''' Exports PasswordService. ''' + +import hashlib +import random + +class PasswordService(object): + ''' Stateless utilities for passwords ''' + + def __init__(self, config): + self._config = config + + def get_password_hash(self, salt, password): + ''' Retrieves new-style password hash. ''' + digest = hashlib.sha256() + digest.update(self._config['basic']['secret'].encode('utf8')) + digest.update(salt.encode('utf8')) + digest.update(password.encode('utf8')) + return digest.hexdigest() + + def get_legacy_password_hash(self, salt, password): + ''' Retrieves old-style password hash. ''' + digest = hashlib.sha1() + digest.update(b'1A2/$_4xVa') + digest.update(salt.encode('utf8')) + digest.update(password.encode('utf8')) + return digest.hexdigest() + + def create_password(self): + ''' Creates an easy-to-remember password. ''' + alphabet = { + 'c': list('bcdfghijklmnpqrstvwxyz'), + 'v': list('aeiou'), + 'n': list('0123456789'), + } + pattern = 'cvcvnncvcv' + return ''.join(random.choice(alphabet[l]) for l in list(pattern)) diff --git a/szurubooru/services/user_service.py b/szurubooru/services/user_service.py index 9afc061..9849bdf 100644 --- a/szurubooru/services/user_service.py +++ b/szurubooru/services/user_service.py @@ -1,8 +1,40 @@ +''' Exports UserService. ''' + +from datetime import datetime from szurubooru.model.user import User +from szurubooru.services.errors import IntegrityError class UserService(object): - def __init__(self, session): - self._session = session + ''' User management ''' + + def __init__(self, config, transaction_manager, password_service): + self._config = config + self._transaction_manager = transaction_manager + self._password_service = password_service + + def create_user(self, name, password, email): + ''' Creates an user with given parameters and returns it. ''' + with self._transaction_manager.transaction() as session: + user = User() + user.name = name + user.password = password + user.password_salt = self._password_service.create_password() + user.password_hash = self._password_service.get_password_hash( + user.password_salt, user.password) + user.email = email + user.access_rank = self._config['service']['default_user_rank'] + user.creation_time = datetime.now() + user.avatar_style = User.AVATAR_GRAVATAR + + try: + session.add(user) + session.commit() + except: + raise IntegrityError('User %r already exists.' % name) + + return user def get_by_name(self, name): - self._session.query(User).filter_by(name=name).first() + ''' Retrieves an user by its name. ''' + with self._transaction_manager.read_only_transaction() as session: + return session.query(User).filter_by(name=name).first()