@@ -16,9 +16,15 @@ from frappe.modules.patch_handler import check_session_stopped | |||||
from frappe.translate import get_lang_code | from frappe.translate import get_lang_code | ||||
from frappe.utils.password import check_password | from frappe.utils.password import check_password | ||||
from frappe.core.doctype.authentication_log.authentication_log import add_authentication_log | 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 | from six.moves.urllib.parse import quote | ||||
import pyotp,base64,os | |||||
class HTTPRequest: | class HTTPRequest: | ||||
def __init__(self): | def __init__(self): | ||||
# Get Environment variables | # Get Environment variables | ||||
@@ -62,6 +68,7 @@ class HTTPRequest: | |||||
def validate_csrf_token(self): | def validate_csrf_token(self): | ||||
if frappe.local.request and frappe.local.request.method=="POST": | if frappe.local.request and frappe.local.request.method=="POST": | ||||
if not frappe.local.session:return | |||||
if not frappe.local.session.data.csrf_token \ | if not frappe.local.session.data.csrf_token \ | ||||
or frappe.local.session.data.device=="mobile" \ | or frappe.local.session.data.device=="mobile" \ | ||||
or frappe.conf.get('ignore_csrf', None): | or frappe.conf.get('ignore_csrf', None): | ||||
@@ -98,7 +105,7 @@ class LoginManager: | |||||
self.user_type = None | self.user_type = None | ||||
if frappe.local.form_dict.get('cmd')=='login' or frappe.local.request.path=="/api/method/login": | 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 | self.resume = False | ||||
# run login triggers | # run login triggers | ||||
@@ -113,12 +120,20 @@ class LoginManager: | |||||
self.make_session() | self.make_session() | ||||
self.set_user_info() | self.set_user_info() | ||||
def login(self): | def login(self): | ||||
# clear cache | # clear cache | ||||
frappe.clear_cache(user = frappe.form_dict.get('usr')) | 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() | self.post_login() | ||||
def post_login(self): | def post_login(self): | ||||
self.run_trigger('on_login') | self.run_trigger('on_login') | ||||
self.validate_ip_address() | self.validate_ip_address() | ||||
@@ -302,6 +317,7 @@ class CookieManager: | |||||
for key in set(self.to_delete): | for key in set(self.to_delete): | ||||
response.set_cookie(key, "", expires=expires) | response.set_cookie(key, "", expires=expires) | ||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def get_logged_user(): | def get_logged_user(): | ||||
return frappe.session.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) | home_page = frappe.get_attr(home_page_method[-1])(user) | ||||
return '/' + home_page.strip('/') | return '/' + home_page.strip('/') | ||||
else: | else: | ||||
return '/me' | |||||
return '/me' |
@@ -105,6 +105,37 @@ | |||||
"set_only_once": 0, | "set_only_once": 0, | ||||
"unique": 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_bulk_edit": 0, | ||||
"allow_on_submit": 0, | "allow_on_submit": 0, | ||||
@@ -148,7 +179,7 @@ | |||||
"issingle": 0, | "issingle": 0, | ||||
"istable": 0, | "istable": 0, | ||||
"max_attachments": 0, | "max_attachments": 0, | ||||
"modified": "2017-05-04 11:03:41.533058", | |||||
"modified": "2017-07-06 12:42:57.097914", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Core", | "module": "Core", | ||||
"name": "Role", | "name": "Role", | ||||
@@ -0,0 +1 @@ | |||||
SMS query parameter for SMS Settings. |
@@ -0,0 +1 @@ | |||||
from __future__ import unicode_literals |
@@ -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 | |||||
} |
@@ -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 |
@@ -0,0 +1 @@ | |||||
Settings for automatically sending SMS from the system. |
@@ -0,0 +1 @@ | |||||
from __future__ import unicode_literals |
@@ -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 | |||||
} |
@@ -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() |
@@ -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() | |||||
]); | |||||
}); |
@@ -772,6 +772,105 @@ | |||||
"set_only_once": 0, | "set_only_once": 0, | ||||
"unique": 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:<strong>240</strong>", | |||||
"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_bulk_edit": 0, | ||||
"allow_on_submit": 0, | "allow_on_submit": 0, | ||||
@@ -1027,7 +1126,7 @@ | |||||
"issingle": 1, | "issingle": 1, | ||||
"istable": 0, | "istable": 0, | ||||
"max_attachments": 0, | "max_attachments": 0, | ||||
"modified": "2017-07-20 22:57:56.466867", | |||||
"modified": "2017-08-04 12:05:08.054099", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Core", | "module": "Core", | ||||
"name": "System Settings", | "name": "System Settings", | ||||
@@ -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'); | frm.trigger('enabled'); | ||||
@@ -111,6 +120,28 @@ frappe.ui.form.on('User', { | |||||
} | } | ||||
cur_frm.dirty(); | 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) { | validate: function(frm) { | ||||
if(frm.roles_editor) { | if(frm.roles_editor) { | ||||
@@ -1971,7 +1971,7 @@ | |||||
"istable": 0, | "istable": 0, | ||||
"max_attachments": 5, | "max_attachments": 5, | ||||
"menu_index": 0, | "menu_index": 0, | ||||
"modified": "2017-07-12 19:24:00.824902", | |||||
"modified": "2017-07-07 17:18:14.047969", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Core", | "module": "Core", | ||||
"name": "User", | "name": "User", | ||||
@@ -14,6 +14,7 @@ import frappe.share | |||||
import re | import re | ||||
from frappe.limits import get_limits | from frappe.limits import get_limits | ||||
from frappe.website.utils import is_signup_enabled | from frappe.website.utils import is_signup_enabled | ||||
from frappe.utils.background_jobs import enqueue | |||||
STANDARD_USERS = ("Guest", "Administrator") | STANDARD_USERS = ("Guest", "Administrator") | ||||
@@ -586,8 +587,8 @@ def get_email_awaiting(user): | |||||
return waiting | return waiting | ||||
else: | else: | ||||
frappe.db.sql("""update `tabUser Email` | 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 | return False | ||||
@frappe.whitelist(allow_guest=False) | @frappe.whitelist(allow_guest=False) | ||||
@@ -675,7 +676,7 @@ def ask_pass_update(): | |||||
from frappe.utils import set_default | from frappe.utils import set_default | ||||
users = frappe.db.sql("""SELECT DISTINCT(parent) as user FROM `tabUser Email` | 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 ] | password_list = [ user.get("user") for user in users ] | ||||
set_default("email_user_password", u','.join(password_list)) | set_default("email_user_password", u','.join(password_list)) | ||||
@@ -888,4 +889,84 @@ def handle_password_test_fail(result): | |||||
def update_gravatar(name): | def update_gravatar(name): | ||||
gravatar = has_gravatar(name) | gravatar = has_gravatar(name) | ||||
if gravatar: | if gravatar: | ||||
frappe.db.set_value('User', name, 'user_image', gravatar) | |||||
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='<p>Your verification code is {0}</p>'.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':'<p>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.</p>'.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.")) |
@@ -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 = { | var utils = { | ||||
@@ -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.geo.country_info import get_country_info | ||||
from frappe.utils.file_manager import save_file | from frappe.utils.file_manager import save_file | ||||
from frappe.utils.password import update_password | from frappe.utils.password import update_password | ||||
from frappe.twofactor import toggle_two_factor_auth | |||||
from werkzeug.useragents import UserAgent | from werkzeug.useragents import UserAgent | ||||
import install_fixtures | import install_fixtures | ||||
@@ -78,6 +79,9 @@ def update_system_settings(args): | |||||
'enable_scheduler': 1 if not frappe.flags.in_test else 0, | 'enable_scheduler': 1 if not frappe.flags.in_test else 0, | ||||
'backup_limit': 3 # Default for downloadable backups | '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() | system_settings.save() | ||||
def update_user_name(args): | def update_user_name(args): | ||||
@@ -267,3 +271,10 @@ def email_setup_wizard_exception(traceback, args): | |||||
def get_language_code(lang): | def get_language_code(lang): | ||||
return frappe.db.get_value('Language', {'language_name':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) | |||||
@@ -37,6 +37,9 @@ class SessionStopped(Exception): | |||||
class UnsupportedMediaType(Exception): | class UnsupportedMediaType(Exception): | ||||
http_status_code = 415 | http_status_code = 415 | ||||
class RequestToken(Exception): | |||||
http_status_code = 200 | |||||
class Redirect(Exception): | class Redirect(Exception): | ||||
http_status_code = 301 | http_status_code = 301 | ||||
@@ -128,7 +128,8 @@ scheduler_events = { | |||||
"frappe.email.doctype.email_account.email_account.pull", | "frappe.email.doctype.email_account.email_account.pull", | ||||
"frappe.email.doctype.email_account.email_account.notify_unreplied", | "frappe.email.doctype.email_account.email_account.notify_unreplied", | ||||
"frappe.oauth.delete_oauth2_data", | "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": [ | "hourly": [ | ||||
"frappe.model.utils.link_count.update_link_count", | "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" | setup_wizard_exception = "frappe.desk.page.setup_wizard.setup_wizard.email_setup_wizard_exception" | ||||
before_write_file = "frappe.limits.validate_space_limit" | before_write_file = "frappe.limits.validate_space_limit" | ||||
otp_methods = ['OTP App','Email','SMS'] |
@@ -5,11 +5,14 @@ window.disable_signup = {{ disable_signup and "true" or "false" }}; | |||||
window.login = {}; | window.login = {}; | ||||
window.verify = {}; | |||||
login.bind_events = function() { | login.bind_events = function() { | ||||
$(window).on("hashchange", function() { | $(window).on("hashchange", function() { | ||||
login.route(); | login.route(); | ||||
}); | }); | ||||
$(".form-login").on("submit", function(event) { | $(".form-login").on("submit", function(event) { | ||||
event.preventDefault(); | event.preventDefault(); | ||||
var args = {}; | var args = {}; | ||||
@@ -92,6 +95,11 @@ login.login = function() { | |||||
$(".for-login").toggle(true); | $(".for-login").toggle(true); | ||||
} | } | ||||
login.steptwo = function() { | |||||
login.reset_sections(); | |||||
$(".for-login").toggle(true); | |||||
} | |||||
login.forgot = function() { | login.forgot = function() { | ||||
login.reset_sections(); | login.reset_sections(); | ||||
$(".for-forgot").toggle(true); | $(".for-forgot").toggle(true); | ||||
@@ -150,7 +158,7 @@ login.login_handlers = (function() { | |||||
var login_handlers = { | var login_handlers = { | ||||
200: function(data) { | 200: function(data) { | ||||
if(data.message=="Logged In") { | |||||
if(data.message == 'Logged In'){ | |||||
login.set_indicator("{{ _("Success") }}", 'green'); | login.set_indicator("{{ _("Success") }}", 'green'); | ||||
window.location.href = get_url_arg("redirect-to") || data.home_page; | window.location.href = get_url_arg("redirect-to") || data.home_page; | ||||
} else if(data.message=="No App") { | } else if(data.message=="No App") { | ||||
@@ -190,15 +198,31 @@ login.login_handlers = (function() { | |||||
} | } | ||||
//login.set_indicator(__(data.message), 'green'); | //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.") }}"), | 401: get_error_handler("{{ _("Invalid Login. Try again.") }}"), | ||||
417: get_error_handler("{{ _("Oops! Something went wrong") }}") | 417: get_error_handler("{{ _("Oops! Something went wrong") }}") | ||||
}; | }; | ||||
return login_handlers; | return login_handlers; | ||||
})(); | |||||
} )(); | |||||
frappe.ready(function() { | frappe.ready(function() { | ||||
login.bind_events(); | login.bind_events(); | ||||
if (!window.location.hash) { | if (!window.location.hash) { | ||||
@@ -210,3 +234,76 @@ frappe.ready(function() { | |||||
$(".form-signup, .form-forgot").removeClass("hide"); | $(".form-signup, .form-forgot").removeClass("hide"); | ||||
$(document).trigger('login_rendered'); | $(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($('<div>').attr({'id':'twofactor_div'}).html( | |||||
'<form class="form-verify">\ | |||||
<div class="page-card-head">\ | |||||
<span class="indicator blue" data-text="Verification">Verification</span>\ | |||||
</div>\ | |||||
<div id="otp_div"></div>\ | |||||
<input type="text" id="login_token" autocomplete="off" class="form-control" placeholder="Verification Code" required="" autofocus="">\ | |||||
<button class="btn btn-sm btn-primary btn-block" id="verify_token">Verify</button>\ | |||||
</form>')); | |||||
// add event handler for submit button | |||||
verify_token(); | |||||
} | |||||
var continue_otp_app = function(setup, qrcode){ | |||||
request_otp(); | |||||
var qrcode_div = $('<div>').attr({'id':'qrcode_div','style':'text-align:center;padding-bottom:15px;'}); | |||||
if (setup){ | |||||
direction = $('<div>').attr('id','qr_info').text('Enter Code displayed in OTP App.'); | |||||
qrcode_div.append(direction); | |||||
$('#otp_div').prepend(qrcode_div); | |||||
} else { | |||||
direction = $('<div>').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 = $('<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 = $('<div>').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 = $('<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 = $('<div>').attr('id','qr_info').text(prompt || 'Verification code email not sent. Please contact Administrator.'); | |||||
email_div.append(direction); | |||||
$('#otp_div').prepend(email_div); | |||||
} | |||||
} |
@@ -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() |
@@ -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 <br> {{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.<br> {{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 | |||||
@@ -68,4 +68,3 @@ def login_via_token(login_token): | |||||
frappe.local.login_manager = LoginManager() | frappe.local.login_manager = LoginManager() | ||||
redirect_post_login(desk_user = frappe.db.get_value("User", frappe.session.user, "user_type")=="System User") | redirect_post_login(desk_user = frappe.db.get_value("User", frappe.session.user, "user_type")=="System User") | ||||
@@ -0,0 +1,31 @@ | |||||
{% extends "templates/web.html" %} | |||||
{% block title %}Register OTP Secret{% endblock %} | |||||
{% block page_content %} | |||||
<div> | |||||
<div style="text-align:center"> | |||||
<table> | |||||
<tr> | |||||
<td width="50%"> | |||||
<div style="margin:auto;text-align:left;"> | |||||
<p> | |||||
Hi {{qr_code_user.first_name}}, please perform the following actions: | |||||
<li> Open your authentication app on your mobile phone, | |||||
<li> Scan the QR Code and enter the resulting code displayed | |||||
<li> Return to the Verification screen and enter the code displayed by your authentication app | |||||
</p> | |||||
<p>Examples of Authentication Apps you can use are Google Authenticator, Lastpass Authenticator, Authy and Duo Mobile. | |||||
</p> | |||||
</div> | |||||
</td> | |||||
<td> | |||||
<div style="padding:10px;"> | |||||
<img src="data:image/svg+xml;base64,{{qrcode_svg}}"> | |||||
</div> | |||||
</td> | |||||
</tr> | |||||
</table> | |||||
</div> | |||||
</div> | |||||
{% endblock %} |
@@ -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) |
@@ -41,4 +41,8 @@ oauthlib | |||||
PyJWT | PyJWT | ||||
pypdf | pypdf | ||||
openpyxl | openpyxl | ||||
pyotp | |||||
pyqrcode | |||||
pypng | |||||
premailer | premailer | ||||