Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions apps/chat/serializers/chat_user_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,6 @@ def _get_login_policy(cls, access_token):
if portal is None or not portal.enable_auth:
return ("portal", 1, 5, 10)
auth_config = portal.auth_config or {}
if "LOCAL" not in auth_config.get("login_value", ["LOCAL"]):
raise AppApiException(500, _("Portal local login is not enabled"))
return (
"portal",
auth_config.get("max_attempts", 1),
Expand All @@ -70,21 +68,20 @@ def _need_captcha(cls, scope, username, max_attempts):
return True

@classmethod
def _validate_captcha(cls, scope, username, captcha, failed_attempts=5, lock_time=10):
def _validate_captcha(cls, scope, username, captcha):
if not captcha:
raise AppApiException(1005, _("Captcha is required"))
captcha_key = Cache_Version.CAPTCHA.get_key(captcha=f"{scope}_{username}")
captcha_cache = cache.get(captcha_key, version=Cache_Version.CAPTCHA.get_version())
if captcha_cache is None or captcha.lower() != captcha_cache:
# 校验失败与口令失败共用同一失败计数与锁定机制,防止"识别-试错"循环绕过验证码
cls._record_login_failure(scope, username, failed_attempts, lock_time)
# 验证码失败不计入失败次数统计 只提示
raise AppApiException(1005, _("Captcha code error or expiration"))
# 校验通过即销毁,保证验证码一次性使用
cache.delete(captcha_key, version=Cache_Version.CAPTCHA.get_version())

@classmethod
def _record_login_failure(cls, scope, username, failed_attempts, lock_time):
"""记录一次认证失败(口令或验证码),累计计数并在达到阈值时创建锁键;不抛异常。"""
"""记录一次口令认证失败,累计计数并在达到阈值时创建锁键;不抛异常。"""
try:
_record_login_fail(scope, username)
except Exception:
Expand Down Expand Up @@ -137,7 +134,7 @@ def local_login(cls, instance, access_token=None):
password = instance.get("password")
captcha = instance.get("captcha", "")
if cls._need_captcha(scope, username, max_attempts):
cls._validate_captcha(scope, username, captcha, failed_attempts, lock_time)
cls._validate_captcha(scope, username, captcha)

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

Expand Down
2 changes: 1 addition & 1 deletion apps/locales/en_US/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -9334,7 +9334,7 @@ msgid "token is required for EVENT triggers"
msgstr ""

msgid "Home page"
msgstr ""
msgstr "Overview"

msgid "Response code"
msgstr ""
Expand Down
2 changes: 1 addition & 1 deletion apps/locales/zh_CN/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -9459,7 +9459,7 @@ msgid "token is required for EVENT triggers"
msgstr "事件触发器必须设置 token"

msgid "Home page"
msgstr "首页"
msgstr "概览"

msgid "Response code"
msgstr "响应码"
Expand Down
2 changes: 1 addition & 1 deletion apps/locales/zh_Hant/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -9459,7 +9459,7 @@ msgid "token is required for EVENT triggers"
msgstr "事件觸發器必須設定 token"

msgid "Home page"
msgstr "首頁"
msgstr "概覽"

msgid "Response code"
msgstr "回應碼"
Expand Down
15 changes: 5 additions & 10 deletions apps/users/serializers/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def login(instance):
)
if LoginSerializer._need_captcha(username, max_attempts):
# 验证验证码
LoginSerializer._validate_captcha(username, captcha, failed_attempts, lock_time)
LoginSerializer._validate_captcha(username, captcha)

# 验证用户凭据:先按用户名查找,再用 password_verify 验证密码
user = LoginSerializer._authenticate(username, password)
Expand Down Expand Up @@ -197,7 +197,7 @@ def _need_captcha_by_key(cache_key: str, max_attempts: int) -> bool:
return True

@staticmethod
def _validate_captcha(username: str, captcha: str, failed_attempts: int = 5, lock_time: int = 10) -> None:
def _validate_captcha(username: str, captcha: str) -> None:
"""验证验证码(一次性消费)"""
if not captcha:
raise AppApiException(1005, _("Captcha is required"))
Expand All @@ -206,27 +206,22 @@ def _validate_captcha(username: str, captcha: str, failed_attempts: int = 5, loc
captcha_cache = cache.get(captcha_key, version=Cache_Version.CAPTCHA.get_version())

if captcha_cache is None or captcha.lower() != captcha_cache:
# 校验失败与口令失败共用同一失败计数与锁定机制,防止"识别-试错"循环绕过验证码
LoginSerializer._record_login_failure(username, failed_attempts, lock_time)
if LoginSerializer._is_account_locked(username, failed_attempts):
raise AppApiException(
1005, _("This account has been locked for %s minutes, please try again later") % lock_time
)
# 验证码失败不计入失败次数统计 只提示
raise AppApiException(1005, _("Captcha code error or expiration"))

# 校验通过即销毁,保证验证码一次性使用
cache.delete(captcha_key, version=Cache_Version.CAPTCHA.get_version())

@staticmethod
def _record_login_failure(username: str, failed_attempts: int, lock_time: int) -> int:
"""记录一次认证失败(口令或验证码),累计失败/锁定计数,达到阈值时创建锁键。
"""记录一次口令认证失败,累计失败/锁定计数,达到阈值时创建锁键。

修复要点:
- 使用 record_login_fail / record_login_fail_lock 两个原子 incr 来记录失败;
- 不再依赖精确等于 0 的比较来触发锁,而是基于原子计数 >= 阈值来决定进入锁定分支;
- 使用 cache.add 原子创建锁键,cache.add 保证只有第一个成功创建者可写入该键;
其他并发到达的请求若发现计数已到达阈值也应当返回"已锁定"响应,避免出现绕过。
- 不抛异常,返回当前锁定计数,供口令校验与验证码校验共用。
- 不抛异常,返回当前锁定计数,供口令校验使用。
"""
# 记录普通失败计数(供验证码触发使用)
try:
Expand Down
6 changes: 5 additions & 1 deletion apps/users/serializers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,11 @@ def _get_user_roles(user_ids, is_admin=True):
if not is_admin and relation.role.type == RoleConstants.ADMIN.name:
continue
user_role_mapping[user_id].add(relation.role.role_name)
user_role_setting_mapping[user_id][role_id].append(workspace_id)
if relation.role.type == RoleConstants.ADMIN.name:
# 系统管理员不关联工作空间,编辑页不显示空间(保持空列表)
user_role_setting_mapping[user_id][role_id]
else:
user_role_setting_mapping[user_id][role_id].append(workspace_id)
if relation.role.type == RoleConstants.ADMIN.name:
# 系统层级角色不关联具体空间,无需空间id
user_role_workspace_mapping[user_id].setdefault(relation.role.role_name, [])
Expand Down
4 changes: 2 additions & 2 deletions ui/src/api/admin/system/user-manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ const getUserManageSyncTypes = () => {
}

/** 从指定来源同步系统用户(file 来源需携带 xlsx 文件)。 */
const postSyncSystemUsers = (syncType: string, syncFile?: File, workspaceId?: string, roleId?: string, defaultGroupId?: string) => {
const postSyncSystemUsers = (syncType: string, syncFile?: File, workspaceId?: string, roleId?: string, defaultGroupIds: string[] = []) => {
const payload = new FormData()
if (workspaceId) payload.append('workspace_id', workspaceId)
if (roleId) payload.append('role_id', roleId)
if (defaultGroupId) payload.append('default_group_id', defaultGroupId)
for (const defaultGroupId of defaultGroupIds) payload.append('default_group_ids', defaultGroupId)
if (syncFile) payload.append('xlsx_file', syncFile)
return post<FormData, ChatUserSyncResult>(`${prefix}/sync/${syncType}`, payload)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,15 +209,13 @@ function submitImportUsers() {
if (!valid) return

const syncFile = isLocalFileSource.value ? ((importUsersForm.files[0]?.raw ?? undefined) as File | undefined) : undefined
const roleId = assignableRoles.value.find((role) => role.type === importUsersForm.role)?.id

importing.value = true
return UserManageApi.postSyncSystemUsers(
isLocalFileSource.value ? 'file' : importUsersForm.source,
syncFile,
importUsersForm.workspaceId || undefined,
importUsersForm.role,
importUsersForm.userGroupIds[0],
importUsersForm.userGroupIds,
)
.then((result) => {
const conflictUsers = result.conflict_users ?? []
Expand Down
Loading