@@ -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-
408377def 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
0 commit comments