diff options
| author | rr- <rr-@sakuya.pl> | 2016-04-01 18:45:25 +0200 |
|---|---|---|
| committer | rr- <rr-@sakuya.pl> | 2016-04-01 18:48:16 +0200 |
| commit | e487adcc97b8d2bd111a34b7d3c72d66001deb8b (patch) | |
| tree | d684abf99bf176ead0a785bc237ae3b6a81417c1 /server | |
| parent | 1ad71585c4e95555566e8af068d5addf75b33274 (diff) | |
split files into client/ and server/
Diffstat (limited to 'server')
28 files changed, 745 insertions, 0 deletions
diff --git a/server/alembic.ini b/server/alembic.ini new file mode 100644 index 0000000..a49947c --- /dev/null +++ b/server/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = szurubooru/migrations + +# overriden from within config.ini +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/server/host-waitress b/server/host-waitress new file mode 100755 index 0000000..c179858 --- /dev/null +++ b/server/host-waitress @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +''' +Script facade for direct execution with waitress WSGI server. +Note that szurubooru can be also run using ``python -m szurubooru``, when in +the repository's root directory. +''' + +import argparse +import os.path +import sys +import waitress +from szurubooru.app import create_app + +def main(): + parser = argparse.ArgumentParser('Starts szurubooru using waitress.') + parser.add_argument( + '-p', '--port', type=int, help='port to listen on', default=6666) + parser.add_argument('--host', help='IP to listen on', default='0.0.0.0') + args = parser.parse_args() + + app = create_app() + waitress.serve(app, host=args.host, port=args.port) + +if __name__ == '__main__': + main() diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..0122fa6 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,5 @@ +alembic>=0.8.5 +configobj>=5.0.6 +falcon>=0.3.0 +psycopg2>=2.6.1 +SQLAlchemy>=1.0.12 diff --git a/server/szurubooru/__init__.py b/server/szurubooru/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/server/szurubooru/__init__.py diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py new file mode 100644 index 0000000..7075658 --- /dev/null +++ b/server/szurubooru/api/__init__.py @@ -0,0 +1,3 @@ +''' Falcon-compatible API facades. ''' + +from szurubooru.api.users import UserListApi, UserDetailApi diff --git a/server/szurubooru/api/users.py b/server/szurubooru/api/users.py new file mode 100644 index 0000000..ead769b --- /dev/null +++ b/server/szurubooru/api/users.py @@ -0,0 +1,81 @@ +''' Users public API. ''' + +import re +import falcon +from szurubooru.services.errors import IntegrityError + +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.result = {'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.request['name'] + password = request.context.request['password'] + email = request.context.request['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) + + session = request.context.session + try: + user = self._user_service.create_user(session, name, password, email) + session.commit() + except: + raise IntegrityError('User %r already exists.' % name) + request.context.result = {'user': _serialize_user(user)} + +class UserDetailApi(object): + ''' API for individual 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, user_name): + ''' Retrieves an user. ''' + self._auth_service.verify_privilege(request.context.user, 'users:view') + session = request.context.session + user = self._user_service.get_by_name(session, user_name) + request.context.result = {'user': _serialize_user(user)} + + def on_put(self, request, response, user_name): + ''' Updates an existing user. ''' + self._auth_service.verify_privilege(request.context.user, 'users:edit') + request.context.result = {'message': 'Updating user ' + user_name} diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py new file mode 100644 index 0000000..5868aca --- /dev/null +++ b/server/szurubooru/app.py @@ -0,0 +1,62 @@ +''' Exports create_app. ''' + +import os +import falcon +import sqlalchemy +import sqlalchemy.orm +import szurubooru.api +import szurubooru.config +import szurubooru.middleware +import szurubooru.services +import szurubooru.util + +class _CustomRequest(falcon.Request): + context_type = szurubooru.util.dotdict + +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(): + ''' 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') + + engine = sqlalchemy.create_engine( + '{schema}://{user}:{password}@{host}:{port}/{name}'.format( + schema=config['database']['schema'], + user=config['database']['user'], + password=config['database']['pass'], + host=config['database']['host'], + port=config['database']['port'], + name=config['database']['name'])) + session_maker = sqlalchemy.orm.sessionmaker(bind=engine) + scoped_session = sqlalchemy.orm.scoped_session(session_maker) + + # TODO: is there a better way? + password_service = szurubooru.services.PasswordService(config) + auth_service = szurubooru.services.AuthService(config, password_service) + user_service = szurubooru.services.UserService(config, password_service) + + user_list = szurubooru.api.UserListApi(config, auth_service, user_service) + user = szurubooru.api.UserDetailApi(config, auth_service, user_service) + + app = falcon.API( + request_type=_CustomRequest, + middleware=[ + szurubooru.middleware.RequireJson(), + szurubooru.middleware.JsonTranslator(), + szurubooru.middleware.DbSession(session_maker), + szurubooru.middleware.Authenticator(auth_service, user_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_name}', user) + + return app diff --git a/server/szurubooru/config.py b/server/szurubooru/config.py new file mode 100644 index 0000000..4b2827e --- /dev/null +++ b/server/szurubooru/config.py @@ -0,0 +1,38 @@ +''' Exports Config. ''' + +import os +import configobj + +class ConfigurationError(RuntimeError): + ''' A problem with config.ini file. ''' + pass + +class Config(object): + ''' INI config parser and container. ''' + def __init__(self): + self.config = configobj.ConfigObj('../config.ini.dist') + if os.path.exists('../config.ini'): + self.config.merge(configobj.ConfigObj('config.ini')) + self._validate() + + def __getitem__(self, key): + return self.config[key] + + def _validate(self): + ''' + Checks whether config.ini doesn't contain errors that might prove + lethal at runtime. + ''' + all_ranks = self['service']['user_ranks'] + for privilege, rank in self['privileges'].items(): + if rank not in all_ranks: + raise ConfigurationError( + 'Rank %r for privilege %r is missing from user_ranks' % ( + rank, privilege)) + for rank in ['anonymous', 'admin', 'nobody']: + if rank not in all_ranks: + raise ConfigurationError('Fixed rank %r is missing from user_ranks' % rank) + if self['service']['default_user_rank'] not in all_ranks: + raise ConfigurationError( + 'Default rank %r is missing from user_ranks' % ( + self['service']['default_user_rank'])) diff --git a/server/szurubooru/middleware/__init__.py b/server/szurubooru/middleware/__init__.py new file mode 100644 index 0000000..a2ba328 --- /dev/null +++ b/server/szurubooru/middleware/__init__.py @@ -0,0 +1,6 @@ +''' 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 +from szurubooru.middleware.db_session import DbSession diff --git a/server/szurubooru/middleware/authenticator.py b/server/szurubooru/middleware/authenticator.py new file mode 100644 index 0000000..df96cdb --- /dev/null +++ b/server/szurubooru/middleware/authenticator.py @@ -0,0 +1,60 @@ +''' Exports Authenticator. ''' + +import base64 +import falcon +from szurubooru.model.user import User +from szurubooru.services.errors import AuthError + +class Authenticator(object): + ''' + Authenticates every request and puts information on active user in the + request context. + ''' + + def __init__(self, auth_service, user_service): + self._auth_service = auth_service + self._user_service = user_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): + if not request.auth: + return self._create_anonymous_user() + + try: + auth_type, user_and_password = request.auth.split(' ', 1) + + if auth_type.lower() != 'basic': + raise falcon.HTTPBadRequest( + 'Invalid authentication type', + 'Only basic authorization is supported.') + + username, password = base64.decodebytes( + user_and_password.encode('ascii')).decode('utf8').split(':') + + session = request.context.session + return self._authenticate(session, username, password) + except ValueError as err: + msg = 'Basic authentication header value not properly formed. ' \ + + 'Supplied header {0}. Got error: {1}' + raise falcon.HTTPBadRequest( + 'Malformed authentication request', + msg.format(request.auth, str(err))) + + def _authenticate(self, session, username, password): + ''' Tries to authenticate user. Throws AuthError for invalid users. ''' + user = self._user_service.get_by_name(session, username) + if not user: + raise AuthError('No such user.') + if not self._auth_service.is_valid_password(user, password): + raise AuthError('Invalid password.') + return user + + def _create_anonymous_user(self): + user = User() + user.name = None + user.access_rank = 'anonymous' + user.password = None + return user diff --git a/server/szurubooru/middleware/db_session.py b/server/szurubooru/middleware/db_session.py new file mode 100644 index 0000000..dff317e --- /dev/null +++ b/server/szurubooru/middleware/db_session.py @@ -0,0 +1,18 @@ +''' Exports DbSession. ''' + +class DbSession(object): + ''' Attaches database session to the context of every request. ''' + + def __init__(self, session_factory): + self._session_factory = session_factory + + def process_request(self, request, response): + ''' Executed before passing the request to the API. ''' + request.context.session = self._session_factory() + + def process_response(self, request, response, resource): + ''' + Executed before passing the response to falcon. + Any commits to database need to happen explicitly in the API layer. + ''' + request.context.session.close() diff --git a/server/szurubooru/middleware/json_translator.py b/server/szurubooru/middleware/json_translator.py new file mode 100644 index 0000000..f04e6c8 --- /dev/null +++ b/server/szurubooru/middleware/json_translator.py @@ -0,0 +1,44 @@ +''' Exports JsonTranslator. ''' + +import json +from datetime import datetime +import falcon + +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 + + body = request.stream.read() + if not body: + raise falcon.HTTPBadRequest( + 'Empty request body', + 'A valid JSON document is required.') + + try: + request.context.request = json.loads(body.decode('utf-8')) + except (ValueError, UnicodeDecodeError): + raise falcon.HTTPError( + falcon.HTTP_401, + 'Malformed JSON', + 'Could not decode the request body. The ' + '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, default=json_serial) diff --git a/server/szurubooru/middleware/require_json.py b/server/szurubooru/middleware/require_json.py new file mode 100644 index 0000000..c593426 --- /dev/null +++ b/server/szurubooru/middleware/require_json.py @@ -0,0 +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/server/szurubooru/migrations/env.py b/server/szurubooru/migrations/env.py new file mode 100644 index 0000000..c10f0a1 --- /dev/null +++ b/server/szurubooru/migrations/env.py @@ -0,0 +1,74 @@ +import os +import sys + +import alembic +import sqlalchemy +import logging.config + +# make szurubooru module importable +dir_to_self = os.path.dirname(os.path.realpath(__file__)) +sys.path.append(os.path.join(dir_to_self, *[os.pardir] * 2)) + +import szurubooru.model.base +import szurubooru.config + +alembic_config = alembic.context.config +logging.config.fileConfig(alembic_config.config_file_name) + +szuru_config = szurubooru.config.Config() +alembic_config.set_main_option( + 'sqlalchemy.url', + '{schema}://{user}:{password}@{host}:{port}/{name}'.format( + schema=szuru_config['database']['schema'], + user=szuru_config['database']['user'], + password=szuru_config['database']['pass'], + host=szuru_config['database']['host'], + port=szuru_config['database']['port'], + name=szuru_config['database']['name'])) + +target_metadata = szurubooru.model.Base.metadata + +def run_migrations_offline(): + ''' + Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + ''' + url = alembic_config.get_main_option('sqlalchemy.url') + alembic.context.configure( + url=url, target_metadata=target_metadata, literal_binds=True) + + with alembic.context.begin_transaction(): + alembic.context.run_migrations() + + +def run_migrations_online(): + ''' + Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + ''' + connectable = sqlalchemy.engine_from_config( + alembic_config.get_section(alembic_config.config_ini_section), + prefix='sqlalchemy.', + poolclass=sqlalchemy.pool.NullPool) + + with connectable.connect() as connection: + alembic.context.configure( + connection=connection, + target_metadata=target_metadata) + + with alembic.context.begin_transaction(): + alembic.context.run_migrations() + +if alembic.context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/server/szurubooru/migrations/script.py.mako b/server/szurubooru/migrations/script.py.mako new file mode 100644 index 0000000..13adc51 --- /dev/null +++ b/server/szurubooru/migrations/script.py.mako @@ -0,0 +1,21 @@ +''' +${message} + +Revision ID: ${up_revision} +Created at: ${create_date} +''' + +import sqlalchemy as sa +from alembic import op +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + +def upgrade(): + ${upgrades if upgrades else "pass"} + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/server/szurubooru/migrations/versions/7032abdf6efd_make_login_time_nullable.py b/server/szurubooru/migrations/versions/7032abdf6efd_make_login_time_nullable.py new file mode 100644 index 0000000..9d6b4b9 --- /dev/null +++ b/server/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/server/szurubooru/migrations/versions/89ca368219b6_change_user_access_rank.py b/server/szurubooru/migrations/versions/89ca368219b6_change_user_access_rank.py new file mode 100644 index 0000000..c9252dc --- /dev/null +++ b/server/szurubooru/migrations/versions/89ca368219b6_change_user_access_rank.py @@ -0,0 +1,22 @@ +''' +Changes access rank column to string + +Revision ID: 89ca368219b6 +Created at: 2016-03-28 10:35:40.285485 +''' + +import sqlalchemy as sa +from alembic import op + +revision = '89ca368219b6' +down_revision = 'd186d2e9c2c9' +branch_labels = None +depends_on = None + +def upgrade(): + op.drop_column('user', 'access_rank') + op.add_column('user', sa.Column('access_rank', sa.String(length=32), nullable=False)) + +def downgrade(): + op.drop_column('user', 'access_rank') + op.add_column('user', sa.Column('access_rank', sa.INTEGER(), autoincrement=False, nullable=False)) diff --git a/server/szurubooru/migrations/versions/d186d2e9c2c9_add_unique_constraint_to_user_name.py b/server/szurubooru/migrations/versions/d186d2e9c2c9_add_unique_constraint_to_user_name.py new file mode 100644 index 0000000..a04f019 --- /dev/null +++ b/server/szurubooru/migrations/versions/d186d2e9c2c9_add_unique_constraint_to_user_name.py @@ -0,0 +1,20 @@ +''' +Add unique constraint to the user name + +Revision ID: d186d2e9c2c9 +Created at: 2016-03-28 10:21:30.440333 +''' + +import sqlalchemy as sa +from alembic import op + +revision = 'd186d2e9c2c9' +down_revision = 'e5c1216a8503' +branch_labels = None +depends_on = None + +def upgrade(): + op.create_unique_constraint('uq_user_name', 'user', ['name']) + +def downgrade(): + op.drop_constraint('uq_user_name', 'user', type_='unique') diff --git a/server/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py b/server/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py new file mode 100644 index 0000000..0826448 --- /dev/null +++ b/server/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py @@ -0,0 +1,31 @@ +''' +Create user table + +Revision ID: e5c1216a8503 +Created at: 2016-03-20 15:53:25.030415 +''' + +import sqlalchemy as sa +from alembic import op + +revision = 'e5c1216a8503' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + op.create_table( + 'user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('password_hash', sa.String(length=64), nullable=False), + sa.Column('pasword_salt', sa.String(length=32), nullable=True), + sa.Column('email', sa.String(length=200), nullable=True), + sa.Column('access_rank', sa.Integer(), nullable=False), + sa.Column('creation_time', sa.DateTime(), nullable=False), + sa.Column('last_login_time', sa.DateTime(), nullable=False), + sa.Column('avatar_style', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id')) + +def downgrade(): + op.drop_table('user') diff --git a/server/szurubooru/model/__init__.py b/server/szurubooru/model/__init__.py new file mode 100644 index 0000000..8423e0d --- /dev/null +++ b/server/szurubooru/model/__init__.py @@ -0,0 +1,6 @@ +''' +Database models. +''' + +from szurubooru.model.base import Base +from szurubooru.model.user import User diff --git a/server/szurubooru/model/base.py b/server/szurubooru/model/base.py new file mode 100644 index 0000000..d622ddf --- /dev/null +++ b/server/szurubooru/model/base.py @@ -0,0 +1,4 @@ +''' Base model for every database resource. ''' + +from sqlalchemy.ext.declarative import declarative_base +Base = declarative_base() # pylint: disable=invalid-name diff --git a/server/szurubooru/model/user.py b/server/szurubooru/model/user.py new file mode 100644 index 0000000..f611a52 --- /dev/null +++ b/server/szurubooru/model/user.py @@ -0,0 +1,23 @@ +# pylint: disable=too-many-instance-attributes,too-few-public-methods + +''' 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) + password_salt = sa.Column('pasword_salt', sa.String(32)) + 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) + avatar_style = sa.Column('avatar_style', sa.Integer, nullable=False) diff --git a/server/szurubooru/services/__init__.py b/server/szurubooru/services/__init__.py new file mode 100644 index 0000000..efd5e5f --- /dev/null +++ b/server/szurubooru/services/__init__.py @@ -0,0 +1,9 @@ +''' +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/server/szurubooru/services/auth_service.py b/server/szurubooru/services/auth_service.py new file mode 100644 index 0000000..638ce6f --- /dev/null +++ b/server/szurubooru/services/auth_service.py @@ -0,0 +1,32 @@ +''' Exports AuthService. ''' + +from szurubooru.services.errors import AuthError + +class AuthService(object): + ''' Services related to user authentication ''' + + def __init__(self, config, password_service): + self._config = config + self._password_service = password_service + + 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 = self._config['service']['user_ranks'] + + assert privilege_name in self._config['privileges'] + assert user.access_rank in all_ranks + minimal_rank = self._config['privileges'][privilege_name] + good_ranks = all_ranks[all_ranks.index(minimal_rank):] + if user.access_rank not in good_ranks: + raise AuthError('Insufficient privileges to do this.') diff --git a/server/szurubooru/services/errors.py b/server/szurubooru/services/errors.py new file mode 100644 index 0000000..09fbe51 --- /dev/null +++ b/server/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/server/szurubooru/services/password_service.py b/server/szurubooru/services/password_service.py new file mode 100644 index 0000000..d330dd9 --- /dev/null +++ b/server/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/server/szurubooru/services/user_service.py b/server/szurubooru/services/user_service.py new file mode 100644 index 0000000..d8ce8b6 --- /dev/null +++ b/server/szurubooru/services/user_service.py @@ -0,0 +1,31 @@ +''' Exports UserService. ''' + +from datetime import datetime +from szurubooru.model.user import User + +class UserService(object): + ''' User management ''' + + def __init__(self, config, password_service): + self._config = config + self._password_service = password_service + + def create_user(self, session, name, password, email): + ''' Creates an user with given parameters and returns it. ''' + 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 + + session.add(user) + return user + + def get_by_name(self, session, name): + ''' Retrieves an user by its name. ''' + return session.query(User).filter_by(name=name).first() diff --git a/server/szurubooru/util.py b/server/szurubooru/util.py new file mode 100644 index 0000000..929957d --- /dev/null +++ b/server/szurubooru/util.py @@ -0,0 +1,8 @@ +''' Exports dotdict. ''' + +class dotdict(dict): # pylint: disable=invalid-name + '''dot.notation access to dictionary attributes''' + def __getattr__(self, attr): + return self.get(attr) + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ |