diff --git a/frappe/__init__.py b/frappe/__init__.py index 34a9208abd..104a2222cb 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -17,7 +17,7 @@ from faker import Faker from .exceptions import * from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader) -__version__ = '10.1.39' +__version__ = '10.1.40' __title__ = "Frappe Framework" local = Local() diff --git a/frappe/auth.py b/frappe/auth.py index f0ce61f160..a7cb0908bd 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -8,13 +8,13 @@ from frappe import _ import frappe import frappe.database import frappe.utils -from frappe.utils import cint +from frappe.utils import cint, flt, get_datetime, datetime import frappe.utils.user from frappe import conf from frappe.sessions import Session, clear_sessions, delete_session 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.utils.password import check_password, delete_login_failed_cache from frappe.core.doctype.activity_log.activity_log import add_authentication_log from frappe.twofactor import (should_run_2fa, authenticate_for_2factor, confirm_otp_token, get_cached_user_pass) @@ -211,6 +211,10 @@ class LoginManager: def check_if_enabled(self, user): """raise exception if user not enabled""" + doc = frappe.get_doc("System Settings") + if doc.allow_consecutive_login_attempts > 0: + check_consecutive_login_attempts(user, doc) + if user=='Administrator': return if not cint(frappe.db.get_value('User', user, 'enabled')): self.fail('User disabled or missing', user=user) @@ -221,6 +225,7 @@ class LoginManager: # returns user in correct case return check_password(user, pwd) except frappe.AuthenticationError: + self.update_invalid_login(user) self.fail('Incorrect password', user=user) def fail(self, message, user=None): @@ -231,6 +236,15 @@ class LoginManager: frappe.db.commit() raise frappe.AuthenticationError + def update_invalid_login(self, user): + last_login_tried = get_last_tried_login_data(user) + + failed_count = 0 + if last_login_tried > get_datetime(): + failed_count = get_login_failed_count(user) + + frappe.cache().hset('login_failed_count', user, failed_count + 1) + def run_trigger(self, event='on_login'): for method in frappe.get_hooks().get(event, []): frappe.call(frappe.get_attr(method), login_manager=self) @@ -346,3 +360,35 @@ def get_website_user_home_page(user): return '/' + home_page.strip('/') else: return '/me' + +def get_last_tried_login_data(user, get_last_login=False): + locked_account_time = frappe.cache().hget('locked_account_time', user) + if get_last_login and locked_account_time: + return locked_account_time + + last_login_tried = frappe.cache().hget('last_login_tried', user) + if not last_login_tried or last_login_tried < get_datetime(): + last_login_tried = get_datetime() + datetime.timedelta(seconds=60) + + frappe.cache().hset('last_login_tried', user, last_login_tried) + + return last_login_tried + +def get_login_failed_count(user): + return cint(frappe.cache().hget('login_failed_count', user)) or 0 + +def check_consecutive_login_attempts(user, doc): + login_failed_count = get_login_failed_count(user) + last_login_tried = (get_last_tried_login_data(user, True) + + datetime.timedelta(seconds=doc.allow_login_after_fail)) + + if login_failed_count >= cint(doc.allow_consecutive_login_attempts): + locked_account_time = frappe.cache().hget('locked_account_time', user) + if not locked_account_time: + frappe.cache().hset('locked_account_time', user, get_datetime()) + + if last_login_tried > get_datetime(): + frappe.throw(_("Your account has been locked and will resume after {0} seconds") + .format(doc.allow_login_after_fail), frappe.SecurityException) + else: + delete_login_failed_cache(user) \ No newline at end of file diff --git a/frappe/core/doctype/activity_log/test_activity_log.py b/frappe/core/doctype/activity_log/test_activity_log.py index 7e308270bb..f28bce19ff 100644 --- a/frappe/core/doctype/activity_log/test_activity_log.py +++ b/frappe/core/doctype/activity_log/test_activity_log.py @@ -5,10 +5,11 @@ from __future__ import unicode_literals import frappe import unittest +import time +from frappe.auth import LoginManager, CookieManager class TestActivityLog(unittest.TestCase): def test_activity_log(self): - from frappe.auth import LoginManager, CookieManager # test user login log frappe.local.form_dict = frappe._dict({ @@ -44,4 +45,44 @@ class TestActivityLog(unittest.TestCase): name = names[0] auth_log = frappe.get_doc('Activity Log', name) - return auth_log \ No newline at end of file + return auth_log + + def test_brute_security(self): + update_system_settings({ + 'allow_consecutive_login_attempts': 3, + 'allow_login_after_fail': 5 + }) + + frappe.local.form_dict = frappe._dict({ + 'cmd': 'login', + 'sid': 'Guest', + 'pwd': 'admin', + 'usr': 'Administrator' + }) + + frappe.local.cookie_manager = CookieManager() + frappe.local.login_manager = LoginManager() + + auth_log = self.get_auth_log() + self.assertEquals(auth_log.status, 'Success') + + # test user logout log + frappe.local.login_manager.logout() + auth_log = self.get_auth_log(operation='Logout') + self.assertEquals(auth_log.status, 'Success') + + # test invalid login + frappe.form_dict.update({ 'pwd': 'password' }) + self.assertRaises(frappe.AuthenticationError, LoginManager) + self.assertRaises(frappe.AuthenticationError, LoginManager) + self.assertRaises(frappe.AuthenticationError, LoginManager) + self.assertRaises(frappe.SecurityException, LoginManager) + time.sleep(5) + self.assertRaises(frappe.AuthenticationError, LoginManager) + + frappe.local.form_dict = frappe._dict() + +def update_system_settings(args): + doc = frappe.get_doc('System Settings') + doc.update(args) + doc.save() diff --git a/frappe/core/doctype/communication/comment.py b/frappe/core/doctype/communication/comment.py index ca98383f94..2c7a4bd122 100644 --- a/frappe/core/doctype/communication/comment.py +++ b/frappe/core/doctype/communication/comment.py @@ -94,9 +94,8 @@ def notify_mentions(doc): subject = _("{0} mentioned you in a comment").format(sender_fullname) - recipients = [frappe.db.get_value("User", {"enabled": 1, "username": username, "user_type": "System User"}) - for username in mentions] - + recipients = [frappe.db.get_value("User", {"enabled": 1, "name": name, "user_type": "System User"}) + for name in mentions] frappe.sendmail( recipients=recipients, sender=frappe.session.user, diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 0f036196f8..b6a08b5cf7 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -927,6 +927,157 @@ "set_only_once": 0, "unique": 0 }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 1, + "columns": 0, + "fieldname": "brute_force_security", + "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": "Brute Force Security", + "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": "allow_consecutive_login_attempts", + "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": "Allow Consecutive Login Attempts ", + "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": "column_break_34", + "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, + "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": "60", + "description": "In seconds", + "fieldname": "allow_login_after_fail", + "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": "Allow Login After Fail", + "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": 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, @@ -1405,7 +1556,7 @@ "issingle": 1, "istable": 0, "max_attachments": 0, - "modified": "2018-05-23 16:45:46.261765", + "modified": "2018-07-06 16:33:49.222058", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 8a567b29ee..2b4e979e7e 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -12,6 +12,7 @@ from frappe.limits import update_limits, clear_limit from frappe.utils import get_url from frappe.core.doctype.user.user import get_total_users from frappe.core.doctype.user.user import MaxUsersReachedError, test_password_strength +from frappe.core.doctype.user.user import extract_mentions test_records = frappe.get_test_records('User') @@ -266,5 +267,13 @@ class TestUser(unittest.TestCase): result = test_password_strength("Eastern_43A1W") self.assertEqual(result['feedback']['password_policy_validation_passed'], True) + def test_comment_mentions(self): + user_name = "@test.comment@example.com" + self.assertEqual(extract_mentions(user_name)[0], "test.comment@example.com") + user_name = "Testing comment, @test-user please check." + self.assertEqual(extract_mentions(user_name)[0], "test-user") + user_name = "Testing comment, @test.user@example.com please check." + self.assertEqual(extract_mentions(user_name)[0], "test.user@example.com") + def delete_contact(user): frappe.db.sql("delete from tabContact where email_id='%s'" % frappe.db.escape(user)) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 7698a71a96..b0a2901c8e 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -927,9 +927,9 @@ def notify_admin_access_to_system_manager(login_manager=None): ) def extract_mentions(txt): - """Find all instances of @username in the string. + """Find all instances of @name in the string. The mentions will be separated by non-word characters or may appear at the start of the string""" - return re.findall(r'(?:[^\w]|^)@([\w]*)', txt) + return re.findall(r'(?:[^\w\.\-\@]|^)@([\w\.\-\@]*)', txt) def handle_password_test_fail(result): diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 38c4b99774..7106311c1d 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -8,31 +8,43 @@ from frappe.utils import cstr, unique, cint from frappe.permissions import has_permission from frappe import _ from six import string_types +import re def sanitize_searchfield(searchfield): blacklisted_keywords = ['select', 'delete', 'drop', 'update', 'case', 'and', 'or', 'like'] - def _raise_exception(): - frappe.throw(_('Invalid Search Field'), frappe.DataError) + def _raise_exception(searchfield): + frappe.throw(_('Invalid Search Field {0}').format(searchfield), frappe.DataError) + + if len(searchfield) == 1: + # do not allow special characters to pass as searchfields + regex = re.compile('^.*[=;*,\'"$\-+%#@()_].*') + if regex.match(searchfield): + _raise_exception(searchfield) if len(searchfield) >= 3: # to avoid 1=1 if '=' in searchfield: - _raise_exception() + _raise_exception(searchfield) # in mysql -- is used for commenting the query elif ' --' in searchfield: - _raise_exception() + _raise_exception(searchfield) # to avoid and, or and like elif any(' {0} '.format(keyword) in searchfield.split() for keyword in blacklisted_keywords): - _raise_exception() + _raise_exception(searchfield) # to avoid select, delete, drop, update and case elif any(keyword in searchfield.split() for keyword in blacklisted_keywords): - _raise_exception() + _raise_exception(searchfield) + + else: + regex = re.compile('^.*[=;*,\'"$\-+%#@()].*') + if any(regex.match(f) for f in searchfield.split()): + _raise_exception(searchfield) # this is called by the Link Field @frappe.whitelist() diff --git a/frappe/exceptions.py b/frappe/exceptions.py index de37c7e481..3a8548a6e0 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -83,4 +83,4 @@ class ImplicitCommitError(ValidationError): pass class RetryBackgroundJobError(Exception): pass class DocumentLockedError(ValidationError): pass class CircularLinkingError(ValidationError): pass - +class SecurityException(Exception): pass diff --git a/frappe/patches.txt b/frappe/patches.txt index 48bb42f9f3..2046dddf0f 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -222,4 +222,4 @@ frappe.patches.v11_0.delete_duplicate_user_permissions frappe.patches.v11_0.replicate_old_user_permissions frappe.patches.v11_0.set_dropbox_file_backup frappe.patches.v11_0.get_docs_apps_if_not_present - +frappe.patches.v10_0.set_default_locking_time diff --git a/frappe/patches/v10_0/set_default_locking_time.py b/frappe/patches/v10_0/set_default_locking_time.py new file mode 100644 index 0000000000..045fa0e3fa --- /dev/null +++ b/frappe/patches/v10_0/set_default_locking_time.py @@ -0,0 +1,8 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +import frappe + +def execute(): + frappe.reload_doc("core", "doctype", "system_settings") + frappe.db.set_value('System Settings', None, "allow_login_after_fail", 60) \ No newline at end of file diff --git a/frappe/public/js/frappe/form/footer/timeline.js b/frappe/public/js/frappe/form/footer/timeline.js index 8521dbe50b..045cd0c14a 100644 --- a/frappe/public/js/frappe/form/footer/timeline.js +++ b/frappe/public/js/frappe/form/footer/timeline.js @@ -17,7 +17,7 @@ frappe.ui.form.Timeline = Class.extend({ this.comment_area = new frappe.ui.CommentArea({ parent: this.wrapper.find('.timeline-head'), - mentions: this.get_usernames_for_mentions(), + mentions: this.get_names_for_mentions(), on_submit: (val) => { val && this.insert_comment( "Comment", val, this.comment_area.button); @@ -99,7 +99,7 @@ frappe.ui.form.Timeline = Class.extend({ this.editing_area = new frappe.ui.CommentArea({ parent: this.$editing_area, - mentions: this.get_usernames_for_mentions(), + mentions: this.get_names_for_mentions(), no_wrapper: true }); @@ -666,11 +666,11 @@ frappe.ui.form.Timeline = Class.extend({ return last_email; }, - get_usernames_for_mentions: function() { + get_names_for_mentions: function() { var valid_users = Object.keys(frappe.boot.user_info) .filter(user => !["Administrator", "Guest"].includes(user)); - return valid_users.map(user => frappe.boot.user_info[user].username || frappe.boot.user_info[user].name); + return valid_users.map(user => frappe.boot.user_info[user].name); }, setup_comment_like: function() { diff --git a/frappe/public/js/frappe/views/reports/grid_report.js b/frappe/public/js/frappe/views/reports/grid_report.js index a143c0464a..b556288be8 100644 --- a/frappe/public/js/frappe/views/reports/grid_report.js +++ b/frappe/public/js/frappe/views/reports/grid_report.js @@ -146,6 +146,7 @@ frappe.views.GridReport = Class.extend({ }, setup_filters: function() { var me = this; + $.each(me.filter_inputs, function(i, v) { var opts = v.get(0).opts; if(opts.fieldtype == "Select" && in_list(me.doctypes, opts.link)) { @@ -173,7 +174,7 @@ frappe.views.GridReport = Class.extend({ this.page.add_menu_item(__("Print"), function() { frappe.ui.get_print_settings(false, function(print_settings) { - frappe.render_grid({grid: me.grid, title: me.page.title, print_settings: print_settings }); + frappe.render_grid({grid: me.grid, title: me.page.title, print_settings: print_settings, report: me}); }); }, true); diff --git a/frappe/public/js/lib/microtemplate.js b/frappe/public/js/lib/microtemplate.js index 9d75ee33fa..bfdf103f78 100644 --- a/frappe/public/js/lib/microtemplate.js +++ b/frappe/public/js/lib/microtemplate.js @@ -88,7 +88,7 @@ frappe.render_grid = function(opts) { // build context if(opts.grid) { opts.columns = opts.grid.getColumns(); - if(opts.report) { + if(opts.report && opts.report.dataView) { opts.data = frappe.slickgrid_tools.get_filtered_items(opts.report.dataView); } else if(opts.grid) { opts.data = opts.grid.getData().getItems(); diff --git a/frappe/tests/test_search.py b/frappe/tests/test_search.py index ee4dd3cc53..9f1891753e 100644 --- a/frappe/tests/test_search.py +++ b/frappe/tests/test_search.py @@ -26,3 +26,15 @@ class TestSearch(unittest.TestCase): self.assertRaises(frappe.DataError, search_link, 'DocType', 'Customer', query=None, filters=None, page_length=20, searchfield='name or (select * from tabSessions)') + + self.assertRaises(frappe.DataError, + search_link, 'DocType', 'Customer', query=None, filters=None, + page_length=20, searchfield='*') + + self.assertRaises(frappe.DataError, + search_link, 'DocType', 'Customer', query=None, filters=None, + page_length=20, searchfield=';') + + self.assertRaises(frappe.DataError, + search_link, 'DocType', 'Customer', query=None, filters=None, + page_length=20, searchfield=';') diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 21fc5b3a6b..672a004d64 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -549,6 +549,8 @@ def in_words(integer, in_million=True): ret = num2words(integer, lang=locale) except NotImplementedError: ret = num2words(integer, lang='en') + except OverflowError: + ret = num2words(integer, lang='en') return ret.replace('-', ' ') def is_html(text): diff --git a/frappe/utils/password.py b/frappe/utils/password.py index 3784720777..911db4adcf 100644 --- a/frappe/utils/password.py +++ b/frappe/utils/password.py @@ -67,12 +67,18 @@ def check_password(user, pwd, doctype='User', fieldname='password'): # lettercase agnostic user = auth[0].name + delete_login_failed_cache(user) if not passlibctx.needs_update(auth[0].password): update_password(user, pwd, doctype, fieldname) return user +def delete_login_failed_cache(user): + frappe.cache().hdel('last_login_tried', user) + frappe.cache().hdel('login_failed_count', user) + frappe.cache().hdel('locked_account_time', user) + def update_password(user, pwd, doctype='User', fieldname='password', logout_all_sessions=False): ''' Update the password for the User