diff --git a/frappe/auth.py b/frappe/auth.py index 8845b3e790..7fb9bb58de 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -16,9 +16,15 @@ from frappe.modules.patch_handler import check_session_stopped from frappe.translate import get_lang_code from frappe.utils.password import check_password from frappe.core.doctype.authentication_log.authentication_log import add_authentication_log +from frappe.utils.background_jobs import enqueue +from twofactor import should_run_2fa, authenticate_for_2factor, \ + confirm_otp_token,get_cached_user_pass + from six.moves.urllib.parse import quote +import pyotp,base64,os + class HTTPRequest: def __init__(self): # Get Environment variables @@ -62,6 +68,7 @@ class HTTPRequest: def validate_csrf_token(self): if frappe.local.request and frappe.local.request.method=="POST": + if not frappe.local.session:return if not frappe.local.session.data.csrf_token \ or frappe.local.session.data.device=="mobile" \ or frappe.conf.get('ignore_csrf', None): @@ -98,7 +105,7 @@ class LoginManager: self.user_type = None if frappe.local.form_dict.get('cmd')=='login' or frappe.local.request.path=="/api/method/login": - self.login() + if self.login()==False:return self.resume = False # run login triggers @@ -113,12 +120,20 @@ class LoginManager: self.make_session() self.set_user_info() + def login(self): # clear cache frappe.clear_cache(user = frappe.form_dict.get('usr')) - self.authenticate() + user,pwd = get_cached_user_pass() + self.authenticate(user=user,pwd=pwd) + if should_run_2fa(self.user): + authenticate_for_2factor(self.user) + if not confirm_otp_token(self): + return False self.post_login() + + def post_login(self): self.run_trigger('on_login') self.validate_ip_address() @@ -302,6 +317,7 @@ class CookieManager: for key in set(self.to_delete): response.set_cookie(key, "", expires=expires) + @frappe.whitelist() def get_logged_user(): return frappe.session.user @@ -317,4 +333,4 @@ def get_website_user_home_page(user): home_page = frappe.get_attr(home_page_method[-1])(user) return '/' + home_page.strip('/') else: - return '/me' + return '/me' \ No newline at end of file diff --git a/frappe/core/doctype/role/role.json b/frappe/core/doctype/role/role.json index 104ee7d53c..1eebb71a36 100644 --- a/frappe/core/doctype/role/role.json +++ b/frappe/core/doctype/role/role.json @@ -105,6 +105,37 @@ "set_only_once": 0, "unique": 0 }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "0", + "fieldname": "two_factor_auth", + "fieldtype": "Check", + "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 Authentication", + "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, @@ -148,7 +179,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2017-05-04 11:03:41.533058", + "modified": "2017-07-06 12:42:57.097914", "modified_by": "Administrator", "module": "Core", "name": "Role", diff --git a/frappe/core/doctype/sms_parameter/README.md b/frappe/core/doctype/sms_parameter/README.md new file mode 100644 index 0000000000..5935a390d2 --- /dev/null +++ b/frappe/core/doctype/sms_parameter/README.md @@ -0,0 +1 @@ +SMS query parameter for SMS Settings. \ No newline at end of file diff --git a/frappe/core/doctype/sms_parameter/__init__.py b/frappe/core/doctype/sms_parameter/__init__.py new file mode 100755 index 0000000000..baffc48825 --- /dev/null +++ b/frappe/core/doctype/sms_parameter/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/frappe/core/doctype/sms_parameter/sms_parameter.json b/frappe/core/doctype/sms_parameter/sms_parameter.json new file mode 100755 index 0000000000..b5648ade80 --- /dev/null +++ b/frappe/core/doctype/sms_parameter/sms_parameter.json @@ -0,0 +1,98 @@ +{ + "allow_copy": 0, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "beta": 0, + "creation": "2013-02-22 01:27:58", + "custom": 0, + "docstatus": 0, + "doctype": "DocType", + "editable_grid": 1, + "fields": [ + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "parameter", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Parameter", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": "150px", + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0, + "width": "150px" + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "value", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Value", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": "150px", + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0, + "width": "150px" + } + ], + "has_web_view": 0, + "hide_heading": 0, + "hide_toolbar": 0, + "idx": 1, + "image_view": 0, + "in_create": 0, + "is_submittable": 0, + "issingle": 0, + "istable": 1, + "max_attachments": 0, + "modified": "2017-07-22 22:52:53.309396", + "modified_by": "chude.osiegbu@manqala.com", + "module": "Core", + "name": "SMS Parameter", + "owner": "Administrator", + "permissions": [], + "quick_entry": 0, + "read_only": 0, + "read_only_onload": 0, + "show_name_in_global_search": 0, + "track_changes": 0, + "track_seen": 0 +} \ No newline at end of file diff --git a/frappe/core/doctype/sms_parameter/sms_parameter.py b/frappe/core/doctype/sms_parameter/sms_parameter.py new file mode 100644 index 0000000000..08b220b61a --- /dev/null +++ b/frappe/core/doctype/sms_parameter/sms_parameter.py @@ -0,0 +1,10 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +from frappe.model.document import Document + +class SMSParameter(Document): + pass \ No newline at end of file diff --git a/frappe/core/doctype/sms_settings/README.md b/frappe/core/doctype/sms_settings/README.md new file mode 100644 index 0000000000..4fb49803b3 --- /dev/null +++ b/frappe/core/doctype/sms_settings/README.md @@ -0,0 +1 @@ +Settings for automatically sending SMS from the system. \ No newline at end of file diff --git a/frappe/core/doctype/sms_settings/__init__.py b/frappe/core/doctype/sms_settings/__init__.py new file mode 100755 index 0000000000..baffc48825 --- /dev/null +++ b/frappe/core/doctype/sms_settings/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/frappe/core/doctype/sms_settings/sms_settings.js b/frappe/core/doctype/sms_settings/sms_settings.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/sms_settings/sms_settings.json b/frappe/core/doctype/sms_settings/sms_settings.json new file mode 100755 index 0000000000..0898ed389e --- /dev/null +++ b/frappe/core/doctype/sms_settings/sms_settings.json @@ -0,0 +1,267 @@ +{ + "allow_copy": 1, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "beta": 0, + "creation": "2013-01-10 16:34:24", + "custom": 0, + "docstatus": 0, + "doctype": "DocType", + "editable_grid": 0, + "fields": [ + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "column_break0", + "fieldtype": "Column Break", + "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, + "length": 0, + "no_copy": 0, + "permlevel": 0, + "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, + "width": "50%" + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "description": "Eg. smsgateway.com/api/send_sms.cgi", + "fieldname": "sms_gateway_url", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "SMS Gateway URL", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "description": "Enter url parameter for message", + "fieldname": "message_parameter", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Message Parameter", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "description": "Enter url parameter for receiver nos", + "fieldname": "receiver_parameter", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Receiver Parameter", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "sms_sender_name", + "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": "SMS Sender Name", + "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, + "fieldname": "static_parameters_section", + "fieldtype": "Column Break", + "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, + "length": 0, + "no_copy": 0, + "permlevel": 0, + "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, + "width": "50%" + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "description": "Enter static url parameters here (Eg. sender=ERPNext, username=ERPNext, password=1234 etc.)", + "fieldname": "parameters", + "fieldtype": "Table", + "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": "Static Parameters", + "length": 0, + "no_copy": 0, + "options": "SMS Parameter", + "permlevel": 0, + "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 + } + ], + "has_web_view": 0, + "hide_heading": 0, + "hide_toolbar": 0, + "icon": "fa fa-cog", + "idx": 1, + "image_view": 0, + "in_create": 0, + "is_submittable": 0, + "issingle": 1, + "istable": 0, + "max_attachments": 0, + "modified": "2017-07-22 22:52:16.066981", + "modified_by": "chude.osiegbu@manqala.com", + "module": "Core", + "name": "SMS Settings", + "owner": "Administrator", + "permissions": [ + { + "amend": 0, + "apply_user_permissions": 0, + "cancel": 0, + "create": 1, + "delete": 0, + "email": 0, + "export": 0, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 0, + "read": 1, + "report": 0, + "role": "System Manager", + "set_user_permissions": 0, + "share": 1, + "submit": 0, + "write": 1 + } + ], + "quick_entry": 0, + "read_only": 0, + "read_only_onload": 0, + "show_name_in_global_search": 0, + "track_changes": 0, + "track_seen": 0 +} \ No newline at end of file diff --git a/frappe/core/doctype/sms_settings/sms_settings.py b/frappe/core/doctype/sms_settings/sms_settings.py new file mode 100644 index 0000000000..a8b59beffa --- /dev/null +++ b/frappe/core/doctype/sms_settings/sms_settings.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +from frappe import _, throw, msgprint +from frappe.utils import nowdate + +from frappe.model.document import Document + +class SMSSettings(Document): + pass + +def validate_receiver_nos(receiver_list): + validated_receiver_list = [] + for d in receiver_list: + # remove invalid character + for x in [' ', '+', '-', '(', ')']: + d = d.replace(x, '') + + validated_receiver_list.append(d) + + if not validated_receiver_list: + throw(_("Please enter valid mobile nos")) + + return validated_receiver_list + + +def get_sender_name(): + "returns name as SMS sender" + sender_name = frappe.db.get_single_value('SMS Settings', 'sms_sender_name') or \ + 'ERPNXT' + if len(sender_name) > 6 and \ + frappe.db.get_default("country") == "India": + throw("""As per TRAI rule, sender name must be exactly 6 characters. + Kindly change sender name in Setup --> Global Defaults. + Note: Hyphen, space, numeric digit, special characters are not allowed.""") + return sender_name + +@frappe.whitelist() +def get_contact_number(contact_name, ref_doctype, ref_name): + "returns mobile number of the contact" + number = frappe.db.sql("""select mobile_no, phone from tabContact + where name=%s + and exists( + select name from `tabDynamic Link` where link_doctype=%s and link_name=%s + ) + """, (contact_name, ref_doctype, ref_name)) + + return number and (number[0][0] or number[0][1]) or '' + +@frappe.whitelist() +def send_sms(receiver_list, msg, sender_name = '', success_msg = True): + + import json + if isinstance(receiver_list, basestring): + receiver_list = json.loads(receiver_list) + if not isinstance(receiver_list, list): + receiver_list = [receiver_list] + + receiver_list = validate_receiver_nos(receiver_list) + + arg = { + 'receiver_list' : receiver_list, + 'message' : unicode(msg).encode('utf-8'), + 'sender_name' : sender_name or get_sender_name(), + 'success_msg' : success_msg + } + + if frappe.db.get_value('SMS Settings', None, 'sms_gateway_url'): + send_via_gateway(arg) + else: + msgprint(_("Please Update SMS Settings")) + +def send_via_gateway(arg): + ss = frappe.get_doc('SMS Settings', 'SMS Settings') + args = {ss.message_parameter: arg.get('message')} + for d in ss.get("parameters"): + args[d.parameter] = d.value + + success_list = [] + for d in arg.get('receiver_list'): + args[ss.receiver_parameter] = d + status = send_request(ss.sms_gateway_url, args) + + if 200 <= status < 300: + success_list.append(d) + + if len(success_list) > 0: + args.update(arg) + create_sms_log(args, success_list) + if arg.get('success_msg'): + frappe.msgprint(_("SMS sent to following numbers: {0}").format("\n" + "\n".join(success_list))) + + +def send_request(gateway_url, params): + import requests + response = requests.get(gateway_url, params = params, headers={'Accept': "text/plain, text/html, */*"}) + response.raise_for_status() + return response.status_code + + +# Create SMS Log +# ========================================================= +def create_sms_log(args, sent_to): + sl = frappe.new_doc('SMS Log') + sl.sender_name = args['sender_name'] + sl.sent_on = nowdate() + sl.message = args['message'].decode('utf-8') + sl.no_of_requested_sms = len(args['receiver_list']) + sl.requested_numbers = "\n".join(args['receiver_list']) + sl.no_of_sent_sms = len(sent_to) + sl.sent_to = "\n".join(sent_to) + sl.flags.ignore_permissions = True + sl.save() diff --git a/frappe/core/doctype/sms_settings/test_sms_settings.js b/frappe/core/doctype/sms_settings/test_sms_settings.js new file mode 100644 index 0000000000..c090d167f5 --- /dev/null +++ b/frappe/core/doctype/sms_settings/test_sms_settings.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +// rename this file from _test_[name] to test_[name] to activate +// and remove above this line + +QUnit.test("test: SMS Settings", function (assert) { + let done = assert.async(); + + // number of asserts + assert.expect(1); + + frappe.run_serially('SMS Settings', [ + // insert a new SMS Settings + () => frappe.tests.make([ + // values to be set + {key: 'value'} + ]), + () => { + assert.equal(cur_frm.doc.key, 'value'); + }, + () => done() + ]); + +}); diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index bbdf75c085..3305d2c0e2 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -772,6 +772,105 @@ "set_only_once": 0, "unique": 0 }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "OTP App", + "depends_on": "", + "description": "Choose authentication method to be used by all users", + "fieldname": "two_factor_method", + "fieldtype": "Select", + "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 Authentication method", + "length": 0, + "no_copy": 0, + "options": "OTP App\nSMS\nEmail", + "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.two_factor_method == \"OTP App\"", + "description": "Time in seconds to retain QR code image on server. Min:240", + "fieldname": "lifespan_qrcode_image", + "fieldtype": "Int", + "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": "Expiry time of QR Code Image Page", + "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, + "default": "Frappe Framework", + "depends_on": "", + "fieldname": "otp_issuer_name", + "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": "OTP Issuer Name", + "length": 0, + "no_copy": 0, + "options": "", + "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, @@ -1027,7 +1126,7 @@ "issingle": 1, "istable": 0, "max_attachments": 0, - "modified": "2017-07-20 22:57:56.466867", + "modified": "2017-08-04 12:05:08.054099", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index 49c1f8b437..601c7d27ce 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -77,6 +77,15 @@ frappe.ui.form.on('User', { } }) }) + + frm.add_custom_button(__("Reset OTP Secret"), function() { + frappe.call({ + method: "frappe.core.doctype.user.user.reset_otp_secret", + args: { + "user": frm.doc.name + } + }) + }) frm.trigger('enabled'); @@ -111,6 +120,28 @@ frappe.ui.form.on('User', { } cur_frm.dirty(); } + + // frappe.call({ + // method: "get_2fa_params", + // doc:frm.doc, + // callback: function(r) { + // if (r.message){ + // frm.toggle_display('two_factor_method', r.message.show_method_field == true); + // if (r.message.restrict_method){ + // $("select[data-fieldname=two_factor_method] > option").each(function() { + // if ($(this).val() != r.message.restrict_method){ + // $(this).attr('disabled',''); + // } else { + // $(this).removeAttr('disabled') + // } + // }); + //frm.set_df_property('two_factor_method', 'options', [r.message.restrict_method]); + //frm.set_value('two_factor_method',r.message.restrict_method) + //frm.refresh_field('two_factor_method'); + // } + // } + // } + // }); }, validate: function(frm) { if(frm.roles_editor) { diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index 0796ff76fb..31714b7116 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -1971,7 +1971,7 @@ "istable": 0, "max_attachments": 5, "menu_index": 0, - "modified": "2017-07-12 19:24:00.824902", + "modified": "2017-07-07 17:18:14.047969", "modified_by": "Administrator", "module": "Core", "name": "User", diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index c5dfbd0e2a..e7d24baf2e 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -14,6 +14,7 @@ import frappe.share import re from frappe.limits import get_limits from frappe.website.utils import is_signup_enabled +from frappe.utils.background_jobs import enqueue STANDARD_USERS = ("Guest", "Administrator") @@ -586,8 +587,8 @@ def get_email_awaiting(user): return waiting else: frappe.db.sql("""update `tabUser Email` - set awaiting_password =0 - where parent = %(user)s""",{"user":user}) + set awaiting_password =0 + where parent = %(user)s""",{"user":user}) return False @frappe.whitelist(allow_guest=False) @@ -675,7 +676,7 @@ def ask_pass_update(): from frappe.utils import set_default users = frappe.db.sql("""SELECT DISTINCT(parent) as user FROM `tabUser Email` - WHERE awaiting_password = 1""", as_dict=True) + WHERE awaiting_password = 1""", as_dict=True) password_list = [ user.get("user") for user in users ] set_default("email_user_password", u','.join(password_list)) @@ -888,4 +889,84 @@ def handle_password_test_fail(result): def update_gravatar(name): gravatar = has_gravatar(name) if gravatar: - frappe.db.set_value('User', name, 'user_image', gravatar) \ No newline at end of file + frappe.db.set_value('User', name, 'user_image', gravatar) + +@frappe.whitelist(allow_guest=True) +def send_token_via_sms(tmp_id,phone_no=None,user=None): + try: + from frappe.core.doctype.sms_settings.sms_settings import send_request + except: + return False + + if not frappe.cache().ttl(tmp_id + '_token'): + return False + ss = frappe.get_doc('SMS Settings', 'SMS Settings') + if not ss.sms_gateway_url: + return False + + token = frappe.cache().get(tmp_id + '_token') + args = {ss.message_parameter: 'verification code is {}'.format(token)} + + for d in ss.get("parameters"): + args[d.parameter] = d.value + + if user: + user_phone = frappe.db.get_value('User', user, ['phone','mobile_no'], as_dict=1) + usr_phone = user_phone.mobile_no or user_phone.phone + if not usr_phone: + return False + else: + if phone_no: + usr_phone = phone_no + else: + return False + + args[ss.receiver_parameter] = usr_phone + status = send_request(ss.sms_gateway_url, args) + + if 200 <= status < 300: + frappe.cache().delete(tmp_id + '_token') + return True + else: + return False + +@frappe.whitelist(allow_guest=True) +def send_token_via_email(tmp_id,token=None): + import pyotp + + user = frappe.cache().get(tmp_id + '_user') + count = token or frappe.cache().get(tmp_id + '_token') + + if ((not user) or (user == 'None') or (not count)): + return False + user_email = frappe.db.get_value('User',user, 'email') + if not user_email: + return False + + otpsecret = frappe.cache().get(tmp_id + '_otp_secret') + hotp = pyotp.HOTP(otpsecret) + + frappe.sendmail( + recipients=user_email, sender=None, subject='Verification Code', + message='

Your verification code is {0}

'.format(hotp.at(int(count))), + delayed=False, retry=3) + + return True + +@frappe.whitelist(allow_guest=True) +def reset_otp_secret(user): + otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name') + user_email = frappe.db.get_value('User',user, 'email') + if frappe.session.user in ["Administrator", user] : + frappe.defaults.clear_default(user + '_otplogin') + frappe.defaults.clear_default(user + '_otpsecret') + email_args = { + 'recipients':user_email, 'sender':None, 'subject':'OTP Secret Reset - {}'.format(otp_issuer or "Frappe Framework"), + 'message':'

Your OTP secret on {} has been reset. If you did not perform this reset and did not request it, please contact your System Administrator immediately.

'.format(otp_issuer or "Frappe Framework"), + 'delayed':False, + 'retry':3 + } + enqueue(method=frappe.sendmail, queue='short', timeout=300, event=None, async=True, job_name=None, now=False, **email_args) + return frappe.msgprint(_("OTP Secret has been reset. Re-registration will be required on next login.")) + else: + return frappe.throw(_("OTP secret can only be reset by the Administrator.")) \ No newline at end of file diff --git a/frappe/desk/page/setup_wizard/setup_wizard.js b/frappe/desk/page/setup_wizard/setup_wizard.js index f76787e0e3..8431df6f8d 100644 --- a/frappe/desk/page/setup_wizard/setup_wizard.js +++ b/frappe/desk/page/setup_wizard/setup_wizard.js @@ -562,6 +562,36 @@ var frappe_slides = [ } }, }, + { + //Two Factor Select + name:'twofactor', + domains: ["all"], + title: __("Two Factor Authentication"), + icon: "fa fa-flag", + help: __("Setup Two Factor Authentication For Users"), + fields: [ + { fieldname: "twofactor_enable", label: __("Enable Two Factor Authentication"), + fieldtype: "Check"}, + { fieldtype: "Section Break" }, + { fieldname: "twofactor_method", label: __("Select Authentication Method"), + fieldtype: "Select"} + ], + onload:function(slide){ + slide.form.fields_dict.twofactor_method.df.options = ['SMS','Email','OTP App'] + slide.form.fields_dict.twofactor_method.$wrapper.css('display','none'); + slide.get_input('twofactor_enable').change(function(){ + slide.form.fields_dict.twofactor_method.$wrapper.toggle(); + if(this.checked){ + slide.form.fields_dict.twofactor_method.df.reqd = 1; + } + else{ + slide.form.fields_dict.twofactor_method.df.reqd = 0; + } + slide.form.fields_dict.twofactor_method.refresh(); + }); + } + + } ]; var utils = { diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index 8e8fef3359..1b8252477f 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -9,6 +9,7 @@ from frappe.translate import (set_default_language, get_dict, send_translations) from frappe.geo.country_info import get_country_info from frappe.utils.file_manager import save_file from frappe.utils.password import update_password +from frappe.twofactor import toggle_two_factor_auth from werkzeug.useragents import UserAgent import install_fixtures @@ -78,6 +79,9 @@ def update_system_settings(args): 'enable_scheduler': 1 if not frappe.flags.in_test else 0, 'backup_limit': 3 # Default for downloadable backups }) + if args.get("twofactor_enable") == 1: + toggle_two_factor_auth(True,roles=['All']) + system_settings.two_factor_method = args.get('twofactor_method') system_settings.save() def update_user_name(args): @@ -267,3 +271,10 @@ def email_setup_wizard_exception(traceback, args): def get_language_code(lang): return frappe.db.get_value('Language', {'language_name':lang}) + + +def enable_twofactor_all_roles(): + all_role = frappe.get_doc('Role',{'role_name':'All'}) + all_role.two_factor_auth = True + all_role.save(ignore_permissions=True) + diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 723c602496..ae9fca7e7a 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -37,6 +37,9 @@ class SessionStopped(Exception): class UnsupportedMediaType(Exception): http_status_code = 415 +class RequestToken(Exception): + http_status_code = 200 + class Redirect(Exception): http_status_code = 301 diff --git a/frappe/hooks.py b/frappe/hooks.py index 2d28b74d91..bf990a9f72 100755 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -128,7 +128,8 @@ scheduler_events = { "frappe.email.doctype.email_account.email_account.pull", "frappe.email.doctype.email_account.email_account.notify_unreplied", "frappe.oauth.delete_oauth2_data", - "frappe.integrations.doctype.razorpay_settings.razorpay_settings.capture_payment" + "frappe.integrations.doctype.razorpay_settings.razorpay_settings.capture_payment", + "frappe.twofactor.delete_all_barcodes_for_users" ], "hourly": [ "frappe.model.utils.link_count.update_link_count", @@ -189,3 +190,5 @@ bot_parsers = [ setup_wizard_exception = "frappe.desk.page.setup_wizard.setup_wizard.email_setup_wizard_exception" before_write_file = "frappe.limits.validate_space_limit" + +otp_methods = ['OTP App','Email','SMS'] diff --git a/frappe/templates/includes/login/login.js b/frappe/templates/includes/login/login.js index 88eb76daf2..cd65cdb250 100644 --- a/frappe/templates/includes/login/login.js +++ b/frappe/templates/includes/login/login.js @@ -5,11 +5,14 @@ window.disable_signup = {{ disable_signup and "true" or "false" }}; window.login = {}; +window.verify = {}; + login.bind_events = function() { $(window).on("hashchange", function() { login.route(); }); + $(".form-login").on("submit", function(event) { event.preventDefault(); var args = {}; @@ -92,6 +95,11 @@ login.login = function() { $(".for-login").toggle(true); } +login.steptwo = function() { + login.reset_sections(); + $(".for-login").toggle(true); +} + login.forgot = function() { login.reset_sections(); $(".for-forgot").toggle(true); @@ -150,7 +158,7 @@ login.login_handlers = (function() { var login_handlers = { 200: function(data) { - if(data.message=="Logged In") { + if(data.message == 'Logged In'){ login.set_indicator("{{ _("Success") }}", 'green'); window.location.href = get_url_arg("redirect-to") || data.home_page; } else if(data.message=="No App") { @@ -190,15 +198,31 @@ login.login_handlers = (function() { } //login.set_indicator(__(data.message), 'green'); } + + //OTP verification + if(data.verification && data.message != 'Logged In') { + login.set_indicator("{{ _("Success") }}", 'green'); + + document.cookie = "tmp_id="+data.tmp_id; + + if (data.verification.method == 'OTP App'){ + continue_otp_app(data.verification.setup, data.verification.qrcode); + } else if (data.verification.method == 'SMS'){ + continue_sms(data.verification.setup, data.verification.prompt); + } else if (data.verification.method == 'Email'){ + continue_email(data.verification.setup, data.verification.prompt); + } + } }, 401: get_error_handler("{{ _("Invalid Login. Try again.") }}"), 417: get_error_handler("{{ _("Oops! Something went wrong") }}") }; return login_handlers; -})(); +} )(); frappe.ready(function() { + login.bind_events(); if (!window.location.hash) { @@ -210,3 +234,76 @@ frappe.ready(function() { $(".form-signup, .form-forgot").removeClass("hide"); $(document).trigger('login_rendered'); }); + +var verify_token = function(event) { + $(".form-verify").on("submit", function(eventx) { + eventx.preventDefault(); + var args = {}; + args.cmd = "login"; + args.otp = $("#login_token").val(); + args.tmp_id = frappe.get_cookie('tmp_id'); + if(!args.otp) { + frappe.msgprint('{{ _("Login token required") }}'); + return false; + } + login.call(args); + return false; + }); +} + +var request_otp = function(r){ + $('.login-content').empty().append($('
').attr({'id':'twofactor_div'}).html( + '
\ +
\ + Verification\ +
\ +
\ + \ + \ +
')); + // add event handler for submit button + verify_token(); +} + +var continue_otp_app = function(setup, qrcode){ + request_otp(); + var qrcode_div = $('
').attr({'id':'qrcode_div','style':'text-align:center;padding-bottom:15px;'}); + + if (setup){ + direction = $('
').attr('id','qr_info').text('Enter Code displayed in OTP App.'); + qrcode_div.append(direction); + $('#otp_div').prepend(qrcode_div); + } else { + direction = $('
').attr('id','qr_info').text('OTP setup using OTP App was not completed. Please contact Administrator.'); + qrcode_div.append(direction); + $('#otp_div').prepend(qrcode_div); + } +} + +var continue_sms = function(setup, prompt){ + request_otp(); + var sms_div = $('
').attr({'id':'sms_div','style':'padding-bottom:15px;text-align:center;'}); + + if (setup){ + sms_div.append(prompt) + $('#otp_div').prepend(sms_div); + } else { + direction = $('
').attr('id','qr_info').text(prompt || 'SMS was not sent. Please contact Administrator.'); + sms_div.append(direction); + $('#otp_div').prepend(sms_div) + } +} + +var continue_email = function(setup, prompt){ + request_otp(); + var email_div = $('
').attr({'id':'email_div','style':'padding-bottom:15px;text-align:center;'}); + + if (setup){ + email_div.append(prompt) + $('#otp_div').prepend(email_div); + } else { + var direction = $('
').attr('id','qr_info').text(prompt || 'Verification code email not sent. Please contact Administrator.'); + email_div.append(direction); + $('#otp_div').prepend(email_div); + } +} \ No newline at end of file diff --git a/frappe/tests/test_twofactor.py b/frappe/tests/test_twofactor.py new file mode 100644 index 0000000000..900e617360 --- /dev/null +++ b/frappe/tests/test_twofactor.py @@ -0,0 +1,128 @@ +# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt +from __future__ import unicode_literals + +import unittest, frappe, pyotp +from werkzeug.wrappers import Request +from werkzeug.test import EnvironBuilder +from frappe.auth import LoginManager, HTTPRequest +from frappe.twofactor import * +import time + + + +class TestTwoFactor(unittest.TestCase): + + + def setUp(self): + self.http_requests = create_http_request() + self.login_manager = frappe.local.login_manager + self.user = self.login_manager.user + + def tearDown(self): + tmp_id = frappe.local.response['tmp_id'] + frappe.local.response['verification'] = None + frappe.local.response['tmp_id'] = None + frappe.clear_cache(user=self.user) + + + def test_should_run_2fa(self): + '''Should return true if enabled.''' + toggle_2fa_all_role(state=True) + self.assertTrue(should_run_2fa(self.user)) + toggle_2fa_all_role(state=False) + self.assertFalse(should_run_2fa(self.user)) + + def test_get_cached_user_pass(self): + '''Cached data should not contain user and pass before 2fa.''' + user,pwd = get_cached_user_pass() + self.assertTrue(all([not user, not pwd])) + + def test_authenticate_for_2factor(self): + '''Verification obj and tmp_id should be set in frappe.local.''' + authenticate_for_2factor(self.user) + verification_obj = frappe.local.response['verification'] + tmp_id = frappe.local.response['tmp_id'] + self.assertTrue(verification_obj) + self.assertTrue(tmp_id) + for k in ['_usr','_pwd','_otp_secret']: + self.assertTrue(frappe.cache().get('{0}{1}'.format(tmp_id,k)), + '{} not available'.format(k)) + + def test_two_factor_is_enabled_for_user(self): + '''Should return true if enabled for user.''' + toggle_2fa_all_role(state=True) + self.assertTrue(two_factor_is_enabled_for_(self.user)) + toggle_2fa_all_role(state=False) + self.assertFalse(two_factor_is_enabled_for_(self.user)) + + def test_get_otpsecret_for_user(self): + '''OTP secret should be set for user.''' + self.assertTrue(get_otpsecret_for_(self.user)) + self.assertTrue(frappe.db.get_default(self.user + '_otpsecret')) + + def test_confirm_otp_token(self): + '''Ensure otp is confirmed''' + authenticate_for_2factor(self.user) + tmp_id = frappe.local.response['tmp_id'] + otp = 'wrongotp' + with self.assertRaises(frappe.AuthenticationError): + confirm_otp_token(self.login_manager,otp=otp,tmp_id=tmp_id) + otp = get_otp(self.user) + self.assertTrue(confirm_otp_token(self.login_manager,otp=otp,tmp_id=tmp_id)) + if frappe.flags.tests_verbose: + print('Sleeping for 30secs to confirm token expires..') + time.sleep(30) + with self.assertRaises(frappe.AuthenticationError): + confirm_otp_token(self.login_manager,otp=otp,tmp_id=tmp_id) + + def test_get_verification_obj(self): + '''Confirm verification object is returned.''' + otp_secret = get_otpsecret_for_(self.user) + token = int(pyotp.TOTP(otp_secret).now()) + self.assertTrue(get_verification_obj(self.user,token,otp_secret)) + + def test_render_string_template(self): + '''String template renders as expected with variables.''' + args = {'issuer_name':'Frappe Technologies'} + _str = 'Verification Code from {{issuer_name}}' + _str = render_string_template(_str,args) + self.assertEqual(_str,'Verification Code from Frappe Technologies') + + +def set_request(**kwargs): + builder = EnvironBuilder(**kwargs) + frappe.local.request = Request(builder.get_environ()) + +def create_http_request(): + '''Get http request object.''' + set_request(method='POST', path='login') + enable_2fa() + frappe.form_dict['usr'] = 'test@erpnext.com' + frappe.form_dict['pwd'] = 'test' + frappe.local.form_dict['cmd'] = 'login' + http_requests = HTTPRequest() + return http_requests + +def enable_2fa(): + '''Enable Two factor in system settings.''' + toggle_two_factor_auth(True) + system_settings = frappe.get_doc('System Settings') + system_settings.two_factor_method = 'OTP App' + system_settings.save(ignore_permissions=True) + frappe.db.commit() + +def toggle_2fa_all_role(state=None): + '''Enable or disable 2fa for 'all' role on the system.''' + all_role = frappe.get_doc('Role','All') + if state == None: + state = False if all_role.two_factor_auth == True else False + if state not in [True,False]:return + all_role.two_factor_auth = state + all_role.save(ignore_permissions=True) + frappe.db.commit() + +def get_otp(user): + otp_secret = get_otpsecret_for_(user) + otp = pyotp.TOTP(otp_secret) + return otp.now() \ No newline at end of file diff --git a/frappe/twofactor.py b/frappe/twofactor.py new file mode 100644 index 0000000000..44e3fb24df --- /dev/null +++ b/frappe/twofactor.py @@ -0,0 +1,358 @@ +# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals + +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 +from frappe.installer import update_site_config + + +class ExpiredLoginException(Exception):pass + + +def toggle_two_factor_auth(state,roles=[]): + '''Enable or disable 2FA in site_config and roles''' + update_site_config('enable_two_factor_auth',state) + for role in roles: + role = frappe.get_doc('Role',{'role_name':role}) + role.two_factor_auth = state + role.save(ignore_permissions=True) + + +def two_factor_is_enabled(user=None): + '''Returns True if 2FA is enabled.''' + enabled = frappe.local.conf.get('enable_two_factor_auth',False) + if not user or not enabled: + return enabled + return two_factor_is_enabled_for_(user) + +def should_run_2fa(user): + '''Check if 2fa should run.''' + return two_factor_is_enabled(user=user) + + +def get_cached_user_pass(): + '''Get user and password if set.''' + user = pwd = None + tmp_id = frappe.form_dict.get('tmp_id') + if tmp_id: + user = frappe.cache().get(tmp_id+'_usr') + pwd = frappe.cache().get(tmp_id+'_pwd') + return (user,pwd) + + +def authenticate_for_2factor(user): + '''Authenticate two factor for enabled user before login.''' + if frappe.form_dict.get('otp'):return + otp_secret = get_otpsecret_for_(user) + verification_method = frappe.db.get_value('System Settings', None, 'two_factor_method') + token = int(pyotp.TOTP(otp_secret).now()) + tmp_id = frappe.generate_hash(length=8) + cache_2fa_data(user,token,otp_secret,tmp_id) + verification_obj = get_verification_obj(user,token,otp_secret) + # Save data in local + frappe.local.response['verification'] = verification_obj + frappe.local.response['tmp_id'] = tmp_id + +def cache_2fa_data(user,token,otp_secret,tmp_id): + '''Cache and set expiry for data.''' + pwd = frappe.form_dict.get('pwd') + verification_method = get_verification_method() + + # set increased expiry time for SMS and Email + if verification_method in ['SMS', 'Email']: + expiry_time = 300 + frappe.cache().set(tmp_id + '_token', token) + frappe.cache().expire(tmp_id + '_token', expiry_time) + else: + expiry_time = 180 + for k,v in {'_usr':user,'_pwd':pwd,'_otp_secret':otp_secret}.iteritems(): + frappe.cache().set("{0}{1}".format(tmp_id,k),v) + frappe.cache().expire("{0}{1}".format(tmp_id,k),expiry_time) + +def two_factor_is_enabled_for_(user): + '''Check if 2factor is enabled for user.''' + if isinstance(user,basestring): + user = frappe.get_doc('User',user) + if user.roles: + query = """select name from `tabRole` where two_factor_auth=1 + and name in ("All",{0});""".format(', '.join('\"{}\"'.format(i.role) for \ + i in user.roles)) + if len(frappe.db.sql(query)) > 0: + return True + return False + +def get_otpsecret_for_(user): + '''Set OTP Secret for user even if not set.''' + otp_secret = frappe.db.get_default(user + '_otpsecret') + if not otp_secret: + otp_secret = b32encode(os.urandom(10)).decode('utf-8') + frappe.db.set_default(user + '_otpsecret', otp_secret) + frappe.db.commit() + return otp_secret + +def get_verification_method(): + return frappe.db.get_value('System Settings', None, 'two_factor_method') + + + +def confirm_otp_token(login_manager,otp=None,tmp_id=None): + '''Confirm otp matches.''' + if not otp: + otp = frappe.form_dict.get('otp') + if not otp: + if two_factor_is_enabled_for_(login_manager.user): + return False + return True + if not tmp_id: + tmp_id = frappe.form_dict.get('tmp_id') + hotp_token = frappe.cache().get(tmp_id + '_token') + otp_secret = frappe.cache().get(tmp_id + '_otp_secret') + if not otp_secret: + raise ExpiredLoginException(_('Login session expired, refresh page to retry')) + hotp = pyotp.HOTP(otp_secret) + if hotp_token: + if hotp.verify(otp, int(hotp_token)): + frappe.cache().delete(tmp_id + '_token') + return True + else: + login_manager.fail(_('Incorrect Verification code'), login_manager.user) + + totp = pyotp.TOTP(otp_secret) + if totp.verify(otp): + # show qr code only once + if not frappe.db.get_default(login_manager.user + '_otplogin'): + frappe.db.set_default(login_manager.user + '_otplogin', 1) + delete_qrimage(login_manager.user) + return True + else: + login_manager.fail(_('Incorrect Verification code'), login_manager.user) + + +def get_verification_obj(user,token,otp_secret): + otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name') + verification_method = get_verification_method() + verification_obj = None + if verification_method == 'SMS': + verification_obj = process_2fa_for_sms(user,token,otp_secret) + elif verification_method == 'OTP App': + #check if this if the first time that the user is trying to login. If so, send an email + if not frappe.db.get_default(user + '_otplogin'): + verification_obj = process_2fa_for_email(user,token,otp_secret,otp_issuer,method='OTP App') + else: + verification_obj = process_2fa_for_otp_app(user,otp_secret,otp_issuer) + elif verification_method == 'Email': + verification_obj = process_2fa_for_email(user,token,otp_secret,otp_issuer) + return verification_obj + + +def process_2fa_for_sms(user,token,otp_secret): + '''Process sms method for 2fa.''' + phone = frappe.db.get_value('User', user, ['phone','mobile_no'], as_dict=1) + phone = phone.mobile_no or phone.phone + status = send_token_via_sms(otp_secret,token=token, phone_no=phone) + verification_obj = {'token_delivery': status, + 'prompt': status and 'Enter verification code sent to {}'.format(phone[:4] + '******' + phone[-3:]), + 'method': 'SMS', + 'setup': status} + return verification_obj + +def process_2fa_for_otp_app(user,otp_secret,otp_issuer): + '''Process OTP App method for 2fa.''' + totp_uri = pyotp.TOTP(otp_secret).provisioning_uri(user, issuer_name=otp_issuer) + if frappe.db.get_default(user + '_otplogin'): + otp_setup_completed = True + else: + otp_setup_completed = False + + verification_obj = {'totp_uri': totp_uri, + 'method': 'OTP App', + 'qrcode': get_qr_svg_code(totp_uri), + 'setup': otp_setup_completed } + return verification_obj + +def process_2fa_for_email(user,token,otp_secret,otp_issuer,method='Email'): + '''Process Email method for 2fa.''' + subject = None + message = None + status = True + prompt = '' + if method == 'OTP App' and not frappe.db.get_default(user + '_otplogin'): + '''Sending one-time email for OTP App''' + totp_uri = pyotp.TOTP(otp_secret).provisioning_uri(user, issuer_name=otp_issuer) + 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}) + prompt = 'Please check your registered email address for instructions on how to proceed. Do not close this window as you will have to return to it!!' + else: + '''Sending email verification''' + prompt = 'Verification code has been sent to your registered email address.' + status = send_token_via_email(user,token,otp_secret,otp_issuer,subject=subject,message=message) + verification_obj = {'token_delivery': status, + 'prompt': status and prompt, + 'method': 'Email', + 'setup': status} + return verification_obj + +def get_email_subject_for_2fa(kwargs_dict): + '''Get email subject for 2fa.''' + subject_template = 'Verification Code from {}'.format(frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name')) + 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}}' + 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 = 'OTP Registration Code from {}'.format(frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name')) + 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 = 'Please click on the following link and follow the instructions on the page.
{{qrcode_link}}' + 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_qrcode_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.''' + otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name') + try: + from frappe.core.doctype.sms_settings.sms_settings import send_request + except: + return False + + if not phone_no: + return False + + ss = frappe.get_doc('SMS Settings', 'SMS Settings') + if not ss.sms_gateway_url: + return False + + hotp = pyotp.HOTP(otpsecret) + args = {ss.message_parameter: 'Your verification code is {}'.format(hotp.at(int(token))), ss.sms_sender_name: otp_issuer} + for d in ss.get("parameters"): + args[d.parameter] = d.value + + args[ss.receiver_parameter] = phone_no + + sms_args = {'gateway_url':ss.sms_gateway_url,'params':args} + 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,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 = get_email_body_for_2fa(template_args) + email_args = { + 'recipients':user_email, 'sender':None, 'subject':subject, + 'message':message, + 'delayed':False, 'retry':3 } + + enqueue(method=frappe.sendmail, queue='short', timeout=300, event=None, async=True, job_name=None, now=False, **email_args) + return True + +def get_qr_svg_code(totp_uri): + '''Get SVG code to display Qrcode for OTP.''' + url = qrcreate(totp_uri) + svg = '' + stream = StringIO() + try: + url.svg(stream, scale=4, background="#eee", module_color="#222") + svg = stream.getvalue().replace('\n','') + svg = b64encode(bytes(svg)) + finally: + stream.close() + return svg + +def qrcode_as_png(user,totp_uri): + '''Save temporary Qrcode to server.''' + from frappe.utils.file_manager import save_file + folder = create_barcode_folder() + png_file_name = '{}.png'.format(frappe.generate_hash(length=20)) + file_obj = save_file(png_file_name,png_file_name,'User',user,folder=folder) + frappe.db.commit() + file_url = get_url(file_obj.file_url) + file_path = os.path.join(frappe.get_site_path('public', 'files'),file_obj.file_name) + url = qrcreate(totp_uri) + with open(file_path,'w') as png_file: + url.png(png_file,scale=8, module_color=[0, 0, 0, 180], background=[0xff, 0xff, 0xcc]) + return file_url + +def create_barcode_folder(): + '''Get Barcodes folder.''' + folder_name = 'Barcodes' + folder = frappe.db.exists('File',{'file_name':folder_name}) + if folder: + return folder + folder = frappe.get_doc({ + 'doctype':'File', + 'file_name':folder_name, + 'is_folder':1, + 'folder':'Home' + }) + folder.insert(ignore_permissions=True) + return folder.name + +def delete_qrimage(user,check_expiry=False): + '''Delete Qrimage when user logs in.''' + user_barcodes = frappe.get_all('File',{'attached_to_doctype':'User', + 'attached_to_name':user,'folder':'Home/Barcodes'}) + for barcode in user_barcodes: + if check_expiry and not should_remove_barcode_image(barcode):continue + barcode = frappe.get_doc('File',barcode.name) + frappe.delete_doc('File',barcode.name,ignore_permissions=True) + +def delete_all_barcodes_for_users(): + '''Task to delete all barcodes for user.''' + users = frappe.get_all('User',{'enabled':1}) + for user in users: + delete_qrimage(user.name,check_expiry=True) + +def should_remove_barcode_image(barcode): + '''Check if it's time to delete barcode image from server. ''' + if isinstance(barcode, basestring): + barcode = frappe.get_doc('File',barcode) + lifespan = frappe.db.get_value('System Settings', 'System Settings', 'lifespan_qrcode_image') + if time_diff_in_seconds(get_datetime(),barcode.creation) > int(lifespan): + return True + return False + diff --git a/frappe/www/login.py b/frappe/www/login.py index cc149abbec..5002a44b35 100644 --- a/frappe/www/login.py +++ b/frappe/www/login.py @@ -68,4 +68,3 @@ def login_via_token(login_token): frappe.local.login_manager = LoginManager() redirect_post_login(desk_user = frappe.db.get_value("User", frappe.session.user, "user_type")=="System User") - diff --git a/frappe/www/qrcode.html b/frappe/www/qrcode.html new file mode 100644 index 0000000000..3b9c81c531 --- /dev/null +++ b/frappe/www/qrcode.html @@ -0,0 +1,31 @@ +{% extends "templates/web.html" %} + +{% block title %}Register OTP Secret{% endblock %} + +{% block page_content %} +
+
+ + + + + +
+
+

+ Hi {{qr_code_user.first_name}}, please perform the following actions: +

  • Open your authentication app on your mobile phone, +
  • Scan the QR Code and enter the resulting code displayed +
  • Return to the Verification screen and enter the code displayed by your authentication app +

    +

    Examples of Authentication Apps you can use are Google Authenticator, Lastpass Authenticator, Authy and Duo Mobile. +

    +
  • +
    +
    + +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/frappe/www/qrcode.py b/frappe/www/qrcode.py new file mode 100644 index 0000000000..636eabac35 --- /dev/null +++ b/frappe/www/qrcode.py @@ -0,0 +1,39 @@ +# 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) diff --git a/requirements.txt b/requirements.txt index 5beb2ecc3c..0f6a4ef421 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,8 @@ oauthlib PyJWT pypdf openpyxl +pyotp +pyqrcode +pypng premailer +