Skip to content

Commit f156285

Browse files
committed
refactor: streamline captcha validation and login failure handling across serializers
1 parent ee06e8b commit f156285

5 files changed

Lines changed: 133 additions & 80 deletions

File tree

‎apps/chat/serializers/chat_user_serializer.py‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,7 @@ def local_login(instance):
5252
need_captcha = fail_count >= max_attempts
5353

5454
if need_captcha:
55-
if not captcha:
56-
raise AppApiException(1005, _("Captcha is required"))
57-
58-
captcha_cache = cache.get(
59-
Cache_Version.CAPTCHA.get_key(captcha=f"chat_{username}"), version=Cache_Version.CAPTCHA.get_version()
60-
)
61-
if captcha_cache is None or captcha.lower() != captcha_cache:
62-
raise AppApiException(1005, _("Captcha code error or expiration"))
55+
ChatUserAccessTokenV3Serializer._validate_captcha(username, captcha)
6356

6457
user = ChatUser.objects.filter(username=username).first()
6558

@@ -75,14 +68,29 @@ def local_login(instance):
7568
cache.delete(system_get_key(f"chat_{username}"), version=system_version)
7669
return user
7770

71+
@staticmethod
72+
def _validate_captcha(username: str, captcha: str) -> None:
73+
"""验证验证码(一次性消费)"""
74+
if not captcha:
75+
raise AppApiException(1005, _("Captcha is required"))
76+
77+
captcha_key = Cache_Version.CAPTCHA.get_key(captcha=f"chat_{username}")
78+
captcha_cache = cache.get(captcha_key, version=Cache_Version.CAPTCHA.get_version())
79+
80+
if captcha_cache is None or captcha.lower() != captcha_cache:
81+
record_login_fail(username)
82+
raise AppApiException(1005, _("Captcha code error or expiration"))
83+
84+
# 校验通过即销毁,保证验证码一次性使用
85+
cache.delete(captcha_key, version=Cache_Version.CAPTCHA.get_version())
86+
7887

7988
def record_login_fail(username: str, expire: int = 600):
80-
"""记录登录失败次数"""
89+
"""记录登录失败次数(原子递增)"""
8190
if not username:
8291
return
8392
fail_key = system_get_key(f"chat_{username}")
84-
fail_count = cache.get(fail_key, version=system_version)
85-
if fail_count is None:
86-
cache.set(fail_key, 1, timeout=expire, version=system_version)
87-
else:
93+
try:
8894
cache.incr(fail_key, 1, version=system_version)
95+
except ValueError:
96+
cache.set(fail_key, 1, timeout=expire, version=system_version)

‎apps/common/utils/common.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
import json
1414
import mimetypes
1515
import pickle
16-
import random
1716
import re
17+
import secrets
1818
import shutil
1919
import uuid
2020
from functools import reduce
@@ -117,7 +117,7 @@ def group_by(list_source: List, key):
117117
def get_random_chars(number=4):
118118
if number <= 0:
119119
return ""
120-
return "".join(random.choices(SAFE_CHAR_SET, k=number))
120+
return "".join(secrets.choice(SAFE_CHAR_SET) for _ in range(number))
121121

122122

123123
def encryption(message: str):

‎apps/portal/serializers/portal.py‎

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -147,25 +147,30 @@ def login(instance):
147147
raise AppApiException(500, _("Portal local login is not enabled"))
148148

149149
max_attempts = auth_config.get("max_attempts", 1)
150-
failed_attempts = auth_config.get("failed_attempts", 5)
151-
lock_time = auth_config.get("lock_time", 10)
152150

153-
license_validator = DatabaseModelManage.get_model("license_is_valid")
154-
is_license_valid = bool(license_validator()) if license_validator else False
151+
license_validator = DatabaseModelManage.get_model("license_is_valid") or (lambda: False)
152+
is_license_valid = license_validator() if license_validator() is not None else False
155153

156-
cache_key = system_get_key(f"portal_{username}")
157154
if is_license_valid:
158-
if PortalLoginSerializer._is_account_locked(username, failed_attempts):
159-
raise AppApiException(
160-
1005, _("This account has been locked for %s minutes, please try again later") % lock_time
161-
)
155+
failed_attempts = auth_config.get("failed_attempts", 5)
156+
lock_time = auth_config.get("lock_time", 10)
157+
else:
158+
failed_attempts = 5
159+
lock_time = 10
160+
161+
cache_key = system_get_key(f"portal_{username}")
162+
163+
if PortalLoginSerializer._is_account_locked(username, failed_attempts):
164+
raise AppApiException(
165+
1005, _("This account has been locked for %s minutes, please try again later") % lock_time
166+
)
162167
if PortalLoginSerializer._need_captcha(username, max_attempts):
163-
PortalLoginSerializer._validate_captcha(username, captcha)
168+
PortalLoginSerializer._validate_captcha(username, captcha, failed_attempts, lock_time)
164169

165170
user = ChatUser.objects.filter(username=username).first()
166171

167172
if not user or not password_verify(password, user.password):
168-
PortalLoginSerializer._handle_failed_login(username, is_license_valid, failed_attempts, lock_time)
173+
PortalLoginSerializer._handle_failed_login(username, failed_attempts, lock_time)
169174
raise AppApiException(500, _("The username or password is incorrect"))
170175

171176
if needs_password_upgrade(user.password):
@@ -234,17 +239,21 @@ def _need_captcha(username: str, max_attempts: int) -> bool:
234239
return True
235240

236241
@staticmethod
237-
def _validate_captcha(username: str, captcha: str) -> None:
242+
def _validate_captcha(username: str, captcha: str, failed_attempts: int = 5, lock_time: int = 10) -> None:
238243
if not captcha:
239244
raise AppApiException(1005, _("Captcha is required"))
240-
captcha_cache = cache.get(
241-
Cache_Version.CAPTCHA.get_key(captcha=f"portal_{username}"), version=Cache_Version.CAPTCHA.get_version()
242-
)
245+
captcha_key = Cache_Version.CAPTCHA.get_key(captcha=f"portal_{username}")
246+
captcha_cache = cache.get(captcha_key, version=Cache_Version.CAPTCHA.get_version())
243247
if captcha_cache is None or captcha.lower() != captcha_cache:
248+
# 校验失败与口令失败共用同一失败计数与锁定机制,防止"识别-试错"循环绕过验证码
249+
PortalLoginSerializer._record_login_failure(username, failed_attempts, lock_time)
244250
raise AppApiException(1005, _("Captcha code error or expiration"))
251+
# 校验通过即销毁,保证验证码一次性使用
252+
cache.delete(captcha_key, version=Cache_Version.CAPTCHA.get_version())
245253

246254
@staticmethod
247-
def _handle_failed_login(username: str, is_license_valid: bool, failed_attempts: int, lock_time: int) -> None:
255+
def _record_login_failure(username: str, failed_attempts: int, lock_time: int) -> int:
256+
"""记录一次认证失败(口令或验证码),累计计数并在达到阈值时创建锁键;不抛异常。"""
248257
try:
249258
_record_login_fail(username)
250259
except Exception:
@@ -254,7 +263,18 @@ def _handle_failed_login(username: str, is_license_valid: bool, failed_attempts:
254263
lock_fail_count = _record_login_fail_lock(username, lock_time)
255264
except Exception:
256265
pass
257-
if not is_license_valid or failed_attempts <= 0:
266+
if failed_attempts > 0 and lock_fail_count >= failed_attempts:
267+
try:
268+
cache.add(system_get_key(f"portal_{username}_lock"), 1, timeout=lock_time * 60, version=system_version)
269+
except Exception:
270+
pass
271+
return lock_fail_count
272+
273+
@staticmethod
274+
def _handle_failed_login(username: str, failed_attempts: int, lock_time: int) -> None:
275+
lock_fail_count = PortalLoginSerializer._record_login_failure(username, failed_attempts, lock_time)
276+
# 仅由失败次数配置控制(CE/PE 同样生效);计数在此之前已记录
277+
if failed_attempts <= 0:
258278
return
259279
if lock_fail_count < failed_attempts:
260280
remain_attempts = failed_attempts - lock_fail_count
@@ -263,10 +283,6 @@ def _handle_failed_login(username: str, is_license_valid: bool, failed_attempts:
263283
_("Login failed %s times, account will be locked, you have %s more chances !")
264284
% (failed_attempts, remain_attempts),
265285
)
266-
try:
267-
cache.add(system_get_key(f"portal_{username}_lock"), 1, timeout=lock_time * 60, version=system_version)
268-
except Exception:
269-
pass
270286
raise AppApiException(
271287
1005, _("This account has been locked for %s minutes, please try again later") % lock_time
272288
)

‎apps/system_manage/serializers/chat_user_serializer.py‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,7 @@ def local_login(instance, access_token):
113113
need_captcha = fail_count >= max_attempts
114114

115115
if need_captcha:
116-
if not captcha:
117-
raise AppApiException(1005, _("Captcha is required"))
118-
119-
captcha_cache = cache.get(
120-
Cache_Version.CAPTCHA.get_key(captcha=f"chat_{username}"), version=Cache_Version.CAPTCHA.get_version()
121-
)
122-
if captcha_cache is None or captcha.lower() != captcha_cache:
123-
raise AppApiException(1005, _("Captcha code error or expiration"))
116+
ChatUserAccessTokenSerializer._validate_captcha(username, captcha)
124117

125118
user = ChatUser.objects.filter(username=username).first()
126119

@@ -136,14 +129,30 @@ def local_login(instance, access_token):
136129
cache.delete(system_get_key(f"chat_{username}"), version=system_version)
137130
return user
138131

132+
@staticmethod
133+
def _validate_captcha(username: str, captcha: str) -> None:
134+
"""验证验证码(一次性消费)"""
135+
if not captcha:
136+
raise AppApiException(1005, _("Captcha is required"))
137+
138+
captcha_key = Cache_Version.CAPTCHA.get_key(captcha=f"chat_{username}")
139+
captcha_cache = cache.get(captcha_key, version=Cache_Version.CAPTCHA.get_version())
140+
141+
if captcha_cache is None or captcha.lower() != captcha_cache:
142+
# 校验失败也计入失败计数,防止"识别-试错"循环绕过验证码
143+
record_login_fail(username)
144+
raise AppApiException(1005, _("Captcha code error or expiration"))
145+
146+
# 校验通过即销毁,保证验证码一次性使用
147+
cache.delete(captcha_key, version=Cache_Version.CAPTCHA.get_version())
148+
139149

140150
def record_login_fail(username: str, expire: int = 600):
141-
"""记录登录失败次数"""
151+
"""记录登录失败次数(原子递增)"""
142152
if not username:
143153
return
144154
fail_key = system_get_key(f"chat_{username}")
145-
fail_count = cache.get(fail_key, version=system_version)
146-
if fail_count is None:
147-
cache.set(fail_key, 1, timeout=expire, version=system_version)
148-
else:
155+
try:
149156
cache.incr(fail_key, 1, version=system_version)
157+
except ValueError:
158+
cache.set(fail_key, 1, timeout=expire, version=system_version)

‎apps/users/serializers/login.py‎

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)