back/users: implement user registration

This commit is contained in:
rr- 2016-03-28 14:14:50 +02:00
parent c9a243cae8
commit 3fd7f64fa3
21 changed files with 332 additions and 57 deletions

View file

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

View file

@ -0,0 +1,3 @@
''' Falcon-compatible API facades. '''
from szurubooru.api.users import UserListApi, UserDetailApi

72
szurubooru/api/users.py Normal file
View file

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

View file

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

View file

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

36
szurubooru/db.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +1,6 @@
'''
Database models.
'''
from szurubooru.model.base import Base
from szurubooru.model.user import User

View file

@ -1,2 +1,4 @@
''' Base model for every database resource. '''
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base() # pylint: disable=C0103

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
''' Exports custom errors. '''
class AuthError(RuntimeError):
''' Generic authentication error '''
pass
class IntegrityError(RuntimeError):
''' Database integrity error '''
pass

View file

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

View file

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