Two Factor Authenticationversion-14
@@ -16,9 +16,14 @@ 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 +67,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): | |||
@@ -88,7 +94,7 @@ class HTTPRequest: | |||
def connect(self, ac_name = None): | |||
"""connect to db, from ac_name or db_name""" | |||
frappe.local.db = frappe.database.Database(user = self.get_db_name(), \ | |||
password = getattr(conf,'db_password', '')) | |||
password = getattr(conf, 'db_password', '')) | |||
class LoginManager: | |||
def __init__(self): | |||
@@ -98,7 +104,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 | |||
@@ -116,7 +122,12 @@ class LoginManager: | |||
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): | |||
@@ -183,7 +194,7 @@ class LoginManager: | |||
if not (user and pwd): | |||
user, pwd = frappe.form_dict.get('usr'), frappe.form_dict.get('pwd') | |||
if not (user and pwd): | |||
self.fail('Incomplete login details', user=user) | |||
self.fail(_('Incomplete login details'), user=user) | |||
if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number")): | |||
user = frappe.db.get_value("User", filters={"mobile_no": user}, fieldname="name") or user | |||
@@ -205,7 +216,9 @@ class LoginManager: | |||
except frappe.AuthenticationError: | |||
self.fail('Incorrect password', user=user) | |||
def fail(self, message, user="NA"): | |||
def fail(self, message, user=None): | |||
if not user: | |||
user = _('Unknown User') | |||
frappe.local.response['message'] = message | |||
add_authentication_log(message, user, status="Failed") | |||
frappe.db.commit() | |||
@@ -302,6 +315,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 +331,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' |
@@ -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", | |||
@@ -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() | |||
]); | |||
}); |
@@ -895,6 +895,165 @@ | |||
"set_only_once": 0, | |||
"unique": 0 | |||
}, | |||
{ | |||
"allow_bulk_edit": 0, | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 1, | |||
"columns": 0, | |||
"fieldname": "two_factor_authentication", | |||
"fieldtype": "Section 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, | |||
"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, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "enable_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": "Enable Two Factor Auth", | |||
"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": "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": "enable_two_factor_auth", | |||
"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 +1186,7 @@ | |||
"issingle": 1, | |||
"istable": 0, | |||
"max_attachments": 0, | |||
"modified": "2017-07-20 22:57:56.466867", | |||
"modified": "2017-08-07 23:29:18.858797", | |||
"modified_by": "Administrator", | |||
"module": "Core", | |||
"name": "System Settings", | |||
@@ -9,6 +9,7 @@ from frappe.model import no_value_fields | |||
from frappe.translate import set_default_language | |||
from frappe.utils import cint | |||
from frappe.utils.momentjs import get_all_timezones | |||
from frappe.twofactor import toggle_two_factor_auth | |||
class SystemSettings(Document): | |||
def validate(self): | |||
@@ -25,6 +26,12 @@ class SystemSettings(Document): | |||
if len(parts)!=2 or not (cint(parts[0]) or cint(parts[1])): | |||
frappe.throw(_("Session Expiry must be in format {0}").format("hh:mm")) | |||
if self.enable_two_factor_auth: | |||
if self.two_factor_method=='SMS': | |||
if not frappe.db.get_value('SMS Settings', None, 'sms_gateway_url'): | |||
frappe.throw(_('Please setup SMS before setting it as an authentication method, via SMS Settings')) | |||
toggle_two_factor_auth(True, roles=['All']) | |||
def on_update(self): | |||
for df in self.meta.get("fields"): | |||
if df.fieldtype not in no_value_fields: | |||
@@ -78,6 +78,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.roles_editor && frm.roles_editor.show(); | |||
@@ -111,6 +120,7 @@ frappe.ui.form.on('User', { | |||
} | |||
cur_frm.dirty(); | |||
} | |||
}, | |||
validate: function(frm) { | |||
if(frm.roles_editor) { | |||
@@ -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", | |||
@@ -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) | |||
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 = { | |||
@@ -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) | |||
@@ -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 | |||
@@ -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'] |
@@ -507,6 +507,7 @@ li { | |||
border-top: 1px solid #EBEFF2; | |||
} | |||
.page_content { | |||
padding-top: 30px; | |||
padding-bottom: 30px; | |||
} | |||
.carousel-control .icon { | |||
@@ -554,6 +555,9 @@ li { | |||
.panel-body { | |||
padding-left: 15px; | |||
} | |||
.page-head { | |||
margin-bottom: -30px; | |||
} | |||
.page-head h1, | |||
.page-head h2 { | |||
margin-top: 0px; | |||
@@ -813,6 +817,9 @@ a.active { | |||
padding: 30px; | |||
padding-left: 40px; | |||
} | |||
.page-content.without-sidebar { | |||
padding-top: 30px; | |||
} | |||
.your-account-info { | |||
margin-top: 30px; | |||
} | |||
@@ -125,6 +125,7 @@ li { | |||
} | |||
.page_content { | |||
padding-top: 30px; | |||
padding-bottom: 30px; | |||
} | |||
@@ -181,6 +182,7 @@ li { | |||
} | |||
.page-head { | |||
margin-bottom: -30px; | |||
h1, h2 { | |||
margin-top: 0px; | |||
} | |||
@@ -504,6 +506,10 @@ a.active { | |||
padding-left: 40px; | |||
} | |||
.page-content.without-sidebar { | |||
padding-top: 30px; | |||
} | |||
.your-account-info { | |||
margin-top: 30px; | |||
} | |||
@@ -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($('<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 class="text-muted" style="padding-bottom: 15px;"></div>'); | |||
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 class="text-muted" style="padding-bottom: 15px;"></div>'); | |||
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 class="text-muted" style="padding-bottom: 15px;"></div>'); | |||
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); | |||
} | |||
} |
@@ -11,7 +11,7 @@ | |||
{% include "templates/includes/web_sidebar.html" %} | |||
</div> | |||
{% endif %} | |||
<div class="{% if show_sidebar %}page-content with-sidebar{% else %} page-content {% endif %}"> | |||
<div class="{% if show_sidebar %}page-content with-sidebar{% else %}page-content without-sidebar{% endif %}"> | |||
<div class="page-content-wrapper"> | |||
<div class="row page-head"> | |||
<div class='col-sm-12'> | |||
@@ -0,0 +1,132 @@ | |||
# 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 HTTPRequest | |||
from frappe.twofactor import (should_run_2fa, authenticate_for_2factor, get_cached_user_pass, | |||
two_factor_is_enabled_for_, confirm_otp_token, get_otpsecret_for_, get_verification_obj, | |||
render_string_template) | |||
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): | |||
frappe.local.response['verification'] = None | |||
frappe.local.response['tmp_id'] = None | |||
disable_2fa() | |||
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.''' | |||
system_settings = frappe.get_doc('System Settings') | |||
system_settings.enable_two_factor_auth = 1 | |||
system_settings.two_factor_method = 'OTP App' | |||
system_settings.save(ignore_permissions=True) | |||
frappe.db.commit() | |||
def disable_2fa(): | |||
system_settings = frappe.get_doc('System Settings') | |||
system_settings.enable_two_factor_auth = 0 | |||
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,369 @@ | |||
# 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, os | |||
from frappe.utils.background_jobs import enqueue | |||
from jinja2 import Template | |||
from pyqrcode import create as qrcreate | |||
from StringIO import StringIO | |||
from base64 import b64encode, b32encode | |||
from frappe.utils import get_url, get_datetime, time_diff_in_seconds | |||
class ExpiredLoginException(Exception): pass | |||
def toggle_two_factor_auth(state, roles=[]): | |||
'''Enable or disable 2FA in site_config and roles''' | |||
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.db.get_value('System Settings', None, 'enable_two_factor_auth') | |||
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) | |||
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) | |||
roles = [frappe.db.escape(d.role) for d in user.roles or []] | |||
roles.append('All') | |||
query = """select name from `tabRole` where two_factor_auth=1 | |||
and name in ({0}) limit 1""".format(', '.join('\"{}\"'.format(i) for \ | |||
i in 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 = _('Login 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 = 'Enter this code to complete your login:<br><br> <b>{{otp}}</b>' | |||
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 = _('One Time Password (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><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, | |||
'header': [_('Verfication Code'), 'blue'], | |||
'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 | |||
def disable(): | |||
frappe.db.set_value('System Settings', None, 'enable_two_factor_auth', 0) | |||
@@ -35,7 +35,6 @@ def get_page_context(path): | |||
page_context = make_page_context(path) | |||
if can_cache(page_context.no_cache): | |||
page_context_cache[frappe.local.lang] = page_context | |||
frappe.cache().hset("page_context", path, page_context_cache) | |||
return page_context | |||
@@ -24,7 +24,11 @@ def find_first_image(html): | |||
return None | |||
def can_cache(no_cache=False): | |||
return not (frappe.conf.disable_website_cache or getattr(frappe.local, "no_cache", False) or no_cache) | |||
if frappe.conf.disable_website_cache or frappe.conf.developer_mode: | |||
return False | |||
if getattr(frappe.local, "no_cache", False): | |||
return False | |||
return not no_cache | |||
def get_comment_list(doctype, name): | |||
return frappe.db.sql("""select | |||
@@ -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") | |||
@@ -0,0 +1,27 @@ | |||
{% extends "templates/web.html" %} | |||
{% block title %}{{ _("QR Code") }}{% endblock %} | |||
{% block page_content %} | |||
<h1>{{ _("QR Code for Login Verification") }}</h1> | |||
<div class='row'> | |||
<div class='col-sm-6'> | |||
<p>{{ _("Hi {0}").format(qr_code_user.first_name) }},</p> | |||
<p>{{ _("Steps to verify your login") }}:</p> | |||
<ol> | |||
<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") }} | |||
</ol> | |||
</p> | |||
<br> | |||
<p class='text-muted small'>{{ _("Authentication Apps you can use are: ") }} | |||
Google Authenticator, Lastpass Authenticator, Authy and Duo Mobile. | |||
</p> | |||
</div> | |||
<div class='col-sm-6' style='padding-top: 15px;'> | |||
<img src="data:image/svg+xml;base64,{{qrcode_svg}}"> | |||
</div> | |||
</div> | |||
{% endblock %} |
@@ -0,0 +1,37 @@ | |||
# 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 | |||
def get_context(context): | |||
context.no_cache = 1 | |||
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 | |||
pypdf | |||
openpyxl | |||
pyotp | |||
pyqrcode | |||
pypng | |||
premailer | |||