import os import re import base64 import hashlib import json import redis import requests from datetime import datetime from collections import OrderedDict from os.path import expandvars from getpass import getpass from string import ascii_lowercase as alpha from bottle import template, request #from cork import AAAException from webrecorder.webreccork import ValidationException, AuthException from webrecorder.models.base import BaseAccess, DupeNameException from webrecorder.models.user import User, UserTable from webrecorder.utils import load_wr_config, sanitize_title, get_bool from webrecorder.webreccork import WebRecCork from webrecorder.redisutils import RedisTable # ============================================================================ class UserManager(object): USER_RX = re.compile(r'^[A-Za-z0-9][\w-]{2,30}$') RESTRICTED_NAMES = ['login', 'logout', 'user', 'admin', 'manager', 'coll', 'collection', 'guest', 'settings', 'profile', 'api', 'anon', 'webrecorder', 'anonymous', 'register', 'join', 'download', 'live', 'embed', 'docs'] PASS_RX = re.compile(r'^(?=.*[\d\W])(?=.*[a-z])(?=.*[A-Z]).{8,}$') EMAIL_RX = re.compile(r'[\w./+-]+@[\w.-]+') LC_USERNAMES_KEY = 'h:lc_users' def __init__(self, redis, cork, config): self.redis = redis self.cork = cork self.config = config self.default_coll = config['default_coll'] self.temp_prefix = config['temp_prefix'] mailing_list = os.environ.get('MAILING_LIST', '').lower() self.mailing_list = mailing_list in ('true', '1', 'yes') self.default_list_endpoint = os.environ.get('MAILING_LIST_ENDPOINT', '') self.list_key = os.environ.get('MAILING_LIST_KEY', '') self.list_removal_endpoint = os.path.expandvars( os.environ.get('MAILING_LIST_REMOVAL', '')) self.payload = os.environ.get('MAILING_LIST_PAYLOAD', '') self.remove_on_delete = (os.environ.get('REMOVE_ON_DELETE', '') in ('true', '1', 'yes')) self.announce_list = os.environ.get('ANNOUNCE_MAILING_LIST_ENDPOINT', False) invites = expandvars(config.get('invites_enabled', 'true')).lower() self.invites_enabled = invites in ('true', '1', 'yes') try: self.redis.hsetnx('h:defaults', 'max_size', int(config['default_max_size'])) self.redis.hsetnx('h:defaults', 'max_anon_size', int(config['default_max_anon_size'])) except Exception as e: print('WARNING: Unable to init defaults: ' + str(e)) self.all_users = UserTable(self.redis, self._get_access) self.invites = RedisTable(self.redis, 'h:invites') def register_user(self, input_data, host): msg = OrderedDict() redir_extra = '' username = input_data.get('username', '') full_name = input_data.get('full_name', '') email = input_data.get('email', '') if 'username' not in input_data: msg['username'] = 'Missing Username' elif username.startswith(self.temp_prefix): msg['username'] = 'Sorry, this is not a valid username' if 'email' not in input_data: msg['email'] = 'Missing Email' if self.invites_enabled: try: val_email = self.is_valid_invite(input_data['invite']) if val_email != email: raise ValidationException('Sorry, this invite can only be used with email: {0}'.format(val_email)) except ValidationException as ve: msg['invite'] = str(ve) else: redir_extra = '?invite=' + input_data.get('invite', '') try: self.validate_user(username, email) self.validate_password(input_data['password'], input_data['confirmpassword']) except ValidationException as ve: msg['validation'] = str(ve) try: move_info = self.get_move_temp_info(input_data) except ValidationException as ve: msg['move_info'] = str(ve) if msg: return msg, redir_extra try: desc = {'name': full_name} if move_info: desc['move_info'] = move_info desc = json.dumps(desc) self.cork.register(username, input_data['password'], email, role='archivist', max_level=50, subject='webrecorder.io Account Creation', email_template='webrecorder/templates/emailconfirm.html', description=desc, host=host) # add to announce list if user opted in if input_data.get('announce_mailer') and self.announce_list: self.add_to_mailing_list(username, email, full_name, list_endpoint=self.announce_list) if self.invites_enabled: self.delete_invite(email) # extend session for upto 90 mins to store data to be migrated # to allow time for user to validate registration if move_info: self.get_session().save() except ValidationException as ve: msg['validation'] = str(ve) except Exception as ex: import traceback traceback.print_exc() msg['other_error'] = 'Registration failed: ' + str(ex) if not msg: msg['success'] = ('A confirmation e-mail has been sent to <b>{0}</b>. ' + 'Please check your e-mail to complete the registration!').format(username) return msg, redir_extra def get_move_temp_info(self, input_data): move_temp = input_data.get('moveTemp') if not move_temp: return None to_coll_title = input_data.get('toColl', '') to_coll = sanitize_title(to_coll_title) if not to_coll: raise ValidationException('invalid_coll_name') if not self.access.session_user.is_anon(): raise ValidationException('invalid_user_import') return {'from_user': self.access.session_user.name, 'to_coll': to_coll, 'to_title': to_coll_title, } def validate_registration(self, reg_code, cookie, username): cookie_validate = 'valreg=' + reg_code if cookie_validate not in cookie: return {'error': 'invalid_code'} try: user, first_coll = self.create_user_from_reg(reg_code, username) return {'registered': user.name, 'first_coll_name': first_coll.name} except ValidationException as ve: return {'error': ve.msg} except Exception as e: import traceback traceback.print_exc() return {'error': 'invalid_code'} def find_case_insensitive_username(self, username): lower_username = username.lower() new_username = self.redis.hget(self.LC_USERNAMES_KEY, lower_username) if new_username == '-' or new_username == username or new_username is None: return None if new_username == '': return lower_username return new_username def get_authenticated_user(self, username, password): """Returns the user matching the supplied username and password otherwise returns None :param str username: The username of the user :param str password: The users password :return: The authenticated user :rtype: User|None """ # first, authenticate the user # if failing, see if case-insensitive username and try that if not self.cork.is_authenticate(username, password): username = self.find_case_insensitive_username(username) if not username or not self.cork.is_authenticate(username, password): return None return self.all_users[username] def login_user_no_cookie(self, username, password): try: authed_user = self.get_authenticated_user(username, password) except Exception: return None if not authed_user: return None self.access.log_in(username, False) sesh = self.get_session() sesh.should_save = False sesh.should_renew = False return authed_user def login_user(self, input_data): """Authenticate users""" username = input_data.get('username', '') password = input_data.get('password', '') try: move_info = self.get_move_temp_info(input_data) except ValidationException as ve: return {'error': str(ve)} user = self.get_authenticated_user(username, password) # first, authenticate the user # if failing, see if case-insensitive username and try that if not user: return {'error': 'invalid_login'} # if not enough space, don't continue with login if move_info: if not self.has_space_for_new_collection(user.my_id, move_info['from_user'], 'temp'): #return {'error': 'Sorry, not enough space to import this Temporary Collection into your account.'} return {'error': 'out_of_space'} new_collection = None try: if move_info: new_collection = self.move_temp_coll(user, move_info) except DupeNameException as de: return {'error': 'duplicate_name'} #return {'error': 'Collection "{0}" already exists'.format(move_info['to_title'])} remember_me = get_bool(input_data.get('remember_me')) # login session and access system self.access.log_in(user.my_id, remember_me) user.update_last_login() return {'success': '1', 'new_coll_name': new_collection.name if new_collection else None, 'user': user} def logout(self): sesh = self.get_session() sesh.delete() return def has_user_email(self, email): #TODO: implement a email table, if needed? for n, user_data in self.all_users.items(): if user_data['email_addr'] == email: return True return False def get_user_email(self, user): if not user: return '' try: user_data = self.all_users[user] except: user_data = None if user_data: return user_data.get('email_addr', '') else: return '' def is_username_available(self, username): username_lc = username.lower() # username matches of the restricted names if username_lc in self.RESTRICTED_NAMES: return False # username doesn't match the allowed regex if not self.USER_RX.match(username): return False # lowercase username already exists if self.redis.hexists(self.LC_USERNAMES_KEY, username_lc): return False # username already exists! (shouldn't match if lowercase exists, but just in case) if username in self.all_users: return False return True def validate_user(self, user, email): if not self.is_username_available(user): raise ValidationException('username_not_available') if self.has_user_email(email): raise ValidationException('email_not_available') return True def validate_password(self, password, confirm): if password != confirm: raise ValidationException('password_mismatch') if not self.PASS_RX.match(password): raise ValidationException('password_invalid') return True def _get_access(self): return request['webrec.access'] @property def access(self): return self._get_access() def get_roles(self): return [x for x in self.cork._store.roles] def get_user(self, username): try: return self.all_users[username] except: return None def get_user_coll(self, username, coll_name): user = self.get_user(username) if not user: return None, None collection = user.get_collection_by_name(coll_name) return user, collection def get_user_coll_rec(self, username, coll_name, rec): user, collection = self.get_user_coll(username, coll_name) if collection: recording = collection.get_recording(rec) else: recording = None return user, collection, recording def update_password(self, curr_password, password, confirm): username = self.access.session_user.name if not self.cork.verify_password(username, curr_password): raise ValidationException('invalid_password') self.validate_password(password, confirm) self.cork.update_password(username, password) def reset_password(self, password, confirm, resetcode): self.validate_password(password, confirm) try: self.cork.reset_password(resetcode, password) except AuthException: raise ValidationException('invalid_reset_code') def is_valid_invite(self, invitekey): try: if not invitekey: return False key = base64.b64decode(invitekey.encode('utf-8')).decode('utf-8') key.split(':', 1) email, hash_ = key.split(':', 1) entry = self.invites[email] if entry and entry.get('hash_') == hash_: return email except Exception as e: print(e) pass msg = 'Sorry, that is not a valid invite code. Please try again or request another invite' raise ValidationException(msg) def delete_invite(self, email): try: archive_invites = RedisTable(self.redis, 'h:arc_invites') archive_invites[email] = self.invites[email] except: pass del self.invites[email] def save_invite(self, email, name, desc=''): if not email or not name: return False self.invites[email] = {'name': name, 'email': email, 'reg_data': desc} return True def send_invite(self, email, email_template, host): entry = self.invites[email] if not entry: print('No Such Email In Invite List') return False hash_ = base64.b64encode(os.urandom(21)).decode('utf-8') entry['hash_'] = hash_ full_hash = email + ':' + hash_ invitekey = base64.b64encode(full_hash.encode('utf-8')).decode('utf-8') email_text = template( email_template, host=host, email_addr=email, name=entry.get('name', email), invite=invitekey, ) self.cork.mailer.send_email(email, 'You are invited to join webrecorder.io beta!', email_text) entry['sent'] = str(datetime.utcnow()) return True def add_to_mailing_list(self, username, email, name, list_endpoint=None): """3rd party mailing list subscription""" if not (list_endpoint or self.default_list_endpoint) or not self.list_key: print('MAILING_LIST is turned on, but required fields are ' 'missing.') return # if no endpoint provided, use default if list_endpoint is None: list_endpoint = self.default_list_endpoint try: res = requests.post(list_endpoint, auth=('nop', self.list_key), data=self.payload.format( email=email, name=name, username=username), timeout=1.5) if res.status_code != 200: print('Unexpected mailing list API response.. ' 'status code: {0.status_code}\n' 'content: {0.content}'.format(res)) except Exception as e: if e is requests.exceptions.Timeout: print('Mailing list API timed out..') else: print('Adding to mailing list failed:', e) def remove_from_mailing_list(self, email): """3rd party mailing list removal""" if not self.list_removal_endpoint or not self.list_key: # fail silently, log info print('REMOVE_ON_DELETE is turned on, but required ' 'fields are missing.') return try: email = email.encode('utf-8').lower() email_hash = hashlib.md5(email).hexdigest() res = requests.delete(self.list_removal_endpoint.format(email_hash), auth=('nop', self.list_key), timeout=1.5) if res.status_code != 204: print('Unexpected mailing list API response.. ' 'status code: {0.status_code}\n' 'content: {0.content}'.format(res)) except Exception as e: if e is requests.exceptions.Timeout: print('Mailing list API timed out..') else: print('Removing from mailing list failed:', e) def get_session(self): return request.environ['webrec.session'] def create_new_user(self, username, init_info=None): init_info = init_info or {} user = self.all_users.make_user(username) user.create_new() # track lowercase username lower_username = username.lower() self.redis.hset(self.LC_USERNAMES_KEY, lower_username, username if lower_username != username else '') first_coll = None move_info = init_info.get('move_info') if move_info: first_coll = self.move_temp_coll(user, move_info) elif self.default_coll: first_coll = user.create_collection(self.default_coll['id'], title=self.default_coll['title'], desc=self.default_coll['desc'].format(username), public=False) # email subscription set up? if self.mailing_list: name = init_info.get('name', '') self.add_to_mailing_list(username, user['email_addr'], name) return user, first_coll def create_user_as_admin(self, email, username, passwd, passwd2, role, name): """Create a new user with command line arguments or series of prompts, preforming basic validation """ self.access.assert_is_superuser() errs = [] # EMAIL # validate email if not re.match(self.EMAIL_RX, email): errs.append('valid email required!') if email in [data['email_addr'] for u, data in self.all_users.items()]: errs.append('A user already exists with {0} email!'.format(email)) # USERNAME # validate username if not username: errs.append('please specify a username!') if not self.is_username_available(username): errs.append('Invalid username.') # ROLE if role not in self.get_roles(): errs.append('Not a valid role.') # PASSWD if passwd != passwd2 or not self.PASS_RX.match(passwd): errs.append('Passwords must match and be at least 8 characters long ' 'with lowercase, uppercase, and either digits or symbols.') if errs: return errs, None # add user to cork #self.cork._store.users[username] = { self.all_users[username] = { 'role': role, 'hash': self.cork._hash(username, passwd).decode('ascii'), 'email_addr': email, 'full_name': name, 'creation_date': str(datetime.utcnow()), 'last_login': str(datetime.utcnow()), } #self.cork._store.save_users() return None, self.create_new_user(username, {'email': email, 'name': name}) def create_user_from_reg(self, reg, username): user, init_info = self.cork.validate_registration(reg, username) if init_info: init_info = json.loads(init_info) user, first_coll = self.create_new_user(user, init_info) # login here self.access.log_in(user.name, remember_me=False) return user, first_coll def update_user_as_admin(self, user, data): """ Update any property on specified user For admin-only """ self.access.assert_is_curr_user(user) errs = [] if not data: errs.append('Nothing To Update') if 'role' in data and data['role'] not in self.get_roles(): errs.append('Not a valid role.') if 'max_size' in data and not isinstance(data['max_size'], int): errs.append('max_size must be an int') if errs: return errs if 'name' in data: #user['desc'] = '{{"name":"{name}"}}'.format(name=data.get('name', '')) user['name'] = data.get('name', '') if 'desc' in data: user['desc'] = data['desc'] if 'max_size' in data: user['max_size'] = data['max_size'] if 'role' in data: user['role'] = data['role'] if 'customer_id' in data: user['customer_id'] = data['customer_id'] if 'customer_max_size' in data: user['customer_max_size'] = data['customer_max_size'] return None def delete_user(self, username): try: user = self.all_users[username] self.access.assert_is_curr_user(user) except Exception: return False if self.mailing_list and self.remove_on_delete: self.remove_from_mailing_list(user['email_addr']) # remove user and from all users table del self.all_users[username] try: self.get_session().delete() except Exception: pass return True def has_space_for_new_collection(self, to_username, from_username, coll_name): try: to_user = self.all_users[to_username] except: return False from_user = self.all_users[from_username] collection = from_user.get_collection_by_name(coll_name) if not collection: return False return (collection.size <= to_user.get_size_remaining()) def move_temp_coll(self, user, move_info): from_user = self.all_users[move_info['from_user']] temp_coll = from_user.get_collection_by_name('temp') if not from_user.move(temp_coll, move_info['to_coll'], user): return None temp_coll.set_prop('title', move_info['to_title']) # don't delete data in temp user dir as its waiting to be committed! self.get_session().set_anon_commit_wait() for recording in temp_coll.get_recordings(): # will be marked for commit recording.set_closed() return temp_coll # ============================================================================ class CLIUserManager(UserManager): def __init__(self, redis_url=None): config = load_wr_config() self.base_access = BaseAccess() # Init Redis if not redis_url: redis_url = os.environ['REDIS_BASE_URL'] r = redis.StrictRedis.from_url(redis_url, decode_responses=True) # Init Cork cork = WebRecCork.create_cork(r, config) super(CLIUserManager, self).__init__( redis=r, cork=cork, config=config) def create_user(self, email=None, username=None, passwd=None, role=None, name=None): """Create a new user with command line arguments or series of prompts, preforming basic validation """ # EMAIL if not email: print('let\'s create a new user..') email = input('email: ').strip() # USERNAME if not username: username = input('username: ').strip() # NAME if not name: name = input('name (optional): ').strip() # ROLE if role not in self.get_roles(): role = self.choose_role() # PASSWD if not passwd: passwd = getpass('password: ') passwd2 = getpass('repeat password: ') else: passwd2 = passwd errs, res = self.create_user_as_admin(email, username, passwd, passwd2, role, name) if errs: for err in errs: print(err) return print('Created user {username} with the email {email} and the role: ' '\'{role}\''.format(username=username, email=email, role=role)) return res def choose_role(self): """Flexible choice prompt for as many roles as the system has""" roles = [r for r in self.cork.list_roles()] formatted = ['{0} (level {1})'.format(*r) for r in roles] condensed = '\n'.join(['{0}.) {1}'.format(*t) for t in zip(alpha, formatted)]) new_role = input('choose: \n{0}\n\n'.format(condensed)) if new_role not in alpha[:len(roles)]: raise Exception('invalid role choice') return roles[alpha.index(new_role)][0] def modify_user(self): """Modify an existing users. available modifications: role, email""" username = input('username to modify: ') has_modified = False if username not in self.all_users: print('{0} doesn\'t exist'.format(username)) return user = self.all_users[username] mod_role = input('change role? currently {0} (y/n) '.format(user['role'])) if mod_role.strip().lower() == 'y': new_role = self.choose_role() user['role'] = new_role has_modified = True print('assigned {0} with the new role: {1}'.format(username, new_role)) mod_email = input('update email? currently {0} (y/n) '.format(user['email_addr'])) if mod_email.strip().lower() == 'y': new_email = input('new email: ') if not re.match(r'[\w.-/+]+@[\w.-]+.\w+', new_email): print('valid email required!') return if new_email in [data['email_addr'] for u, data in self.all_users.items()]: print('A user already exists with {0} email!'.format(new_email)) return # assume the 3rd party mailing list doesn't support updating addresses # so if add & remove are turned on, remove the old and add the # new address. if self.mailing_list and self.remove_on_delete: self.remove_from_mailing_list(user['email_addr']) #name = json.loads(self.get_users()[username].get('desc', '{}')).get('name', '') name = user['name'] self.add_to_mailing_list(username, new_email, name) user['email_addr'] = new_email print('assigned {0} with the new email: {1}'.format(username, new_email)) has_modified = True # # additional modifications can be added here # #if has_modified: # self.cork._store.save_users() print('All done!') def list_users(self): """List all existing users.""" input_ = input( "{number} users, do you want to list users? (y/n)".format( number=len(self.all_users) ) ) if input_ == "Y" or input_ == "y": print( "\n".join( "User {username}".format(username=user) for user in self.all_users ) ) def check_user(self, username): """Check if username exists. :param str username: username """ if username not in self.all_users: print("User {username} does not exist".format(username=username)) return False else: print("User {username} exists".format(username=username)) return True def delete_user(self): """Remove a user from the system""" username = input('username to delete: ') confirmation = input('** all data for the username `{0}` will be wiped! **\n' 'please type the username again to confirm: '.format(username)) if username != confirmation: print('Username confirmation didn\'t match! Aborting..') return if username not in self.all_users: print('The username {0} doesn\'t exist..'.format(username)) return print('removing {0}..'.format(username)) super(CLIUserManager, self).delete_user(username) def _get_access(self): return self.base_access