From b861efa9ee0d476dce6d63f80973dcc8663b25e3 Mon Sep 17 00:00:00 2001 From: wxg0103 <727495428@qq.com> Date: Tue, 3 Nov 2026 18:18:46 +0800 Subject: [PATCH] refactor: simplify captcha validation and enhance user group handling in user synchronization --- apps/chat/serializers/chat_user_serializer.py | 11 ++++------- apps/locales/en_US/LC_MESSAGES/django.po | 2 +- apps/locales/zh_CN/LC_MESSAGES/django.po | 2 +- apps/locales/zh_Hant/LC_MESSAGES/django.po | 2 +- apps/users/serializers/login.py | 15 +++++---------- apps/users/serializers/user.py | 6 +++++- ui/src/api/admin/system/user-manage.ts | 4 ++-- .../users/import-users/ImportUsersDialog.vue | 4 +--- 8 files changed, 20 insertions(+), 26 deletions(-) diff --git a/apps/chat/serializers/chat_user_serializer.py b/apps/chat/serializers/chat_user_serializer.py index b87979ecffa..38e9dfaaccf 100644 --- a/apps/chat/serializers/chat_user_serializer.py +++ b/apps/chat/serializers/chat_user_serializer.py @@ -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), @@ -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: @@ -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() diff --git a/apps/locales/en_US/LC_MESSAGES/django.po b/apps/locales/en_US/LC_MESSAGES/django.po index 569285fb221..a75386aab2c 100644 --- a/apps/locales/en_US/LC_MESSAGES/django.po +++ b/apps/locales/en_US/LC_MESSAGES/django.po @@ -9334,7 +9334,7 @@ msgid "token is required for EVENT triggers" msgstr "" msgid "Home page" -msgstr "" +msgstr "Overview" msgid "Response code" msgstr "" diff --git a/apps/locales/zh_CN/LC_MESSAGES/django.po b/apps/locales/zh_CN/LC_MESSAGES/django.po index b57336df890..58a6e98d594 100644 --- a/apps/locales/zh_CN/LC_MESSAGES/django.po +++ b/apps/locales/zh_CN/LC_MESSAGES/django.po @@ -9459,7 +9459,7 @@ msgid "token is required for EVENT triggers" msgstr "事件触发器必须设置 token" msgid "Home page" -msgstr "首页" +msgstr "概览" msgid "Response code" msgstr "响应码" diff --git a/apps/locales/zh_Hant/LC_MESSAGES/django.po b/apps/locales/zh_Hant/LC_MESSAGES/django.po index 7d85ac57cd5..9a42db61a01 100644 --- a/apps/locales/zh_Hant/LC_MESSAGES/django.po +++ b/apps/locales/zh_Hant/LC_MESSAGES/django.po @@ -9459,7 +9459,7 @@ msgid "token is required for EVENT triggers" msgstr "事件觸發器必須設定 token" msgid "Home page" -msgstr "首頁" +msgstr "概覽" msgid "Response code" msgstr "回應碼" diff --git a/apps/users/serializers/login.py b/apps/users/serializers/login.py index ca1f182ffe9..8470a86bcce 100644 --- a/apps/users/serializers/login.py +++ b/apps/users/serializers/login.py @@ -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) @@ -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")) @@ -206,12 +206,7 @@ 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")) # 校验通过即销毁,保证验证码一次性使用 @@ -219,14 +214,14 @@ def _validate_captcha(username: str, captcha: str, failed_attempts: int = 5, loc @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: diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index 2eb5e81429a..4f0b15242fb 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -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, []) diff --git a/ui/src/api/admin/system/user-manage.ts b/ui/src/api/admin/system/user-manage.ts index 3719828d165..3ef4c564caa 100644 --- a/ui/src/api/admin/system/user-manage.ts +++ b/ui/src/api/admin/system/user-manage.ts @@ -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(`${prefix}/sync/${syncType}`, payload) } diff --git a/ui/src/views/system/identity/users/import-users/ImportUsersDialog.vue b/ui/src/views/system/identity/users/import-users/ImportUsersDialog.vue index b44f051dc86..96ff67cf00e 100644 --- a/ui/src/views/system/identity/users/import-users/ImportUsersDialog.vue +++ b/ui/src/views/system/identity/users/import-users/ImportUsersDialog.vue @@ -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 ?? []