@@ -490,6 +490,8 @@ def change_password(user, old_password, new_password):
490490 )
491491 _validate_password_strength (new_password )
492492 user .password = user .set_password (new_password )
493+ # Clear any account lockout on password change
494+ user .clear_failed_logins ()
493495 try :
494496 db .session .add (user )
495497 db .session .commit ()
@@ -529,6 +531,8 @@ def admin_change_password(user, new_password):
529531 logger .info (f"[SERVICE]: Admin changing password for user { user .email } " )
530532 _validate_password_strength (new_password )
531533 user .password = user .set_password (new_password )
534+ # Clear any account lockout on admin password change
535+ user .clear_failed_logins ()
532536 try :
533537 db .session .add (user )
534538 db .session .commit ()
@@ -619,6 +623,8 @@ def _recover_password_legacy(user):
619623 """
620624 password = _generate_secure_password ()
621625 user .password = user .set_password (password = password )
626+ # Clear any account lockout on password recovery
627+ user .clear_failed_logins ()
622628 try :
623629 logger .info ("[DB]: ADD" )
624630 db .session .add (user )
@@ -744,6 +750,9 @@ def reset_password_with_token(token_string, new_password):
744750 # Set new password
745751 user .password = user .set_password (password = new_password )
746752
753+ # Clear any account lockout - password reset unlocks the account
754+ user .clear_failed_logins ()
755+
747756 # Mark token as used
748757 reset_token .mark_used ()
749758
@@ -967,6 +976,17 @@ def delete_user(
967976
968977 @staticmethod
969978 def authenticate_user (email , password ):
979+ """Authenticate a user by email and password.
980+
981+ Returns:
982+ User object on successful authentication, None if user not found
983+ or password is invalid.
984+
985+ Raises:
986+ AccountLockedError: If the account is locked due to failed attempts.
987+ """
988+ from gefapi .errors import AccountLockedError
989+
970990 logger .info (f"[AUTH]: Authentication attempt for { email } " )
971991 user = User .query .filter_by (email = email ).first ()
972992
@@ -975,14 +995,98 @@ def authenticate_user(email, password):
975995 log_authentication_event (False , email , "user_not_found" )
976996 return None
977997
998+ # Check if account is locked
999+ if user .is_locked ():
1000+ minutes_remaining = user .get_lockout_minutes_remaining ()
1001+ if minutes_remaining is None :
1002+ logger .warning (f"[AUTH]: Account locked until password reset: { email } " )
1003+ log_authentication_event (False , email , "account_locked_permanent" )
1004+ raise AccountLockedError (
1005+ "Your account is locked due to too many failed login attempts. "
1006+ "Please reset your password to regain access." ,
1007+ minutes_remaining = None ,
1008+ requires_password_reset = True ,
1009+ )
1010+ logger .warning (
1011+ f"[AUTH]: Account temporarily locked for { email } , "
1012+ f"{ minutes_remaining } minutes remaining"
1013+ )
1014+ log_authentication_event (
1015+ False , email , f"account_locked_{ minutes_remaining } m"
1016+ )
1017+ raise AccountLockedError (
1018+ f"Your account is temporarily locked. "
1019+ f"Please try again in { minutes_remaining } minute(s)." ,
1020+ minutes_remaining = minutes_remaining ,
1021+ requires_password_reset = False ,
1022+ )
1023+
9781024 if not user .check_password (password ):
9791025 logger .warning (f"[AUTH]: Failed login - invalid password: { email } " )
980- log_authentication_event (False , email , "invalid_password" )
1026+
1027+ # Record failed attempt and apply lockout if threshold reached
1028+ try :
1029+ is_locked , lockout_minutes = user .record_failed_login ()
1030+ db .session .add (user )
1031+ db .session .commit ()
1032+
1033+ if is_locked :
1034+ if lockout_minutes is None :
1035+ logger .warning (
1036+ f"[AUTH]: Account locked until password reset after "
1037+ f"{ user .failed_login_count } failures: { email } "
1038+ )
1039+ log_authentication_event (
1040+ False , email , "account_locked_permanent"
1041+ )
1042+ # Log security event for account lockout
1043+ from gefapi .utils .security_events import log_security_event
1044+
1045+ log_security_event (
1046+ "ACCOUNT_LOCKED" ,
1047+ user_email = email ,
1048+ details = {
1049+ "reason" : "max_failed_attempts" ,
1050+ "failed_count" : user .failed_login_count ,
1051+ "unlock_method" : "password_reset_required" ,
1052+ },
1053+ level = "warning" ,
1054+ )
1055+ raise AccountLockedError (
1056+ "Your account has been locked due to too many failed "
1057+ "login attempts. Please reset your password to unlock." ,
1058+ minutes_remaining = None ,
1059+ requires_password_reset = True ,
1060+ )
1061+ logger .warning (
1062+ f"[AUTH]: Account locked for { lockout_minutes } minutes "
1063+ f"after { user .failed_login_count } failures: { email } "
1064+ )
1065+ log_authentication_event (
1066+ False , email , f"account_locked_{ lockout_minutes } m"
1067+ )
1068+ raise AccountLockedError (
1069+ f"Your account has been temporarily locked after "
1070+ f"{ user .failed_login_count } failed login attempts. "
1071+ f"Please try again in { lockout_minutes } minute(s)." ,
1072+ minutes_remaining = lockout_minutes ,
1073+ requires_password_reset = False ,
1074+ )
1075+ log_authentication_event (False , email , "invalid_password" )
1076+ except AccountLockedError :
1077+ # Re-raise AccountLockedError so it propagates to the route handler
1078+ raise
1079+ except Exception as e :
1080+ logger .warning (f"[AUTH]: Failed to record failed login: { e } " )
1081+ db .session .rollback ()
1082+ log_authentication_event (False , email , "invalid_password" )
1083+
9811084 return None
9821085
983- # Successful authentication - update login and activity timestamps
1086+ # Successful authentication - clear failed login count and update timestamps
9841087 try :
9851088 now = datetime .datetime .utcnow ()
1089+ user .clear_failed_logins () # Reset lockout state
9861090 user .last_login_at = now
9871091 user .last_activity_at = now
9881092 db .session .add (user )
0 commit comments