From b038680280343b1c27059841677a501e5cdfd0f8 Mon Sep 17 00:00:00 2001 From: Todor Minakov Date: Sat, 14 Oct 2017 11:49:48 +0300 Subject: [PATCH 1/3] * REFA - renamed the init.py to serverconfig.py, and moved all configuration functions to it - separation of logic * FIX - the "list" CLI option works once again * FEAT - skipping of directory writability check, thus allowing to work under Windows --- photobackup_bottle/photobackup.py | 88 ++++++++---------- .../{init.py => serverconfig.py} | 93 ++++++++++++++++--- 2 files changed, 115 insertions(+), 66 deletions(-) rename photobackup_bottle/{init.py => serverconfig.py} (50%) mode change 100755 => 100644 diff --git a/photobackup_bottle/photobackup.py b/photobackup_bottle/photobackup.py index 5215f0a..8dd6703 100755 --- a/photobackup_bottle/photobackup.py +++ b/photobackup_bottle/photobackup.py @@ -31,7 +31,7 @@ """ # stlib -import configparser + import os import sys # pipped @@ -39,54 +39,12 @@ from bottle import abort, redirect, request, route, run import bottle from docopt import docopt -from logbook import info, warn, error, Logger, StreamHandler +from logbook import Logger, StreamHandler # local -from . import __version__, init - - -def create_logger(): - """ Creates the logger fpr this module. """ - StreamHandler(sys.stdout).push_application() - return Logger('PhotoBackup') - - -def init_config(username=None): - """ Launch init.py script to create configuration file on user's disk. """ - init.init(username) - sys.exit("\nCreated, now launch PhotoBackup server with 'photobackup run'") - - -def print_list(): - """ Print the existing PhotoBackup configurations. """ - sections = '\n'.join(get_config().sections()) - sections = sections.replace('photobackup-', '- ') - sections = sections.replace('photobackup', '') - print('Runnable PhotoBackup configurations are:') - print(sections) - - -def read_config(username=None): - """ Set configuration file data into local dictionnary. """ - config_file = os.path.expanduser("~/.photobackup") - config = configparser.RawConfigParser() - config.optionxform = lambda option: option # to keep case of keys - try: - config.read_file(open(config_file)) - except EnvironmentError: - log.error("can't read configuration file, running 'photobackup init'") - init_config(username) - - suffix = '-' + username if username else '' - config_key = 'photobackup' + suffix - - values = None - try: - values = config[config_key] - except KeyError: - values = None - return values +from . import __version__, serverconfig +# Server functions def end(code, message): """ Aborts the request and returns the given error. """ log.error(message) @@ -184,22 +142,50 @@ def test(): log.info("Test succeeded \o/") +# CLI handlers - they don't use the log, but print() +def init_config(username=None): + """ Creates the configuration file. """ + serverconfig.init(username) + print("Created, now launch PhotoBackup server with 'photobackup run'") + sys.exit(0) + + +def print_list(): + """ Print the existing PhotoBackup sections. """ + print(serverconfig.return_config_sections()) + sys.exit(0) + + +# internal helpers +def _create_logger(): + """ Creates the logger fpr this module. """ + StreamHandler(sys.stdout).push_application() + return Logger('PhotoBackup') + # variables arguments = docopt(__doc__, version='PhotoBackup ' + __version__) -log = create_logger() -config = read_config(arguments['']) + +# the server configuraiton dict; will be filled-in in main() +config = None + +log = _create_logger() def main(): """ Prepares and launches the bottle app. """ - if (arguments['init']): + if arguments['init']: init_config(arguments['']) - elif (arguments['run']): + sys.exit(0) + + global config + config = serverconfig.read_config(arguments['']) + + if arguments['run']: app = bottle.default_app() if 'HTTPPrefix' in config: app.mount(config['HTTPPrefix'], app) app.run(port=config['Port'], host=config['BindAddress']) - elif (arguments['list']): + elif arguments['list']: print_list() diff --git a/photobackup_bottle/init.py b/photobackup_bottle/serverconfig.py old mode 100755 new mode 100644 similarity index 50% rename from photobackup_bottle/init.py rename to photobackup_bottle/serverconfig.py index a4dcdf2..99b01c7 --- a/photobackup_bottle/init.py +++ b/photobackup_bottle/serverconfig.py @@ -27,27 +27,40 @@ import getpass import hashlib import os -import pwd +import sys +try: + import pwd +except ImportError: + pass # bypass the import error for Windows + import stat # pipped import bcrypt +# variables +_config_file = os.path.expanduser("~/.photobackup") + def writable_by(dirname, name, user_or_group): """ Checks if the given directory is writable by the named user or group. user_or_group is a boolean with True for a user and False for a group. """ + # TODO combine user & group in one check... try: pwnam = pwd.getpwnam(name) except KeyError: print('[ERROR] User or group {0} does not exist!'.format(name)) return False + except NameError: + print('[WARN] Write verify cannot be performed on non-unix like systems, skipping.') + return True + ugid = pwnam.pw_uid if user_or_group else pwnam.pw_gid dir_stat = os.stat(dirname) ug_stat = dir_stat[stat.ST_UID] if user_or_group else dir_stat[stat.ST_GID] iw_stat = stat.S_IWUSR if user_or_group else stat.S_IWGRP - if ((ug_stat == ugid) and (dir_stat[stat.ST_MODE] & iw_stat)): + if (ug_stat == ugid) and (dir_stat[stat.ST_MODE] & iw_stat): return True return False @@ -60,22 +73,24 @@ def init(username=None): ===============================""") # ask for the upload directory (should be writable by the server) - media_root = input("The directory where to put the pictures" + - " (should be writable by the server you use): ") + media_root = input('The directory where to put the pictures (should be writable by the server you use): ') try: os.mkdir(media_root) - print("Directory {0} does not exist, creating it".format(media_root)) + print('Directory {0} did not exist, created it'.format(media_root)) except OSError: - print("Directory already exists") + print('Directory already exists') - # test for user writability of the directory - server_user = input("Owner of the directory [www-data]: ") + server_user = input('Owner of the directory [www-data]: ') if not server_user: server_user = 'www-data' - if not writable_by(media_root, server_user, True) and \ - not writable_by(media_root, server_user, False): - print('[INFO] Directory {0} is not writable by {1}, check it!' - .format(media_root, server_user)) + + check_writable_by = input('Verify the user {0} has write permissions on {1}? [Yn]' + .format(server_user, media_root)) + + # test for user writability of the directory + if not check_writable_by or check_writable_by.strip().lower()[0] == 'y': + if not writable_by(media_root, server_user, True) and not writable_by(media_root, server_user, False): + print('[WARN] Directory {0} is not writable by {1}, check it!'.format(media_root, server_user)) # ask a password for the server password = getpass.getpass(prompt='The server password: ') @@ -84,20 +99,68 @@ def init(username=None): passhash = bcrypt.hashpw(pass_sha, bcrypt.gensalt()) # save in config file - config_file = os.path.expanduser("~/.photobackup") + config = configparser.ConfigParser() config.optionxform = str # to keep case of keys - config.read(config_file) # to keep existing data + config.read(_config_file) # to keep existing data; doesn't file if there's no file yet + suffix = '-' + username if username else '' config_key = 'photobackup' + suffix + config[config_key] = {'BindAddress': '127.0.0.1', 'MediaRoot': media_root, 'Password': pass_sha.decode(), 'PasswordBcrypt': passhash.decode(), 'Port': 8420} - with open(config_file, 'w') as configfile: + + with open(_config_file, 'w') as configfile: config.write(configfile) +def read_config(username=None): + """ Returns a dictionary with the configuration data, read from the config file. """ + + config = configparser.RawConfigParser() + config.optionxform = lambda option: option # to keep case of keys + try: + if not os.path.isfile(_config_file): + raise OSError('The configuration file "{}" does not exist'.format(_config_file)) + config.read_file(open(_config_file)) + except EnvironmentError as ex: + print("Can't read the configuration file - run 'photobackup init'\n{}".format(ex)) + sys.exit(1) + + suffix = '-' + username if username else '' + config_key = 'photobackup' + suffix + + values = None + try: + values = config[config_key] + except KeyError: + print("The configuration file does not have {} section".format(config_key)) + sys.exit(1) + + return values + + +def return_config_sections(): + """ Print the existing PhotoBackup configurations. """ + + config = configparser.RawConfigParser() + config.optionxform = lambda option: option # to keep case of keys + + try: + config.read_file(open(_config_file)) + except EnvironmentError as ex: + print("Cannot read the configuration file - {}".format(ex)) + sys.exit(1) + + sections = '\n'.join(config.sections()) + sections = sections.replace('photobackup-', '') + sections = sections.replace('photobackup', '') + + return 'Runnable PhotoBackup configurations are: \n{}'.format(sections) + + if __name__ == '__main__': init() From a968f4c1d4d269693f44c49b6f8571845ae09695 Mon Sep 17 00:00:00 2001 From: Todor Minakov Date: Sat, 14 Oct 2017 19:35:01 +0300 Subject: [PATCH 2/3] * REFA - the check for directory write capabilities checks both the user & group permissions in one pass, and is more complete. Minor docstrings updates --- photobackup_bottle/photobackup.py | 19 +++---- photobackup_bottle/serverconfig.py | 85 +++++++++++++++++++----------- 2 files changed, 64 insertions(+), 40 deletions(-) diff --git a/photobackup_bottle/photobackup.py b/photobackup_bottle/photobackup.py index 8dd6703..ee3b3f3 100755 --- a/photobackup_bottle/photobackup.py +++ b/photobackup_bottle/photobackup.py @@ -52,8 +52,7 @@ def end(code, message): def validate_password(request, isTest=False): - """ Validates the password given in the request - against the stored Bcrypted one. """ + """ Validates the password given in the request against the stored Bcrypted one. """ password = None try: password = request.forms.get('password').encode('utf-8') @@ -71,7 +70,7 @@ def validate_password(request, isTest=False): def save_file(upfile, filesize): - """ Saves the sent file locally. """ + """ Saves the received file locally. """ path = os.path.join(config['MediaRoot'], os.path.basename(upfile.raw_filename)) if not os.path.exists(path): @@ -107,7 +106,7 @@ def index(): @route('/', method='POST') def save_image(): - """ Saves the given image to the parameterized directory. """ + """ Saves the given image to the directory set in the configured. """ validate_password(request) upfile = request.files.get('upfile') @@ -125,7 +124,7 @@ def save_image(): @route('/test', method='POST') def test(): - """ Tests the server capabilities to handle POST requests. """ + """ Tests the server capabilities to handle a POST requests. """ validate_password(request, True) if not os.path.exists(config['MediaRoot']): @@ -143,15 +142,17 @@ def test(): # CLI handlers - they don't use the log, but print() -def init_config(username=None): - """ Creates the configuration file. """ - serverconfig.init(username) +def init_config(section=None): + """ Creates the configuration file. + param section: Optional argument of a custom section that'll be created in the config file. + """ + serverconfig.init(section) print("Created, now launch PhotoBackup server with 'photobackup run'") sys.exit(0) def print_list(): - """ Print the existing PhotoBackup sections. """ + """ Prints the existing PhotoBackup configuration sections. """ print(serverconfig.return_config_sections()) sys.exit(0) diff --git a/photobackup_bottle/serverconfig.py b/photobackup_bottle/serverconfig.py index 99b01c7..5e4f1ae 100644 --- a/photobackup_bottle/serverconfig.py +++ b/photobackup_bottle/serverconfig.py @@ -26,13 +26,12 @@ import configparser import getpass import hashlib -import os +import os, errno # for creating the directory import sys try: - import pwd + import pwd # permission checks on *nix systems except ImportError: pass # bypass the import error for Windows - import stat # pipped import bcrypt @@ -41,70 +40,92 @@ _config_file = os.path.expanduser("~/.photobackup") -def writable_by(dirname, name, user_or_group): - """ Checks if the given directory is writable by the named user or group. - user_or_group is a boolean with True for a user and False for a group. """ - # TODO combine user & group in one check... +def writable_by(dirname, user): + """ Checks if the given directory is writable by the named user, or the group she belongs to.""" + + dir_stat = os.stat(dirname) + + if not stat.S_ISDIR(dir_stat.st_mode): + print('The {} is not a directory, exiting.'.format(dirname)) + sys.exit(1) + try: - pwnam = pwd.getpwnam(name) + pwnam = pwd.getpwnam(user) except KeyError: - print('[ERROR] User or group {0} does not exist!'.format(name)) + print('[ERROR] User or group {0} does not exist!'.format(user)) return False except NameError: - print('[WARN] Write verify cannot be performed on non-unix like systems, skipping.') + print('[WARN] Writable verification cannot be performed on non-unix like systems, skipping.') return True - ugid = pwnam.pw_uid if user_or_group else pwnam.pw_gid - - dir_stat = os.stat(dirname) - ug_stat = dir_stat[stat.ST_UID] if user_or_group else dir_stat[stat.ST_GID] - iw_stat = stat.S_IWUSR if user_or_group else stat.S_IWGRP - - if (ug_stat == ugid) and (dir_stat[stat.ST_MODE] & iw_stat): + user_id, group_id = pwnam.pw_uid, pwnam.pw_gid + directory_mode = dir_stat[stat.ST_MODE] + # The user has to have w, r and x on a directory - to list content, create/modify, and access inodes + # https://stackoverflow.com/a/46745175/3446126 + + if user_id == dir_stat[stat.ST_UID] and stat.S_IRWXU & directory_mode == stat.S_IRWXU: # owner and has RWX + return True + elif group_id == dir_stat[stat.ST_GID] and stat.S_IRWXG & directory_mode == stat.S_IRWXG: # in group & it has RWX + return True + elif stat.S_IRWXO & directory_mode == stat.S_IRWXO: # everyone has RWX return True + # no permissions return False -def init(username=None): - """ Initializes the PhotoBackup configuration file. """ +def init(section=None): + """ Initializes the PhotoBackup configuration file. + :param section: Optional argument of a custom section to be created in the config file. The generated name + will be "[photobackup-section]". + The option is useful for having different configurations like staging, production, etc, in the + same file, or pseudo multi-tenant environment. + If not set, the default "[photobackup]" section is created. + """ print("""=============================== PhotoBackup_bottle init process ===============================""") # ask for the upload directory (should be writable by the server) media_root = input('The directory where to put the pictures (should be writable by the server you use): ') + + if not media_root: + print('No directory given, stopping.') + sys.exit(1) + try: - os.mkdir(media_root) + os.makedirs(media_root) print('Directory {0} did not exist, created it'.format(media_root)) - except OSError: - print('Directory already exists') + except OSError as ex: + if ex.errno != errno.EEXIST: + print('The directory did not exist, and failed to create it - {} '.format(ex)) + sys.exit(1) server_user = input('Owner of the directory [www-data]: ') if not server_user: server_user = 'www-data' - check_writable_by = input('Verify the user {0} has write permissions on {1}? [Yn]' + check_writable_by = input('Verify the user {0} has write permissions on {1} [Yn]: ' .format(server_user, media_root)) # test for user writability of the directory if not check_writable_by or check_writable_by.strip().lower()[0] == 'y': - if not writable_by(media_root, server_user, True) and not writable_by(media_root, server_user, False): + if not writable_by(media_root, server_user): print('[WARN] Directory {0} is not writable by {1}, check it!'.format(media_root, server_user)) - # ask a password for the server + # ask for the server password password = getpass.getpass(prompt='The server password: ') pass_sha = hashlib.sha512( password.encode('utf-8')).hexdigest().encode('utf-8') passhash = bcrypt.hashpw(pass_sha, bcrypt.gensalt()) - # save in config file + # save the config file config = configparser.ConfigParser() config.optionxform = str # to keep case of keys config.read(_config_file) # to keep existing data; doesn't file if there's no file yet - suffix = '-' + username if username else '' + suffix = '-' + section if section else '' config_key = 'photobackup' + suffix config[config_key] = {'BindAddress': '127.0.0.1', @@ -117,8 +138,10 @@ def init(username=None): config.write(configfile) -def read_config(username=None): - """ Returns a dictionary with the configuration data, read from the config file. """ +def read_config(section=None): + """ Returns a dictionary with the configuration data, read from the config file. + :param section: Optional argument of which custom section to read. + If not set, the "[photobackup]" section is read.""" config = configparser.RawConfigParser() config.optionxform = lambda option: option # to keep case of keys @@ -130,7 +153,7 @@ def read_config(username=None): print("Can't read the configuration file - run 'photobackup init'\n{}".format(ex)) sys.exit(1) - suffix = '-' + username if username else '' + suffix = '-' + section if section else '' config_key = 'photobackup' + suffix values = None @@ -144,7 +167,7 @@ def read_config(username=None): def return_config_sections(): - """ Print the existing PhotoBackup configurations. """ + """ Print the existing PhotoBackup configuration sections; the default is named "". """ config = configparser.RawConfigParser() config.optionxform = lambda option: option # to keep case of keys From 60d9def8ca78f78fe2d171cfb6dfb683fee229b9 Mon Sep 17 00:00:00 2001 From: Todor Minakov Date: Sat, 14 Oct 2017 21:34:25 +0300 Subject: [PATCH 3/3] * FEAT - full migration to bcrypt - if the client sent 'Password' request attribute, bcrypt it & compare it like this; if the config file does not have bcrypted password - generate it on load --- photobackup_bottle/photobackup.py | 36 +++++++++++++++--------------- photobackup_bottle/serverconfig.py | 26 ++++++++++++++++----- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/photobackup_bottle/photobackup.py b/photobackup_bottle/photobackup.py index ee3b3f3..3652321 100755 --- a/photobackup_bottle/photobackup.py +++ b/photobackup_bottle/photobackup.py @@ -34,6 +34,7 @@ import os import sys +from functools import wraps # better decorators # pipped import bcrypt from bottle import abort, redirect, request, route, run @@ -51,22 +52,23 @@ def end(code, message): abort(code, message) -def validate_password(request, isTest=False): +def validate_password(func): """ Validates the password given in the request against the stored Bcrypted one. """ - password = None - try: - password = request.forms.get('password').encode('utf-8') - except AttributeError: - end(403, "No password in request") - - if 'PasswordBcrypt' in config: + @wraps(func) + def wrapper(*args, **kwargs): + password = None + try: + password = request.forms.get('password').encode('utf-8') + except AttributeError: + end(403, 'No password in request') + passcrypt = config['PasswordBcrypt'].encode('utf-8') - if bcrypt.hashpw(password, passcrypt) != passcrypt: - end(403, "wrong password!") - elif 'Password' in config and config['Password'] != password: - end(403, "wrong password!") - elif isTest: - end(401, "There's no password in server configuration!") + if not bcrypt.checkpw(password, passcrypt): + end(403, 'wrong password!') + + return func(*args, **kwargs) + + return wrapper def save_file(upfile, filesize): @@ -105,10 +107,9 @@ def index(): @route('/', method='POST') +@validate_password def save_image(): """ Saves the given image to the directory set in the configured. """ - validate_password(request) - upfile = request.files.get('upfile') if not upfile: end(401, "no file in the request!") @@ -123,10 +124,9 @@ def save_image(): @route('/test', method='POST') +@validate_password def test(): """ Tests the server capabilities to handle a POST requests. """ - validate_password(request, True) - if not os.path.exists(config['MediaRoot']): end(500, "'MediaRoot' directory does not exist!") diff --git a/photobackup_bottle/serverconfig.py b/photobackup_bottle/serverconfig.py index 5e4f1ae..289ea82 100644 --- a/photobackup_bottle/serverconfig.py +++ b/photobackup_bottle/serverconfig.py @@ -114,16 +114,18 @@ def init(section=None): print('[WARN] Directory {0} is not writable by {1}, check it!'.format(media_root, server_user)) # ask for the server password - password = getpass.getpass(prompt='The server password: ') - pass_sha = hashlib.sha512( - password.encode('utf-8')).hexdigest().encode('utf-8') - passhash = bcrypt.hashpw(pass_sha, bcrypt.gensalt()) + plaintext_password = getpass.getpass(prompt='The server password: ') + + # sha512 on the password - adds entropy, allows pass >72 chars, no issues with funky Unicode chars + pass_sha = hashlib.sha512(plaintext_password.encode('utf-8')).hexdigest().encode('utf-8') + + pass_bcrypt = bcrypt.hashpw(pass_sha, bcrypt.gensalt()) # save the config file config = configparser.ConfigParser() config.optionxform = str # to keep case of keys - config.read(_config_file) # to keep existing data; doesn't file if there's no file yet + config.read(_config_file) # to keep existing data; doesn't fail if there's no file yet suffix = '-' + section if section else '' config_key = 'photobackup' + suffix @@ -131,7 +133,7 @@ def init(section=None): config[config_key] = {'BindAddress': '127.0.0.1', 'MediaRoot': media_root, 'Password': pass_sha.decode(), - 'PasswordBcrypt': passhash.decode(), + 'PasswordBcrypt': pass_bcrypt.decode(), 'Port': 8420} with open(_config_file, 'w') as configfile: @@ -163,6 +165,18 @@ def read_config(section=None): print("The configuration file does not have {} section".format(config_key)) sys.exit(1) + # sanitize - all required settings are present + if 'PasswordBcrypt' not in values: # legacy config, no bcrypted password - generate it now + if 'Password' not in values: + print('ERROR - the config file does not have nor PasswordBcrypt, nor Password.') + sys.exit(1) + values['PasswordBcrypt'] = bcrypt.hashpw(values['Password'].encode('utf-8'), bcrypt.gensalt()) + + for key in ('BindAddress', 'MediaRoot', 'Port'): + if key not in values: + print('The requered setting {} is not in the config file; exiting.'.format(key)) + sys.exit(1) + return values