back/users: implement user registration
This commit is contained in:
parent
c9a243cae8
commit
3fd7f64fa3
21 changed files with 332 additions and 57 deletions
|
@ -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
|
||||
|
|
3
szurubooru/api/__init__.py
Normal file
3
szurubooru/api/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
''' Falcon-compatible API facades. '''
|
||||
|
||||
from szurubooru.api.users import UserListApi, UserDetailApi
|
72
szurubooru/api/users.py
Normal file
72
szurubooru/api/users.py
Normal 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}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
36
szurubooru/db.py
Normal 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()
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.')
|
||||
|
|
|
@ -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)
|
|
@ -1,2 +1,6 @@
|
|||
'''
|
||||
Database models.
|
||||
'''
|
||||
|
||||
from szurubooru.model.base import Base
|
||||
from szurubooru.model.user import User
|
||||
|
|
|
@ -1,2 +1,4 @@
|
|||
''' Base model for every database resource. '''
|
||||
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
Base = declarative_base() # pylint: disable=C0103
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
9
szurubooru/services/errors.py
Normal file
9
szurubooru/services/errors.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
''' Exports custom errors. '''
|
||||
|
||||
class AuthError(RuntimeError):
|
||||
''' Generic authentication error '''
|
||||
pass
|
||||
|
||||
class IntegrityError(RuntimeError):
|
||||
''' Database integrity error '''
|
||||
pass
|
36
szurubooru/services/password_service.py
Normal file
36
szurubooru/services/password_service.py
Normal 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))
|
|
@ -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()
|
||||
|
|
Loading…
Reference in a new issue