Skip to content

Commit 748cac6

Browse files
committed
Merge branch 'staging' into production
2 parents bdbe009 + 3eca989 commit 748cac6

File tree

21 files changed

+783
-186
lines changed

21 files changed

+783
-186
lines changed

frappe/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
sys.setdefaultencoding("utf-8")
2525

2626
__frappe_version__ = '12.8.1'
27-
__version__ = '2.3.1'
27+
__version__ = '2.3.2'
2828
__title__ = "Frappe Framework"
2929

3030
local = Local()
@@ -176,17 +176,20 @@ def init(site, sites_path=None, new_site=False):
176176

177177
local.initialised = True
178178

179-
def connect(site=None, db_name=None):
179+
def connect(site=None, db_name=None, set_admin_as_user=True):
180180
"""Connect to site database instance.
181181
182182
:param site: If site is given, calls `frappe.init`.
183-
:param db_name: Optional. Will use from `site_config.json`."""
183+
:param db_name: Optional. Will use from `site_config.json`.
184+
:param set_admin_as_user: Set Administrator as current user.
185+
"""
184186
from frappe.database import get_db
185187
if site:
186188
init(site)
187189

188190
local.db = get_db(user=db_name or local.conf.db_name)
189-
set_user("Administrator")
191+
if set_admin_as_user:
192+
set_user("Administrator")
190193

191194
def connect_replica():
192195
from frappe.database import get_db

frappe/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ def init_request(request):
126126
if frappe.local.conf.get('maintenance_mode'):
127127
frappe.connect()
128128
raise frappe.SessionStopped('Session Stopped')
129+
else:
130+
frappe.connect(set_admin_as_user=False)
129131

130132
make_form_dict(request)
131133

frappe/auth.py

Lines changed: 114 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -203,23 +203,44 @@ def clear_active_sessions(self):
203203
if frappe.session.user != "Guest":
204204
clear_sessions(frappe.session.user, keep_current=True)
205205

206-
def authenticate(self, user=None, pwd=None):
206+
def authenticate(self, user = None, pwd = None):
207+
from frappe.core.doctype.user.user import User
208+
207209
if not (user and pwd):
208210
user, pwd = frappe.form_dict.get('usr'), frappe.form_dict.get('pwd')
209211
if not (user and pwd):
210212
self.fail(_('Incomplete login details'), user=user)
211213

212-
if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number")):
213-
user = frappe.db.get_value("User", filters={"mobile_no": user}, fieldname="name") or user
214+
# Ignore password check if tmp_id is set, 2FA takes care of authentication.
215+
validate_password = not bool(frappe.form_dict.get('tmp_id'))
216+
user = User.find_by_credentials(user, pwd, validate_password=validate_password)
217+
218+
if not user:
219+
self.fail('Invalid login credentials')
220+
221+
sys_settings = frappe.get_doc("System Settings")
222+
track_login_attempts = (sys_settings.allow_consecutive_login_attempts >0)
223+
224+
tracker_kwargs = {}
225+
if track_login_attempts:
226+
tracker_kwargs['lock_interval'] = sys_settings.allow_login_after_fail
227+
tracker_kwargs['max_consecutive_login_attempts'] = sys_settings.allow_consecutive_login_attempts
214228

215-
if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_user_name")):
216-
user = frappe.db.get_value("User", filters={"username": user}, fieldname="name") or user
229+
tracker = LoginAttemptTracker(user.name, **tracker_kwargs)
217230

218-
self.check_if_enabled(user)
219-
if not frappe.form_dict.get('tmp_id'):
220-
self.user = self.check_password(user, pwd)
231+
if track_login_attempts and not tracker.is_user_allowed():
232+
frappe.throw(_("Your account has been locked and will resume after {0} seconds")
233+
.format(sys_settings.allow_login_after_fail), frappe.SecurityException)
234+
235+
if not user.is_authenticated:
236+
tracker.add_failure_attempt()
237+
self.fail('Invalid login credentials', user=user.name)
238+
elif not (user.name == 'Administrator' or user.enabled):
239+
tracker.add_failure_attempt()
240+
self.fail('User disabled or missing', user=user.name)
221241
else:
222-
self.user = user
242+
tracker.add_success_attempt()
243+
self.user = user.name
223244

224245
def force_user_to_reset_password(self):
225246
if not self.user:
@@ -241,23 +262,12 @@ def force_user_to_reset_password(self):
241262
if last_pwd_reset_days > reset_pwd_after_days:
242263
return True
243264

244-
def check_if_enabled(self, user):
245-
"""raise exception if user not enabled"""
246-
doc = frappe.get_doc("System Settings")
247-
if cint(doc.allow_consecutive_login_attempts) > 0:
248-
check_consecutive_login_attempts(user, doc)
249-
250-
if user=='Administrator': return
251-
if not cint(frappe.db.get_value('User', user, 'enabled')):
252-
self.fail('User disabled or missing', user=user)
253-
254265
def check_password(self, user, pwd):
255266
"""check password"""
256267
try:
257268
# returns user in correct case
258269
return check_password(user, pwd)
259270
except frappe.AuthenticationError:
260-
self.update_invalid_login(user)
261271
self.fail('Incorrect password', user=user)
262272

263273
def fail(self, message, user=None):
@@ -268,15 +278,6 @@ def fail(self, message, user=None):
268278
frappe.db.commit()
269279
raise frappe.AuthenticationError
270280

271-
def update_invalid_login(self, user):
272-
last_login_tried = get_last_tried_login_data(user)
273-
274-
failed_count = 0
275-
if last_login_tried > get_datetime():
276-
failed_count = get_login_failed_count(user)
277-
278-
frappe.cache().hset('login_failed_count', user, failed_count + 1)
279-
280281
def run_trigger(self, event='on_login'):
281282
for method in frappe.get_hooks().get(event, []):
282283
frappe.call(frappe.get_attr(method), login_manager=self)
@@ -373,38 +374,6 @@ def get_website_user_home_page(user):
373374
else:
374375
return '/me'
375376

376-
def get_last_tried_login_data(user, get_last_login=False):
377-
locked_account_time = frappe.cache().hget('locked_account_time', user)
378-
if get_last_login and locked_account_time:
379-
return locked_account_time
380-
381-
last_login_tried = frappe.cache().hget('last_login_tried', user)
382-
if not last_login_tried or last_login_tried < get_datetime():
383-
last_login_tried = get_datetime() + datetime.timedelta(seconds=60)
384-
385-
frappe.cache().hset('last_login_tried', user, last_login_tried)
386-
387-
return last_login_tried
388-
389-
def get_login_failed_count(user):
390-
return cint(frappe.cache().hget('login_failed_count', user)) or 0
391-
392-
def check_consecutive_login_attempts(user, doc):
393-
login_failed_count = get_login_failed_count(user)
394-
last_login_tried = (get_last_tried_login_data(user, True)
395-
+ datetime.timedelta(seconds=doc.allow_login_after_fail))
396-
397-
if login_failed_count >= cint(doc.allow_consecutive_login_attempts):
398-
locked_account_time = frappe.cache().hget('locked_account_time', user)
399-
if not locked_account_time:
400-
frappe.cache().hset('locked_account_time', user, get_datetime())
401-
402-
if last_login_tried > get_datetime():
403-
frappe.throw(_("Your account has been locked and will resume after {0} seconds")
404-
.format(doc.allow_login_after_fail), frappe.SecurityException)
405-
else:
406-
delete_login_failed_cache(user)
407-
408377
def validate_ip_address(user):
409378
"""check if IP Address is valid"""
410379
user = frappe.get_cached_doc("User", user) if not frappe.flags.in_test else frappe.get_doc("User", user)
@@ -426,3 +395,87 @@ def validate_ip_address(user):
426395
return
427396

428397
frappe.throw(_("Access not allowed from this IP Address"), frappe.AuthenticationError)
398+
399+
400+
class LoginAttemptTracker(object):
401+
"""Track login attempts of a user.
402+
403+
Lock the account for s number of seconds if there have been n consecutive unsuccessful attempts to log in.
404+
"""
405+
def __init__(self, user_name, max_consecutive_login_attempts=3, lock_interval=5*60):
406+
""" Initialize the tracker.
407+
408+
:param user_name: Name of the loggedin user
409+
:param max_consecutive_login_attempts: Maximum allowed consecutive failed login attempts
410+
:param lock_interval: Locking interval incase of maximum failed attempts
411+
"""
412+
self.user_name = user_name
413+
self.lock_interval = datetime.timedelta(seconds=lock_interval)
414+
self.max_failed_logins = max_consecutive_login_attempts
415+
416+
@property
417+
def login_failed_count(self):
418+
return frappe.cache().hget('login_failed_count', self.user_name)
419+
420+
@login_failed_count.setter
421+
def login_failed_count(self, count):
422+
frappe.cache().hset('login_failed_count', self.user_name, count)
423+
424+
@login_failed_count.deleter
425+
def login_failed_count(self):
426+
frappe.cache().hdel('login_failed_count', self.user_name)
427+
428+
@property
429+
def login_failed_time(self):
430+
"""First failed login attempt time within lock interval.
431+
432+
For every user we track only First failed login attempt time within lock interval of time.
433+
"""
434+
return frappe.cache().hget('login_failed_time', self.user_name)
435+
436+
@login_failed_time.setter
437+
def login_failed_time(self, timestamp):
438+
frappe.cache().hset('login_failed_time', self.user_name, timestamp)
439+
440+
@login_failed_time.deleter
441+
def login_failed_time(self):
442+
frappe.cache().hdel('login_failed_time', self.user_name)
443+
444+
def add_failure_attempt(self):
445+
""" Log user failure attempts into the system.
446+
447+
Increase the failure count if new failure is with in current lock interval time period, if not reset the login failure count.
448+
"""
449+
login_failed_time = self.login_failed_time
450+
login_failed_count = self.login_failed_count # Consecutive login failure count
451+
current_time = get_datetime()
452+
453+
if not (login_failed_time and login_failed_count):
454+
login_failed_time, login_failed_count = current_time, 0
455+
456+
if login_failed_time + self.lock_interval > current_time:
457+
login_failed_count += 1
458+
else:
459+
login_failed_time, login_failed_count = current_time, 1
460+
461+
self.login_failed_time = login_failed_time
462+
self.login_failed_count = login_failed_count
463+
464+
def add_success_attempt(self):
465+
"""Reset login failures.
466+
"""
467+
del self.login_failed_count
468+
del self.login_failed_time
469+
470+
def is_user_allowed(self):
471+
"""Is user allowed to login
472+
473+
User is not allowed to login if login failures are greater than threshold within in lock interval from first login failure.
474+
"""
475+
login_failed_time = self.login_failed_time
476+
login_failed_count = self.login_failed_count or 0
477+
current_time = get_datetime()
478+
479+
if login_failed_time and login_failed_time + self.lock_interval > current_time and login_failed_count > self.max_failed_logins:
480+
return False
481+
return True

frappe/core/doctype/activity_log/test_activity_log.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ def test_brute_security(self):
7777
self.assertRaises(frappe.AuthenticationError, LoginManager)
7878
self.assertRaises(frappe.AuthenticationError, LoginManager)
7979
self.assertRaises(frappe.AuthenticationError, LoginManager)
80+
81+
# REMOVE ME: current logic allows allow_consecutive_login_attempts+1 attempts
82+
# before raising security exception, remove below line when that is fixed.
83+
self.assertRaises(frappe.AuthenticationError, LoginManager)
8084
self.assertRaises(frappe.SecurityException, LoginManager)
8185
time.sleep(5)
8286
self.assertRaises(frappe.AuthenticationError, LoginManager)

frappe/core/doctype/user/user.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from frappe.model.document import Document
77
from frappe.utils import cint, flt, has_gravatar, format_datetime, now_datetime, get_formatted_email, today
88
from frappe import throw, msgprint, _
9-
from frappe.utils.password import update_password as _update_password
9+
from frappe.utils.password import update_password as _update_password, check_password
1010
from frappe.desk.notifications import clear_notifications
1111
from frappe.desk.doctype.notification_settings.notification_settings import create_notification_settings
1212
from frappe.utils.user import get_system_managers
@@ -505,6 +505,27 @@ def get_restricted_ip_list(self):
505505

506506
return [i.strip() for i in self.restrict_ip.split(",")]
507507

508+
@classmethod
509+
def find_by_credentials(cls, user_name, password, validate_password=True):
510+
"""Find the user by credentials.
511+
"""
512+
login_with_mobile = cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number"))
513+
filter = {"mobile_no": user_name} if login_with_mobile else {"name": user_name}
514+
515+
user = frappe.db.get_value("User", filters=filter, fieldname=['name', 'enabled'], as_dict=True) or {}
516+
if not user:
517+
return
518+
519+
user['is_authenticated'] = True
520+
if validate_password:
521+
try:
522+
check_password(user_name, password)
523+
except frappe.AuthenticationError:
524+
user['is_authenticated'] = False
525+
526+
return user
527+
528+
508529
@frappe.whitelist()
509530
def get_timezones():
510531
import pytz

0 commit comments

Comments
 (0)