from datetime import datetime from datetime import timedelta from flask import current_app from flask import make_response from flask import redirect from flask import render_template from flask import request from flask import session from flask import url_for import logging import re import uuid from crypt_util import hash_pw from rz_api_common import API_Exception__bad_request from rz_mail import send_email__flask_ctx from rz_req_handling import make_response__json, make_response__json__html log = logging.getLogger('rhizi') class User_Account(object): def __init__(self, first_name, last_name, rz_username, email_address, pw_hash, role_set=[]): self.first_name = first_name self.last_name = last_name self.rz_username = rz_username self.email_address = email_address self.pw_hash = pw_hash self.role_set = role_set def __str__(self, *args, **kwargs): ret_arr = ['rz_username: %s' % (self.rz_username), 'email_address: %s' % (self.email_address), 'role-set: %s' % (self.role_set), ] return ','.join(ret_arr) class User_Signup_Request(dict): def __init__(self, email_address, first_name, last_name, pw_plaintxt, rz_username): self['rz_username'] = rz_username self['email_address'] = email_address self['last_name'] = last_name self['first_name'] = first_name self['pw_plaintxt'] = pw_plaintxt self['submission_date'] = None self['validation_key'] = None def __str__(self): return 'email_address: %s' % (self['email_address']) def has_expired(self): date_now = datetime.now() dt_6h = timedelta(hours=6) dt = self['submission_date'] - date_now return dt > dt_6h class User_Pw_Reset_Request(object): # extend from object as these won't be serialized def __init__(self, u_account, pw_reset_token): self.u_account = u_account self.submission_date = None self.pw_reset_tok = pw_reset_token def __str__(self): return 'email_address: %s' % (self.email_address) def has_expired(self): date_now = datetime.now() dt_6h = timedelta(hours=6) dt = self.submission_date - date_now return dt > dt_6h def activate_user_account(us_req): user_db = current_app.user_db existing_account = None try: _, existing_account = user_db.lookup_user__by_email_address(us_req['email_address']) except Exception as _: pass # thrown if user was not found, expected if None != existing_account: raise Exception('account activation code reused: existing-account: %s' % (existing_account.email_address)) pw_plaintxt = us_req['pw_plaintxt'] pw_hash = calc_user_pw_hash(pw_plaintxt) u_account = User_Account(first_name=us_req['first_name'], last_name=us_req['last_name'], rz_username=us_req['rz_username'], email_address=us_req['email_address'], pw_hash=pw_hash, role_set=['user']) user_db.user_add(u_account) log.info('user account activated: email: %s, rz_username: %s' % (us_req['email_address'], us_req['rz_username'])) def calc_user_pw_hash(plaintxt_pw): """ calc password hash """ salt = current_app.rz_config.secret_key pw_hash = hash_pw(str(plaintxt_pw), salt) return pw_hash def filter_expired__singup_requests(us_req_map): for su_req_email, su_req in us_req_map.items(): if su_req.has_expired(): log.info('user sign-up request expired: %s' % (su_req)) del us_req_map[su_req_email] def filter_expired__pw_rst_requests(pw_rst_req_map): for pw_rst_tok, pw_rst_req in pw_rst_req_map.items(): if pw_rst_req.has_expired(): log.info('user pw reset request expired: %s' % (pw_rst_req)) del pw_rst_req_map[pw_rst_tok] def generate_security_token(): """ generate a random UUID based string ID """ return str(uuid.uuid4()).replace('-', '') def rest__login(): def sanitize_input(req): req_json = request.get_json() email_address = req_json['email_address'] p = req_json['password'] return email_address, p if request.method == 'POST': try: email_address, p = sanitize_input(request) except: log.warn('failed to sanitize inputs. request: %s' % request) return make_response__json(status=401) # return empty response u_account = None try: _uid, u_account = current_app.user_db.lookup_user__by_email_address(email_address) except: log.warn('login: login attempt to unknown account: email_address: \'%s\'' % (email_address)) return make_response__json(status=401) # return empty response try: salt = current_app.rz_config.secret_key pw_hash = hash_pw(p, salt) current_app.user_db.validate_login(email_address=u_account.email_address, pw_hash=pw_hash) except Exception as e: # login failed log.warn('login: unauthorized: user: %s' % (email_address)) return make_response__json(status=401) # return empty response # login successful session['username'] = email_address log.debug('login: success: user: %s' % (email_address)) return make_response__json(status=200) # return empty response if request.method == 'GET': return render_template('login.html', signup_enabled=current_app.rz_config.signup_enabled) def rest__logout(): """ REST API endpoint: logout """ # remove the username from the session if it's there u = session.pop('username', None) assert None != u # assert user session was found - note: /logout is login protected log.debug('logout: success: user: %s' % (u)) # redirect on GET, invalidate cookie on GET/POST if request.method == 'GET': resp = make_response(redirect(url_for('login'))) if request.method == 'POST': resp = make_response__json() resp.set_cookie(key='session', value='', expires=0) # UNIX timestamp 0 return resp def rest__pw_reset(): def get_or_init_pw_rst_req_map(): """ lazy pw reset request map getter. We map requests twice: - once by email, to throttle reset requests per account - second by security token, to allow lookup by supplied token on POST """ if not hasattr(current_app, 'pw_rst_req_map'): setattr(current_app, 'pw_rst_req_map', {}) return current_app.pw_rst_req_map def sanitize_input(req): """ here we expect a map of input-element id's to input-element values """ req_json = request.get_json() email_address = req_json.get('email_address') # None when collecting the new password new_user_pw = req_json.get('new_user_password') # None when submitting initial reset request pw_rst_tok = req_json.get('pw_rst_tok') # None unless accepting new pw if None != email_address: regex__email_address = r'[^@]+@[^@]+\.[^@]+' if None == re.match(regex__email_address, email_address): raise Exception('malformed signup request: regex match failure: regex: %s, input: %s' % (regex__email_address, email_address)) if None != pw_rst_tok: tmp_tok = generate_security_token() if len(tmp_tok) != len(pw_rst_tok): raise Exception('malformed signup request: malformed security token: %s' % (pw_rst_tok)) return {'email_address': email_address, 'new_user_pw': new_user_pw, 'pw_rst_tok': pw_rst_tok } html_ok__submitted = '
Your request has been received.
Please check your email to reset your password.
Your password has been successfully updated.
Please head over to the login page to access your account.'
html_ok__already_pending = '
Your request has already been submitted.
please check your email to proceed.
We are experiencing technical difficulty processing your request,
please try again later.
Your request has been successfully submitted.
Please check your email to activate your account.
Your request has already been submitted.
Please check your email to activate your account.
We are experiencing technical difficulty processing your request,
Please try again later.
Signup requires a \'@%s\' domain email account.
Please obtain one to proceed.
%s
' % (e.caller_err_msg)) except Exception as e: log.exception(e) return make_response__json__html(status=400, html_str=html_err__tech_difficulty) # FIXME: implement form validation # probe for existing request existing_req = us_req_map.get(us_req['email_address']) if None != existing_req: # already pending log.warning('user signup: request already pending: %s' % (existing_req)) return make_response__json__html(status=200, html_str=html_ok__already_pending) # validate email against ACL:email_domain acl__singup__email_domain = current_app.rz_config.acl__singup__email_domain if current_app.rz_config.access_control and acl__singup__email_domain is not None: if us_req['email_address'].split('@')[-1].lower() != acl__singup__email_domain: return make_response__json__html(status=500, html_str=html_err__acl__singup__email_domain % (acl__singup__email_domain)) us_req['submission_date'] = datetime.now() us_req['validation_key'] = generate_security_token() # send activation email try: activation_link = send_user_activation_link__email(request.url_root, us_req) log.info('user sign-up: req received, activation link sent via email: link: %s' % (activation_link)) add_user_signup_req(us_req_map, us_req) return make_response__json__html(html_str=html_ok__submitted) except Exception as e: log.exception('user sign-up: failed to send validation email') # exception derived from stack return make_response__json__html(status=500, html_str=html_err__tech_difficulty) if request.method == 'GET': v_tok = request.args.get('v_tok') if None != v_tok: # validation token passed for _, us_req in us_req_map.items(): other_req_v_tok = us_req['validation_key'] if None == other_req_v_tok: continue if other_req_v_tok == v_tok: if us_req.has_expired(): # check again whether request has expired log.warning('user signup: attempt to activate expired signup request: email: %s' % (us_req['email_address'])) return render_template('user_signup.html', state='activation_failure') try: activate_user_account(us_req) except Exception as e: log.exception(e) return render_template('user_signup.html', state='activation_failure') # activation success rm_user_signup_req(us_req_map, us_req) return render_template('user_signup.html', state='activation_success') # request expired & already removed OR bad token log.warning('user sign-up: request not found or bad token: remote-address: %s' % (request.remote_addr)) return render_template('user_signup.html', state='activation_failure') else: # no validation token: new user return render_template('user_signup.html') def send_user_activation_link__email(req__url_root, us_req): """ Send user feedback by email along with screen capture attachments @return: activation_link @raise exception: on send error """ activation_link = '%ssignup?v_tok=%s' % (req__url_root, us_req['validation_key']) msg_body = ['Hello %s %s,' % (us_req['first_name'], us_req['last_name']), '', 'Thank you for your joining the Rhizi community!', '', 'Below is a sign up confirmation link - open it in your browser to activate your account:', '', activation_link, '', 'Happy knowledge editing!', 'The Rhizi team.' ] msg_body = '\n'.join(msg_body) send_email__flask_ctx(recipients=[us_req['email_address']], subject="Rhizi sign up request", body=msg_body) return activation_link def send_user_pw_reset__email(req__url_root, u_account, pw_reset_token): """ Send user pw reset email @return: pw reset link @raise exception: on send error """ pw_reset_link = '%spw-reset?pw_rst_tok=%s' % (req__url_root, pw_reset_token) msg_body = ['Hello %s %s,' % (u_account.first_name, u_account.last_name), '', 'A password reset request has been received for this email address.', 'Please follow the link below to reset the password for your account:', '', pw_reset_link, '', 'Happy knowledge editing!', 'The Rhizi team.' ] msg_body = '\n'.join(msg_body) send_email__flask_ctx(recipients=[u_account.email_address], subject="Rhizi password reset request", body=msg_body) return pw_reset_link