From 94cc69dfa576d1f216e2215c0d67941c36ac5251 Mon Sep 17 00:00:00 2001 From: B H Boma Date: Fri, 28 Jul 2017 17:48:36 +0100 Subject: [PATCH] [WIP]Add QRCode email feature --- .../system_settings/system_settings.json | 132 +++++++++++++++++- frappe/twofactor.py | 84 +++++++++-- frappe/www/qrcode.html | 12 ++ frappe/www/qrcode.py | 43 ++++++ 4 files changed, 253 insertions(+), 18 deletions(-) create mode 100644 frappe/www/qrcode.html create mode 100644 frappe/www/qrcode.py diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 77f207cb10..02e5eda0e5 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -751,7 +751,7 @@ "collapsible": 0, "columns": 0, "depends_on": "eval:doc.enable_two_factor_auth==1 && doc.two_factor_method == \"OTP App\" && doc.send_barcode_as_email==1", - "description": "Time in seconds to retain barcode image on server. Min:240", + "description": "Time in seconds to retain QR code image on server. Min:240", "fieldname": "lifespan_barcode_image", "fieldtype": "Int", "hidden": 0, @@ -761,7 +761,7 @@ "in_global_search": 0, "in_list_view": 0, "in_standard_filter": 0, - "label": "Delete Barcode Image On server", + "label": "Delete QR Code Image On server", "length": 0, "no_copy": 0, "permlevel": 0, @@ -1010,7 +1010,131 @@ "in_global_search": 0, "in_list_view": 0, "in_standard_filter": 0, - "label": "Send Barcode as Email", + "label": "Send QR Code as email", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "depends_on": "eval:doc.enable_two_factor_auth==1 && doc.two_factor_method == \"OTP App\" && doc.send_barcode_as_email==1", + "fieldname": "qr_code_email_subject", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "QR Code Email Subject", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "depends_on": "eval:doc.enable_two_factor_auth==1 && doc.two_factor_method == \"OTP App\" && doc.send_barcode_as_email==1", + "fieldname": "qr_code_email_body", + "fieldtype": "Small Text", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "QR Code Email Body", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "depends_on": "eval:doc.enable_two_factor_auth==1 && doc.two_factor_method == \"Email\"", + "fieldname": "two_factor_email_subject", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Two factor Email Subject", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "depends_on": "eval:doc.enable_two_factor_auth==1 && doc.two_factor_method == \"Email\"", + "fieldname": "two_factor_email_body", + "fieldtype": "Small Text", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Two factor Email Body", "length": 0, "no_copy": 0, "permlevel": 0, @@ -1157,7 +1281,7 @@ "issingle": 1, "istable": 0, "max_attachments": 0, - "modified": "2017-07-27 12:23:01.135841", + "modified": "2017-07-28 07:21:12.520227", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/twofactor.py b/frappe/twofactor.py index a70f3d1985..3bcf8c5ce1 100644 --- a/frappe/twofactor.py +++ b/frappe/twofactor.py @@ -7,19 +7,20 @@ import frappe from frappe import _ import pyotp,base64,os from frappe.utils.background_jobs import enqueue +from jinja2 import Template from pyqrcode import create as qrcreate from StringIO import StringIO from base64 import b64encode,b32encode from frappe.utils import get_url, get_datetime, time_diff_in_seconds -class ExpiredLoginExpection(Exception):pass +class ExpiredLoginException(Exception):pass def should_run_2fa(user): '''Check if 2fa should run.''' site_otp_enabled = frappe.db.get_value('System Settings', 'System Settings', 'enable_two_factor_auth') user_otp_enabled = two_factor_is_enabled_for_(user) - #Don't validate for Admin of if not enabled + #Don't validate for Admin or if not enabled if user =='Administrator' or not site_otp_enabled or not user_otp_enabled: return False return True @@ -102,7 +103,7 @@ def confirm_otp_token(login_manager,otp=None,tmp_id=None): hotp_token = frappe.cache().get(tmp_id + '_token') otp_secret = frappe.cache().get(tmp_id + '_otp_secret') if not otp_secret: - raise ExpiredLoginExpection(_('Login session expired, refresh page to retry')) + raise ExpiredLoginException(_('Login session expired, refresh page to retry')) hotp = pyotp.HOTP(otp_secret) if hotp_token: if hotp.verify(otp, int(hotp_token)): @@ -119,7 +120,7 @@ def confirm_otp_token(login_manager,otp=None,tmp_id=None): delete_qrimage(login_manager.user) return True else: - login_manager.fail('Incorrect Verification code', login_manager.user) + login_manager.fail(_('Incorrect Verification code'), login_manager.user) def get_verification_obj(user,token,otp_secret): @@ -166,20 +167,71 @@ def process_2fa_for_email(user,token,otp_secret,otp_issuer,method='email'): '''Process Email method for 2fa.''' message = None status = True - # TODO SVG don't display in email if method == 'otp_app' and not frappe.db.get_default(user + '_otplogin'): totp_uri = pyotp.TOTP(otp_secret).provisioning_uri(user, issuer_name=otp_issuer) - message = '''

Please scan the barcode for One Time Password

- '''.format(qrcode_as_png(user,totp_uri)) + qrcode_link = get_link_for_qrcode(user,totp_uri) + message = get_email_body_for_qr_code({'qrcode_link':qrcode_link}) + subject = get_email_subject_for_qr_code({'qrcode_link':qrcode_link}) if method == 'email' or message: - status = send_token_via_email(user,token,otp_secret,otp_issuer,message=message) + status = send_token_via_email(user,token,otp_secret,otp_issuer,subject=subject,message=message) verification_obj = {'token_delivery': status, 'prompt': status and 'Verification code has been sent to your registered email address', 'method': 'Email'} return verification_obj - +def get_email_subject_for_2fa(kwargs_dict): + '''Get email subject for 2fa.''' + subject_template = 'Verifcation Code from Frappe Framework' + template = frappe.get_value('System Settings','System Settings','two_factor_email_subject') + if not template == '': + subject_template = template + subject = render_string_template(subject_template,kwargs_dict) + return subject + +def get_email_body_for_2fa(kwargs_dict): + '''Get email body for 2fa.''' + body_template = 'Use this token to login
{{otp}}' + template = frappe.get_value('System Settings','System Settings','two_factor_email_body') + if not template == '': + subject_template = template + body = render_string_template(body_template,kwargs_dict) + return body + +def get_email_subject_for_qr_code(kwargs_dict): + '''Get QRCode email subject.''' + subject_template = 'Verification Code from Frappe Framework' + template = frappe.get_value('System Settings','System Settings','qr_code_email_subject') + if not template == '': + subject_template = template + subject = render_string_template(subject_template,kwargs_dict) + return subject + +def get_email_body_for_qr_code(kwargs_dict): + '''Get QRCode email body.''' + body_template = 'Scan the QRCode on this link to get token
{{qrcode_link}}' + template = frappe.get_value('System Settings','System Settings','qr_code_email_body') + if not template == '': + body_template = template + body = render_string_template(body_template,kwargs_dict) + return body + +def render_string_template(_str,kwargs_dict): + '''Render string with jinja.''' + s = Template(_str) + s = s.render(**kwargs_dict) + return s + +def get_link_for_qrcode(user,totp_uri): + '''Get link to temporary page showing QRCode.''' + key = frappe.generate_hash(length=20) + key_user = "{}_user".format(key) + key_uri = "{}_uri".format(key) + lifespan = int(frappe.db.get_value('System Settings', 'System Settings', 'lifespan_barcode_image')) + if lifespan<=0: + lifespan = 240 + frappe.cache().set_value(key_uri,totp_uri,expires_in_sec=lifespan) + frappe.cache().set_value(key_user,user,expires_in_sec=lifespan) + return get_url('/qrcode?k={}'.format(key)) def send_token_via_sms(otpsecret, token=None, phone_no=None): '''Send token as sms to user.''' @@ -207,16 +259,20 @@ def send_token_via_sms(otpsecret, token=None, phone_no=None): enqueue(method=send_request, queue='short', timeout=300, event=None, async=True, job_name=None, now=False, **sms_args) return True -def send_token_via_email(user, token, otp_secret, otp_issuer,message=None): +def send_token_via_email(user, token, otp_secret, otp_issuer,subject=None,message=None): '''Send token to user as email.''' user_email = frappe.db.get_value('User', user, 'email') if not user_email: return False hotp = pyotp.HOTP(otp_secret) + otp = hotp.at(int(token)) + template_args = {'otp':otp,'otp_issuer':otp_issuer} + if not subject: + subject = get_email_subject_for_2fa(template_args) if not message: - message = '

Your verification code is {}.

'.format(hotp.at(int(token))) + message = get_email_body_for_2fa(template_args) email_args = { - 'recipients':user_email, 'sender':None, 'subject':'Verification Code from {}'.format(otp_issuer or "Frappe Framework"), + 'recipients':user_email, 'sender':None, 'subject':subject, 'message':message, 'delayed':False, 'retry':3 } @@ -236,7 +292,7 @@ def get_qr_svg_code(totp_uri): svg = '' stream = StringIO() try: - url.svg(stream, scale=3) + url.svg(stream, scale=4, background="#eee", module_color="#222") svg = stream.getvalue().replace('\n','') svg = b64encode(bytes(svg)) finally: diff --git a/frappe/www/qrcode.html b/frappe/www/qrcode.html new file mode 100644 index 0000000000..3edd1f7e18 --- /dev/null +++ b/frappe/www/qrcode.html @@ -0,0 +1,12 @@ +
+
+
+ Hi {{qr_code_user.first_name}}, Please scan QR Code and enter the resulting code displayed. + You can use apps such as Google Authenticator, Lastpass Authenticator, Authy, Duo Mobile and others. + +
+
+ +
+
+
\ No newline at end of file diff --git a/frappe/www/qrcode.py b/frappe/www/qrcode.py new file mode 100644 index 0000000000..f87ede7597 --- /dev/null +++ b/frappe/www/qrcode.py @@ -0,0 +1,43 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals + +import frappe +from frappe import _ +from urlparse import parse_qs +from frappe.twofactor import get_qr_svg_code + +no_cache = 1 + + +def get_context(context): + context.qr_code_user,context.qrcode_svg = get_user_svg_from_cache() + + + + + +def get_query_key(): + '''Return query string arg.''' + query_string = frappe.local.request.query_string + query = parse_qs(query_string) + if not 'k' in query.keys(): + frappe.throw(_('Not Permitted'),frappe.PermissionError) + query = (query['k'][0]).strip() + if False in [i.isalpha() or i.isdigit() for i in query]: + frappe.throw(_('Not Permitted'),frappe.PermissionError) + return query + +def get_user_svg_from_cache(): + '''Get User and SVG code from cache.''' + key = get_query_key() + totp_uri = frappe.cache().get_value("{}_uri".format(key)) + user = frappe.cache().get_value("{}_user".format(key)) + if not totp_uri or not user: + frappe.throw(_('Page has expired!'),frappe.PermissionError) + if not frappe.db.exists('User',user): + frappe.throw(_('Not Permitted'), frappe.PermissionError) + user = frappe.get_doc('User',user) + svg = get_qr_svg_code(totp_uri) + return (user,svg)