Bladeren bron

Merge pull request #3877 from rmehta/manqala-twofactor

Two Factor Authentication
version-14
Rushabh Mehta 8 jaren geleden
committed by GitHub
bovenliggende
commit
c8e04b3de0
33 gewijzigde bestanden met toevoegingen van 1569 en 20 verwijderingen
  1. +20
    -6
      frappe/auth.py
  2. +32
    -1
      frappe/core/doctype/role/role.json
  3. +1
    -0
      frappe/core/doctype/sms_parameter/README.md
  4. +1
    -0
      frappe/core/doctype/sms_parameter/__init__.py
  5. +98
    -0
      frappe/core/doctype/sms_parameter/sms_parameter.json
  6. +10
    -0
      frappe/core/doctype/sms_parameter/sms_parameter.py
  7. +1
    -0
      frappe/core/doctype/sms_settings/README.md
  8. +1
    -0
      frappe/core/doctype/sms_settings/__init__.py
  9. +0
    -0
      frappe/core/doctype/sms_settings/sms_settings.js
  10. +267
    -0
      frappe/core/doctype/sms_settings/sms_settings.json
  11. +117
    -0
      frappe/core/doctype/sms_settings/sms_settings.py
  12. +23
    -0
      frappe/core/doctype/sms_settings/test_sms_settings.js
  13. +160
    -1
      frappe/core/doctype/system_settings/system_settings.json
  14. +7
    -0
      frappe/core/doctype/system_settings/system_settings.py
  15. +10
    -0
      frappe/core/doctype/user/user.js
  16. +1
    -1
      frappe/core/doctype/user/user.json
  17. +85
    -4
      frappe/core/doctype/user/user.py
  18. +30
    -0
      frappe/desk/page/setup_wizard/setup_wizard.js
  19. +11
    -0
      frappe/desk/page/setup_wizard/setup_wizard.py
  20. +3
    -0
      frappe/exceptions.py
  21. +4
    -1
      frappe/hooks.py
  22. +7
    -0
      frappe/public/css/website.css
  23. +6
    -0
      frappe/public/less/website.less
  24. +99
    -2
      frappe/templates/includes/login/login.js
  25. +1
    -1
      frappe/templates/web.html
  26. +132
    -0
      frappe/tests/test_twofactor.py
  27. +369
    -0
      frappe/twofactor.py
  28. +0
    -1
      frappe/website/router.py
  29. +5
    -1
      frappe/website/utils.py
  30. +0
    -1
      frappe/www/login.py
  31. +27
    -0
      frappe/www/qrcode.html
  32. +37
    -0
      frappe/www/qrcode.py
  33. +4
    -0
      requirements.txt

+ 20
- 6
frappe/auth.py Bestand weergeven

@@ -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'

+ 32
- 1
frappe/core/doctype/role/role.json Bestand weergeven

@@ -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",


+ 1
- 0
frappe/core/doctype/sms_parameter/README.md Bestand weergeven

@@ -0,0 +1 @@
SMS query parameter for SMS Settings.

+ 1
- 0
frappe/core/doctype/sms_parameter/__init__.py Bestand weergeven

@@ -0,0 +1 @@
from __future__ import unicode_literals

+ 98
- 0
frappe/core/doctype/sms_parameter/sms_parameter.json Bestand weergeven

@@ -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
}

+ 10
- 0
frappe/core/doctype/sms_parameter/sms_parameter.py Bestand weergeven

@@ -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

+ 1
- 0
frappe/core/doctype/sms_settings/README.md Bestand weergeven

@@ -0,0 +1 @@
Settings for automatically sending SMS from the system.

+ 1
- 0
frappe/core/doctype/sms_settings/__init__.py Bestand weergeven

@@ -0,0 +1 @@
from __future__ import unicode_literals

+ 0
- 0
frappe/core/doctype/sms_settings/sms_settings.js Bestand weergeven


+ 267
- 0
frappe/core/doctype/sms_settings/sms_settings.json Bestand weergeven

@@ -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
}

+ 117
- 0
frappe/core/doctype/sms_settings/sms_settings.py Bestand weergeven

@@ -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()

+ 23
- 0
frappe/core/doctype/sms_settings/test_sms_settings.js Bestand weergeven

@@ -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()
]);

});

+ 160
- 1
frappe/core/doctype/system_settings/system_settings.json Bestand weergeven

@@ -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",


+ 7
- 0
frappe/core/doctype/system_settings/system_settings.py Bestand weergeven

@@ -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:


+ 10
- 0
frappe/core/doctype/user/user.js Bestand weergeven

@@ -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) {


+ 1
- 1
frappe/core/doctype/user/user.json Bestand weergeven

@@ -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",


+ 85
- 4
frappe/core/doctype/user/user.py Bestand weergeven

@@ -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."))

+ 30
- 0
frappe/desk/page/setup_wizard/setup_wizard.js Bestand weergeven

@@ -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 = {


+ 11
- 0
frappe/desk/page/setup_wizard/setup_wizard.py Bestand weergeven

@@ -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)


+ 3
- 0
frappe/exceptions.py Bestand weergeven

@@ -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



+ 4
- 1
frappe/hooks.py Bestand weergeven

@@ -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']

+ 7
- 0
frappe/public/css/website.css Bestand weergeven

@@ -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;
}


+ 6
- 0
frappe/public/less/website.less Bestand weergeven

@@ -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;
}


+ 99
- 2
frappe/templates/includes/login/login.js Bestand weergeven

@@ -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);
}
}

+ 1
- 1
frappe/templates/web.html Bestand weergeven

@@ -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'>


+ 132
- 0
frappe/tests/test_twofactor.py Bestand weergeven

@@ -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()

+ 369
- 0
frappe/twofactor.py Bestand weergeven

@@ -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)


+ 0
- 1
frappe/website/router.py Bestand weergeven

@@ -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


+ 5
- 1
frappe/website/utils.py Bestand weergeven

@@ -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


+ 0
- 1
frappe/www/login.py Bestand weergeven

@@ -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")


+ 27
- 0
frappe/www/qrcode.html Bestand weergeven

@@ -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 %}

+ 37
- 0
frappe/www/qrcode.py Bestand weergeven

@@ -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)

+ 4
- 0
requirements.txt Bestand weergeven

@@ -41,4 +41,8 @@ oauthlib
PyJWT
pypdf
openpyxl
pyotp
pyqrcode
pypng
premailer


Laden…
Annuleren
Opslaan