@@ -137,26 +137,29 @@ def login(instance):
137137 # 获取认证配置
138138 auth_setting = LoginSerializer .get_auth_setting ()
139139 max_attempts = auth_setting .get ("max_attempts" , 1 )
140- failed_attempts = auth_setting .get ("failed_attempts" , 5 )
141- lock_time = auth_setting .get ("lock_time" , 10 )
142140
143- # 检查许可证有效性
144- license_validator = DatabaseModelManage .get_model ("license_is_valid" )
145- is_license_valid = bool (license_validator ()) if license_validator else False
141+ license_validator = DatabaseModelManage .get_model ("license_is_valid" ) or (lambda : False )
142+ is_license_valid = license_validator () if license_validator () is not None else False
146143
147- if is_license_valid and LoginSerializer ._is_account_locked (username , failed_attempts ):
148- # 检查账户是否被锁定
144+ if is_license_valid :
145+ failed_attempts = auth_setting .get ("failed_attempts" , 5 )
146+ lock_time = auth_setting .get ("lock_time" , 10 )
147+ else :
148+ failed_attempts = 5
149+ lock_time = 10
150+
151+ if LoginSerializer ._is_account_locked (username , failed_attempts ):
149152 raise AppApiException (
150153 1005 , _ ("This account has been locked for %s minutes, please try again later" ) % lock_time
151154 )
152155 if LoginSerializer ._need_captcha (username , max_attempts ):
153156 # 验证验证码
154- LoginSerializer ._validate_captcha (username , captcha )
157+ LoginSerializer ._validate_captcha (username , captcha , failed_attempts , lock_time )
155158
156159 # 验证用户凭据:先按用户名查找,再用 password_verify 验证密码
157160 user = LoginSerializer ._authenticate (username , password )
158161 if user is None :
159- LoginSerializer ._handle_failed_login (username , is_license_valid , failed_attempts , lock_time )
162+ LoginSerializer ._handle_failed_login (username , failed_attempts , lock_time )
160163 raise AppApiException (500 , _ ("The username or password is incorrect" ))
161164
162165 if not user .is_active :
@@ -192,27 +195,36 @@ def _need_captcha_by_key(cache_key: str, max_attempts: int) -> bool:
192195 return True
193196
194197 @staticmethod
195- def _validate_captcha (username : str , captcha : str ) -> None :
196- """验证验证码"""
198+ def _validate_captcha (username : str , captcha : str , failed_attempts : int = 5 , lock_time : int = 10 ) -> None :
199+ """验证验证码(一次性消费) """
197200 if not captcha :
198201 raise AppApiException (1005 , _ ("Captcha is required" ))
199202
200- captcha_cache = cache .get (
201- Cache_Version .CAPTCHA .get_key (captcha = f"system_{ username } " ), version = Cache_Version .CAPTCHA .get_version ()
202- )
203+ captcha_key = Cache_Version .CAPTCHA .get_key (captcha = f"system_{ username } " )
204+ captcha_cache = cache .get (captcha_key , version = Cache_Version .CAPTCHA .get_version ())
203205
204206 if captcha_cache is None or captcha .lower () != captcha_cache :
207+ # 校验失败与口令失败共用同一失败计数与锁定机制,防止"识别-试错"循环绕过验证码
208+ LoginSerializer ._record_login_failure (username , failed_attempts , lock_time )
209+ if LoginSerializer ._is_account_locked (username , failed_attempts ):
210+ raise AppApiException (
211+ 1005 , _ ("This account has been locked for %s minutes, please try again later" ) % lock_time
212+ )
205213 raise AppApiException (1005 , _ ("Captcha code error or expiration" ))
206214
215+ # 校验通过即销毁,保证验证码一次性使用
216+ cache .delete (captcha_key , version = Cache_Version .CAPTCHA .get_version ())
217+
207218 @staticmethod
208- def _handle_failed_login (username : str , is_license_valid : bool , failed_attempts : int , lock_time : int ) -> None :
209- """处理登录失败
219+ def _record_login_failure (username : str , failed_attempts : int , lock_time : int ) -> int :
220+ """记录一次认证失败(口令或验证码),累计失败/锁定计数,达到阈值时创建锁键。
210221
211222 修复要点:
212223 - 使用 record_login_fail / record_login_fail_lock 两个原子 incr 来记录失败;
213224 - 不再依赖精确等于 0 的比较来触发锁,而是基于原子计数 >= 阈值来决定进入锁定分支;
214225 - 使用 cache.add 原子创建锁键,cache.add 保证只有第一个成功创建者可写入该键;
215226 其他并发到达的请求若发现计数已到达阈值也应当返回"已锁定"响应,避免出现绕过。
227+ - 不抛异常,返回当前锁定计数,供口令校验与验证码校验共用。
216228 """
217229 # 记录普通失败计数(供验证码触发使用)
218230 try :
@@ -227,8 +239,29 @@ def _handle_failed_login(username: str, is_license_valid: bool, failed_attempts:
227239 except Exception :
228240 maxkb_logger .exception ("Failed to record lock fail count for user %s" , username )
229241
230- # 如果不是企业版或禁用锁定功能,直接返回(但计数已经记录)
231- if not is_license_valid or failed_attempts <= 0 :
242+ # 当计数达到或超过阈值时,尝试原子创建锁键;无论 cache.add 返回 True/False 都视为已锁定,
243+ # 因为若为 False 说明其他并发请求已将账户标记为锁定,行为应一致。
244+ if failed_attempts > 0 and lock_fail_count >= failed_attempts :
245+ try :
246+ locked = cache .add (
247+ system_get_key (f"system_{ username } _lock" ), 1 , timeout = lock_time * 60 , version = system_version
248+ )
249+ if locked :
250+ maxkb_logger .info ("Account %s locked by setting cache key" , username )
251+ else :
252+ maxkb_logger .info ("Account %s lock key already present (another request set it)" , username )
253+ except Exception :
254+ maxkb_logger .exception ("Failed to set lock key for user %s" , username )
255+
256+ return lock_fail_count
257+
258+ @staticmethod
259+ def _handle_failed_login (username : str , failed_attempts : int , lock_time : int ) -> None :
260+ """处理口令校验失败:记录失败计数并抛出对应提示"""
261+ lock_fail_count = LoginSerializer ._record_login_failure (username , failed_attempts , lock_time )
262+
263+ # 仅由失败次数配置控制(CE/PE 同样生效);计数在此之前已记录
264+ if failed_attempts <= 0 :
232265 return
233266
234267 # 当计数小于阈值,告知剩余尝试次数
@@ -240,19 +273,6 @@ def _handle_failed_login(username: str, is_license_valid: bool, failed_attempts:
240273 % (failed_attempts , remain_attempts ),
241274 )
242275
243- # 当计数达到或超过阈值时,尝试原子创建锁键;无论 cache.add 返回 True/False,都返回已锁定响应,
244- # 因为若为 False 说明其他并发请求已将账户标记为锁定,行为应一致。
245- try :
246- locked = cache .add (
247- system_get_key (f"system_{ username } _lock" ), 1 , timeout = lock_time * 60 , version = system_version
248- )
249- if locked :
250- maxkb_logger .info ("Account %s locked by setting cache key" , username )
251- else :
252- maxkb_logger .info ("Account %s lock key already present (another request set it)" , username )
253- except Exception :
254- maxkb_logger .exception ("Failed to set lock key for user %s" , username )
255-
256276 raise AppApiException (
257277 1005 , _ ("This account has been locked for %s minutes, please try again later" ) % lock_time
258278 )
0 commit comments