From c2784c44adae38940f7ec77d55c5a07baa6b1cb1 Mon Sep 17 00:00:00 2001 From: wangruidong <940853815@qq.com> Date: Wed, 4 Sep 2024 15:49:59 +0800 Subject: [PATCH] feat: LDAP HA --- apps/authentication/backends/ldap.py | 126 ++++++---- apps/i18n/core/zh/LC_MESSAGES/django.po | 274 +++++++++++++-------- apps/jumpserver/conf.py | 20 ++ apps/jumpserver/settings/auth.py | 41 ++- apps/settings/api/ldap.py | 6 +- apps/settings/api/settings.py | 1 + apps/settings/serializers/auth/__init__.py | 1 + apps/settings/serializers/auth/base.py | 1 + apps/settings/serializers/auth/ldap_ha.py | 94 +++++++ apps/settings/tasks/ldap.py | 115 ++++++--- apps/settings/utils/ldap.py | 68 ++--- apps/settings/ws.py | 67 +++-- apps/users/models/user/_source.py | 2 + 13 files changed, 564 insertions(+), 252 deletions(-) create mode 100644 apps/settings/serializers/auth/ldap_ha.py diff --git a/apps/authentication/backends/ldap.py b/apps/authentication/backends/ldap.py index 616052af2..26ae2bb31 100644 --- a/apps/authentication/backends/ldap.py +++ b/apps/authentication/backends/ldap.py @@ -1,6 +1,6 @@ # coding:utf-8 # - +import abc import ldap from django.conf import settings from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist @@ -15,13 +15,16 @@ from .base import JMSBaseAuthBackend logger = _LDAPConfig.get_logger() -class LDAPAuthorizationBackend(JMSBaseAuthBackend, LDAPBackend): - """ - Override this class to override _LDAPUser to LDAPUser - """ - @staticmethod - def is_enabled(): - return settings.AUTH_LDAP +class LDAPBaseBackend(LDAPBackend): + + @abc.abstractmethod + def is_enabled(self): + raise NotImplementedError('is_enabled') + + @property + @abc.abstractmethod + def is_user_login_only_in_users(self): + raise NotImplementedError('is_authenticated') def get_or_build_user(self, username, ldap_user): """ @@ -56,38 +59,6 @@ class LDAPAuthorizationBackend(JMSBaseAuthBackend, LDAPBackend): return user, built - def pre_check(self, username, password): - if not settings.AUTH_LDAP: - error = 'Not enabled auth ldap' - return False, error - if not username: - error = 'Username is None' - return False, error - if not password: - error = 'Password is None' - return False, error - if settings.AUTH_LDAP_USER_LOGIN_ONLY_IN_USERS: - user_model = self.get_user_model() - exist = user_model.objects.filter(username=username).exists() - if not exist: - error = 'user ({}) is not in the user list'.format(username) - return False, error - return True, '' - - def authenticate(self, request=None, username=None, password=None, **kwargs): - logger.info('Authentication LDAP backend') - if username is None or password is None: - logger.info('No username or password') - return None - match, msg = self.pre_check(username, password) - if not match: - logger.info('Authenticate failed: {}'.format(msg)) - return None - ldap_user = LDAPUser(self, username=username.strip(), request=request) - user = self.authenticate_ldap_user(ldap_user, password) - logger.info('Authenticate user: {}'.format(user)) - return user if self.user_can_authenticate(user) else None - def get_user(self, user_id): user = None try: @@ -111,6 +82,67 @@ class LDAPAuthorizationBackend(JMSBaseAuthBackend, LDAPBackend): user = ldap_user.populate_user() return user + def authenticate(self, request=None, username=None, password=None, **kwargs): + logger.info('Authentication LDAP backend') + if username is None or password is None: + logger.info('No username or password') + return None + match, msg = self.pre_check(username, password) + if not match: + logger.info('Authenticate failed: {}'.format(msg)) + return None + ldap_user = LDAPUser(self, username=username.strip(), request=request) + user = self.authenticate_ldap_user(ldap_user, password) + logger.info('Authenticate user: {}'.format(user)) + return user if self.user_can_authenticate(user) else None + + def pre_check(self, username, password): + if not self.is_enabled(): + error = 'Not enabled auth ldap' + return False, error + if not username: + error = 'Username is None' + return False, error + if not password: + error = 'Password is None' + return False, error + if self.is_user_login_only_in_users: + user_model = self.get_user_model() + exist = user_model.objects.filter(username=username).exists() + if not exist: + error = 'user ({}) is not in the user list'.format(username) + return False, error + return True, '' + + +class LDAPAuthorizationBackend(JMSBaseAuthBackend, LDAPBaseBackend): + """ + Override this class to override _LDAPUser to LDAPUser + """ + + @staticmethod + def is_enabled(): + return settings.AUTH_LDAP + + @property + def is_user_login_only_in_users(self): + return settings.AUTH_LDAP_USER_LOGIN_ONLY_IN_USERS + + +class LDAPHAAuthorizationBackend(JMSBaseAuthBackend, LDAPBaseBackend): + """ + Override this class to override _LDAPUser to LDAPUser + """ + settings_prefix = "AUTH_LDAP_HA_" + + @staticmethod + def is_enabled(): + return settings.AUTH_LDAP_HA + + @property + def is_user_login_only_in_users(self): + return settings.AUTH_LDAP_HA_USER_LOGIN_ONLY_IN_USERS + class LDAPUser(_LDAPUser): @@ -126,13 +158,18 @@ class LDAPUser(_LDAPUser): configuration in the settings.py file is configured with a `lambda` problem value """ - + if isinstance(self.backend, LDAPAuthorizationBackend): + search_filter = settings.AUTH_LDAP_SEARCH_FILTER + search_ou = settings.AUTH_LDAP_SEARCH_OU + else: + search_filter = settings.AUTH_LDAP_HA_SEARCH_FILTER + search_ou = settings.AUTH_LDAP_HA_SEARCH_OU user_search_union = [ LDAPSearch( USER_SEARCH, ldap.SCOPE_SUBTREE, - settings.AUTH_LDAP_SEARCH_FILTER + search_filter ) - for USER_SEARCH in str(settings.AUTH_LDAP_SEARCH_OU).split("|") + for USER_SEARCH in str(search_ou).split("|") ] search = LDAPSearchUnion(*user_search_union) @@ -169,7 +206,8 @@ class LDAPUser(_LDAPUser): else: value = is_true(value) except LookupError: - logger.warning("{} does not have a value for the attribute {}".format(self.dn, attr)) + logger.warning( + "{} does not have a value for the attribute {}".format(self.dn, attr)) else: if not hasattr(self._user, field): continue diff --git a/apps/i18n/core/zh/LC_MESSAGES/django.po b/apps/i18n/core/zh/LC_MESSAGES/django.po index 0699f9316..2beb7c7eb 100644 --- a/apps/i18n/core/zh/LC_MESSAGES/django.po +++ b/apps/i18n/core/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-09-10 16:58+0800\n" +"POT-Creation-Date: 2024-09-11 18:15+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -124,9 +124,10 @@ msgstr "成功: %s, 失败: %s, 总数: %s" #: authentication/forms.py:28 #: authentication/templates/authentication/login.html:362 #: settings/serializers/auth/ldap.py:26 settings/serializers/auth/ldap.py:52 -#: settings/serializers/msg.py:37 settings/serializers/terminal.py:28 -#: terminal/serializers/storage.py:123 terminal/serializers/storage.py:142 -#: users/forms/profile.py:21 users/serializers/user.py:144 +#: settings/serializers/auth/ldap_ha.py:34 settings/serializers/msg.py:37 +#: settings/serializers/terminal.py:28 terminal/serializers/storage.py:123 +#: terminal/serializers/storage.py:142 users/forms/profile.py:21 +#: users/serializers/user.py:144 #: users/templates/users/_msg_user_created.html:13 #: users/templates/users/user_password_verify.html:18 #: xpack/plugins/cloud/serializers/account_attrs.py:28 @@ -549,7 +550,8 @@ msgstr "SSH 密钥推送方式" #: accounts/models/automations/gather_account.py:58 #: accounts/serializers/account/backup.py:40 #: accounts/serializers/automations/change_secret.py:58 -#: settings/serializers/auth/ldap.py:100 settings/serializers/msg.py:45 +#: settings/serializers/auth/ldap.py:100 +#: settings/serializers/auth/ldap_ha.py:82 settings/serializers/msg.py:45 msgid "Recipient" msgstr "收件人" @@ -707,8 +709,8 @@ msgstr "密码规则" #: authentication/models/ssh_key.py:12 #: authentication/serializers/connect_token_secret.py:113 #: authentication/serializers/connect_token_secret.py:169 labels/models.py:11 -#: ops/mixin.py:28 ops/models/adhoc.py:20 ops/models/celery.py:15 -#: ops/models/celery.py:81 ops/models/job.py:142 ops/models/playbook.py:28 +#: ops/mixin.py:28 ops/models/adhoc.py:19 ops/models/celery.py:15 +#: ops/models/celery.py:81 ops/models/job.py:142 ops/models/playbook.py:30 #: ops/serializers/job.py:18 orgs/models.py:82 #: perms/models/asset_permission.py:61 rbac/models/role.py:29 #: rbac/serializers/role.py:28 settings/models.py:35 settings/models.py:184 @@ -1037,8 +1039,8 @@ msgid "" msgstr "关联平台,可配置推送参数,如果不关联,将使用默认参数" #: accounts/serializers/account/virtual.py:19 assets/models/cmd_filter.py:40 -#: assets/models/cmd_filter.py:88 common/db/models.py:36 ops/models/adhoc.py:26 -#: ops/models/job.py:158 ops/models/playbook.py:31 rbac/models/role.py:37 +#: assets/models/cmd_filter.py:88 common/db/models.py:36 ops/models/adhoc.py:25 +#: ops/models/job.py:158 ops/models/playbook.py:33 rbac/models/role.py:37 #: settings/models.py:40 terminal/models/applet/applet.py:46 #: terminal/models/applet/applet.py:332 terminal/models/applet/host.py:143 #: terminal/models/component/endpoint.py:25 @@ -2974,9 +2976,9 @@ msgstr "用户会话" msgid "Offline user session" msgstr "下线用户会话" -#: audits/serializers.py:33 ops/models/adhoc.py:25 ops/models/base.py:16 +#: audits/serializers.py:33 ops/models/adhoc.py:24 ops/models/base.py:16 #: ops/models/base.py:53 ops/models/celery.py:87 ops/models/job.py:151 -#: ops/models/job.py:240 ops/models/playbook.py:30 +#: ops/models/job.py:240 ops/models/playbook.py:32 #: terminal/models/session/sharing.py:25 msgid "Creator" msgstr "创建者" @@ -3031,7 +3033,7 @@ msgstr "认证令牌" #: audits/signal_handlers/login_log.py:37 authentication/notifications.py:73 #: authentication/views/login.py:78 notifications/backends/__init__.py:11 #: settings/serializers/auth/wecom.py:11 settings/serializers/auth/wecom.py:16 -#: users/models/user/__init__.py:122 users/models/user/_source.py:18 +#: users/models/user/__init__.py:122 users/models/user/_source.py:19 msgid "WeCom" msgstr "企业微信" @@ -3039,21 +3041,21 @@ msgstr "企业微信" #: authentication/views/login.py:90 notifications/backends/__init__.py:14 #: settings/serializers/auth/feishu.py:12 #: settings/serializers/auth/feishu.py:14 users/models/user/__init__.py:128 -#: users/models/user/_source.py:20 +#: users/models/user/_source.py:21 msgid "FeiShu" msgstr "飞书" #: audits/signal_handlers/login_log.py:40 authentication/views/login.py:102 #: authentication/views/slack.py:79 notifications/backends/__init__.py:16 #: settings/serializers/auth/slack.py:11 settings/serializers/auth/slack.py:13 -#: users/models/user/__init__.py:134 users/models/user/_source.py:22 +#: users/models/user/__init__.py:134 users/models/user/_source.py:23 msgid "Slack" msgstr "Slack" #: audits/signal_handlers/login_log.py:41 authentication/views/dingtalk.py:151 #: authentication/views/login.py:84 notifications/backends/__init__.py:12 #: settings/serializers/auth/dingtalk.py:11 users/models/user/__init__.py:125 -#: users/models/user/_source.py:19 +#: users/models/user/_source.py:20 msgid "DingTalk" msgstr "钉钉" @@ -3500,7 +3502,7 @@ msgstr "设置手机号码启用" msgid "Clear phone number to disable" msgstr "清空手机号码禁用" -#: authentication/middleware.py:94 settings/utils/ldap.py:681 +#: authentication/middleware.py:94 settings/utils/ldap.py:691 msgid "Authentication failed (before login check failed): {}" msgstr "认证失败 (登录前检查失败): {}" @@ -3818,7 +3820,7 @@ msgstr "代码错误" #: authentication/templates/authentication/_msg_oauth_bind.html:3 #: authentication/templates/authentication/_msg_reset_password.html:3 #: authentication/templates/authentication/_msg_reset_password_code.html:9 -#: jumpserver/conf.py:502 +#: jumpserver/conf.py:522 #: perms/templates/perms/_msg_item_permissions_expire.html:3 #: tickets/templates/tickets/approve_check_password.html:32 #: users/templates/users/_msg_account_expire_reminder.html:4 @@ -4578,16 +4580,16 @@ msgstr "不能包含特殊字符" msgid "The mobile phone number format is incorrect" msgstr "手机号格式不正确" -#: jumpserver/conf.py:496 +#: jumpserver/conf.py:516 #, python-brace-format msgid "The verification code is: {code}" msgstr "验证码为: {code}" -#: jumpserver/conf.py:501 +#: jumpserver/conf.py:521 msgid "Create account successfully" msgstr "创建账号成功" -#: jumpserver/conf.py:503 +#: jumpserver/conf.py:523 msgid "Your account has been created successfully" msgstr "你的账号已创建成功" @@ -4749,31 +4751,31 @@ msgid "" "The task is being created and cannot be interrupted. Please try again later." msgstr "正在创建任务,无法中断,请稍后重试。" -#: ops/api/playbook.py:39 +#: ops/api/playbook.py:50 msgid "Currently playbook is being used in a job" msgstr "当前 playbook 正在作业中使用" -#: ops/api/playbook.py:97 +#: ops/api/playbook.py:113 msgid "Unsupported file content" msgstr "不支持的文件内容" -#: ops/api/playbook.py:99 ops/api/playbook.py:145 ops/api/playbook.py:193 +#: ops/api/playbook.py:115 ops/api/playbook.py:161 ops/api/playbook.py:209 msgid "Invalid file path" msgstr "无效的文件路径" -#: ops/api/playbook.py:171 +#: ops/api/playbook.py:187 msgid "This file can not be rename" msgstr "该文件不能重命名" -#: ops/api/playbook.py:190 +#: ops/api/playbook.py:206 msgid "File already exists" msgstr "文件已存在" -#: ops/api/playbook.py:208 +#: ops/api/playbook.py:224 msgid "File key is required" msgstr "文件密钥该字段是必填项。" -#: ops/api/playbook.py:211 +#: ops/api/playbook.py:227 msgid "This file can not be delete" msgstr "无法删除此文件" @@ -4817,7 +4819,7 @@ msgstr "VCS" msgid "Adhoc" msgstr "命令" -#: ops/const.py:39 ops/models/job.py:149 ops/models/playbook.py:88 +#: ops/const.py:39 ops/models/job.py:149 ops/models/playbook.py:91 msgid "Playbook" msgstr "Playbook" @@ -4881,21 +4883,31 @@ msgstr "超时" msgid "Command execution disabled" msgstr "命令执行已禁用" +#: ops/const.py:86 +msgctxt "scope" +msgid "Public" +msgstr "公有 + +#: ops/const.py:87 +msgid "Private" +msgstr "私有" + #: ops/exception.py:6 msgid "no valid program entry found." msgstr "没有可用程序入口" #: ops/mixin.py:30 ops/mixin.py:110 settings/serializers/auth/ldap.py:73 +#: settings/serializers/auth/ldap_ha.py:55 msgid "Periodic run" msgstr "周期执行" #: ops/mixin.py:32 ops/mixin.py:96 ops/mixin.py:116 -#: settings/serializers/auth/ldap.py:80 +#: settings/serializers/auth/ldap.py:80 settings/serializers/auth/ldap_ha.py:62 msgid "Interval" msgstr "间隔" #: ops/mixin.py:35 ops/mixin.py:94 ops/mixin.py:113 -#: settings/serializers/auth/ldap.py:77 +#: settings/serializers/auth/ldap.py:77 settings/serializers/auth/ldap_ha.py:59 msgid "Crontab" msgstr "Crontab" @@ -4915,19 +4927,25 @@ msgstr "输入在 {} - {} 范围之间" msgid "Require interval or crontab setting" msgstr "需要周期或定期设置" -#: ops/models/adhoc.py:21 +#: ops/models/adhoc.py:20 msgid "Pattern" msgstr "模式" -#: ops/models/adhoc.py:23 ops/models/job.py:146 +#: ops/models/adhoc.py:22 ops/models/job.py:146 msgid "Module" msgstr "模块" -#: ops/models/adhoc.py:24 ops/models/celery.py:82 ops/models/job.py:144 +#: ops/models/adhoc.py:23 ops/models/celery.py:82 ops/models/job.py:144 #: terminal/models/component/task.py:14 msgid "Args" msgstr "参数" +#: ops/models/adhoc.py:26 ops/models/playbook.py:36 ops/serializers/mixin.py:10 +#: rbac/models/role.py:31 rbac/models/rolebinding.py:46 +#: rbac/serializers/role.py:12 settings/serializers/auth/oauth2.py:37 +msgid "Scope" +msgstr "范围" + #: ops/models/base.py:19 msgid "Account policy" msgstr "账号策略" @@ -5015,11 +5033,11 @@ msgstr "Material 类型" msgid "Job Execution" msgstr "作业执行" -#: ops/models/playbook.py:33 +#: ops/models/playbook.py:35 msgid "CreateMethod" msgstr "创建方式" -#: ops/models/playbook.py:34 +#: ops/models/playbook.py:37 msgid "VCS URL" msgstr "VCS URL" @@ -5262,7 +5280,7 @@ msgstr "请选择一个组织后再保存" #: orgs/mixins/models.py:57 orgs/mixins/serializers.py:25 orgs/models.py:91 #: rbac/const.py:7 rbac/models/rolebinding.py:56 -#: rbac/serializers/rolebinding.py:44 settings/serializers/auth/base.py:52 +#: rbac/serializers/rolebinding.py:44 settings/serializers/auth/base.py:53 #: terminal/templates/terminal/_msg_command_warning.html:21 #: terminal/templates/terminal/_msg_session_sharing.html:14 #: tickets/models/ticket/general.py:303 tickets/serializers/ticket/ticket.py:60 @@ -5578,11 +5596,6 @@ msgstr "内容类型" msgid "Permissions" msgstr "授权" -#: rbac/models/role.py:31 rbac/models/rolebinding.py:46 -#: rbac/serializers/role.py:12 settings/serializers/auth/oauth2.py:37 -msgid "Scope" -msgstr "范围" - #: rbac/models/role.py:46 rbac/models/rolebinding.py:52 #: users/models/user/__init__.py:66 msgid "Role" @@ -5736,7 +5749,7 @@ msgstr "邮件已经发送{}, 请检查" msgid "Test smtp setting" msgstr "测试 smtp 设置" -#: settings/api/ldap.py:90 +#: settings/api/ldap.py:92 msgid "" "Users are not synchronized, please click the user synchronization button" msgstr "用户未同步,请点击同步用户按钮" @@ -5834,58 +5847,62 @@ msgid "LDAP Auth" msgstr "LDAP 认证" #: settings/serializers/auth/base.py:14 +msgid "LDAP Auth HA" +msgstr "LDAP HA 认证" + +#: settings/serializers/auth/base.py:15 msgid "CAS Auth" msgstr "CAS 认证" -#: settings/serializers/auth/base.py:15 +#: settings/serializers/auth/base.py:16 msgid "OPENID Auth" msgstr "OIDC 认证" -#: settings/serializers/auth/base.py:16 +#: settings/serializers/auth/base.py:17 msgid "SAML2 Auth" msgstr "SAML2 认证" -#: settings/serializers/auth/base.py:17 +#: settings/serializers/auth/base.py:18 msgid "OAuth2 Auth" msgstr "OAuth2 认证" -#: settings/serializers/auth/base.py:18 +#: settings/serializers/auth/base.py:19 msgid "RADIUS Auth" msgstr "RADIUS 认证" -#: settings/serializers/auth/base.py:19 +#: settings/serializers/auth/base.py:20 msgid "DingTalk Auth" msgstr "钉钉 认证" -#: settings/serializers/auth/base.py:20 +#: settings/serializers/auth/base.py:21 msgid "FeiShu Auth" msgstr "飞书 认证" -#: settings/serializers/auth/base.py:21 +#: settings/serializers/auth/base.py:22 msgid "Lark Auth" msgstr "Lark 认证" -#: settings/serializers/auth/base.py:22 +#: settings/serializers/auth/base.py:23 msgid "Slack Auth" msgstr "Slack 认证" -#: settings/serializers/auth/base.py:23 +#: settings/serializers/auth/base.py:24 msgid "WeCom Auth" msgstr "企业微信 认证" -#: settings/serializers/auth/base.py:24 +#: settings/serializers/auth/base.py:25 msgid "SSO Auth" msgstr "SSO 令牌认证" -#: settings/serializers/auth/base.py:25 +#: settings/serializers/auth/base.py:26 msgid "Passkey Auth" msgstr "Passkey 认证" -#: settings/serializers/auth/base.py:27 +#: settings/serializers/auth/base.py:28 msgid "Email suffix" msgstr "邮件后缀" -#: settings/serializers/auth/base.py:29 +#: settings/serializers/auth/base.py:30 msgid "" "After third-party user authentication is successful, if the third-party " "authentication service platform does not return the user's email " @@ -5895,19 +5912,19 @@ msgstr "" "第三方用户认证成功后,若第三方认证服务平台未返回该用户的邮箱信息,系统将自动" "以此邮箱后缀创建用户" -#: settings/serializers/auth/base.py:36 +#: settings/serializers/auth/base.py:37 msgid "Forgot Password URL" msgstr "忘记密码链接" -#: settings/serializers/auth/base.py:37 +#: settings/serializers/auth/base.py:38 msgid "The URL for Forgotten Password on the user login page" msgstr "用户登录页面忘记密码的 URL" -#: settings/serializers/auth/base.py:40 +#: settings/serializers/auth/base.py:41 msgid "Login redirection" msgstr "启用登录跳转提示" -#: settings/serializers/auth/base.py:42 +#: settings/serializers/auth/base.py:43 msgid "" "Should an flash page be displayed before the user is redirected to third-" "party authentication when the administrator enables third-party redirect " @@ -5916,7 +5933,7 @@ msgstr "" "当管理员启用第三方重定向身份验证时,在用户重定向到第三方身份验证之前是否显示 " "Flash 页面" -#: settings/serializers/auth/base.py:54 +#: settings/serializers/auth/base.py:55 msgid "" "When you create a user, you associate the user to the organization of your " "choice. Users always belong to the Default organization." @@ -5928,7 +5945,7 @@ msgid "CAS" msgstr "CAS" #: settings/serializers/auth/cas.py:15 settings/serializers/auth/ldap.py:44 -#: settings/serializers/auth/oidc.py:61 +#: settings/serializers/auth/ldap_ha.py:26 settings/serializers/auth/oidc.py:61 msgid "Server" msgstr "服务端地址" @@ -5955,9 +5972,10 @@ msgstr "启用属性映射" #: settings/serializers/auth/cas.py:34 settings/serializers/auth/dingtalk.py:18 #: settings/serializers/auth/feishu.py:18 settings/serializers/auth/lark.py:17 -#: settings/serializers/auth/ldap.py:66 settings/serializers/auth/oauth2.py:60 -#: settings/serializers/auth/oidc.py:39 settings/serializers/auth/saml2.py:35 -#: settings/serializers/auth/slack.py:18 settings/serializers/auth/wecom.py:18 +#: settings/serializers/auth/ldap.py:66 settings/serializers/auth/ldap_ha.py:48 +#: settings/serializers/auth/oauth2.py:60 settings/serializers/auth/oidc.py:39 +#: settings/serializers/auth/saml2.py:35 settings/serializers/auth/slack.py:18 +#: settings/serializers/auth/wecom.py:18 msgid "User attribute" msgstr "映射属性" @@ -5999,7 +6017,7 @@ msgstr "" "用户属性映射,其中 `key` 是 JumpServer 用户属性名称,`value` 是飞书服务用户属" "性名称" -#: settings/serializers/auth/lark.py:13 users/models/user/_source.py:21 +#: settings/serializers/auth/lark.py:13 users/models/user/_source.py:22 msgid "Lark" msgstr "" @@ -6019,38 +6037,38 @@ msgstr "LDAP" msgid "LDAP server URI" msgstr "LDAP 服务域名" -#: settings/serializers/auth/ldap.py:48 +#: settings/serializers/auth/ldap.py:48 settings/serializers/auth/ldap_ha.py:30 msgid "Bind DN" msgstr "绑定 DN" -#: settings/serializers/auth/ldap.py:49 +#: settings/serializers/auth/ldap.py:49 settings/serializers/auth/ldap_ha.py:31 msgid "Binding Distinguished Name" msgstr "绑定目录管理员" -#: settings/serializers/auth/ldap.py:53 +#: settings/serializers/auth/ldap.py:53 settings/serializers/auth/ldap_ha.py:35 msgid "Binding password" msgstr "绑定密码" -#: settings/serializers/auth/ldap.py:56 +#: settings/serializers/auth/ldap.py:56 settings/serializers/auth/ldap_ha.py:38 msgid "Search OU" msgstr "用户 OU" -#: settings/serializers/auth/ldap.py:58 +#: settings/serializers/auth/ldap.py:58 settings/serializers/auth/ldap_ha.py:40 msgid "" "User Search Base, if there are multiple OUs, you can separate them with the " "`|` symbol" msgstr "用户搜索库,如果有多个OU,可以用`|`符号分隔" -#: settings/serializers/auth/ldap.py:62 +#: settings/serializers/auth/ldap.py:62 settings/serializers/auth/ldap_ha.py:44 msgid "Search filter" msgstr "用户过滤器" -#: settings/serializers/auth/ldap.py:63 +#: settings/serializers/auth/ldap.py:63 settings/serializers/auth/ldap_ha.py:45 #, python-format msgid "Selection could include (cn|uid|sAMAccountName=%(user)s)" msgstr "可能的选项是(cn或uid或sAMAccountName=%(user)s)" -#: settings/serializers/auth/ldap.py:68 +#: settings/serializers/auth/ldap.py:68 settings/serializers/auth/ldap_ha.py:50 msgid "" "User attribute mapping, where the `key` is the JumpServer user attribute " "name and the `value` is the LDAP service user attribute name" @@ -6058,11 +6076,11 @@ msgstr "" "用户属性映射,其中 `key` 是 JumpServer 用户属性名称,`value` 是 LDAP 服务用户" "属性名称" -#: settings/serializers/auth/ldap.py:84 +#: settings/serializers/auth/ldap.py:84 settings/serializers/auth/ldap_ha.py:66 msgid "Connect timeout (s)" msgstr "连接超时时间 (秒)" -#: settings/serializers/auth/ldap.py:89 +#: settings/serializers/auth/ldap.py:89 settings/serializers/auth/ldap_ha.py:71 msgid "User DN cache timeout (s)" msgstr "User DN 缓存超时时间 (秒)" @@ -6076,10 +6094,29 @@ msgstr "" "对用户登录认证时查询出的 User DN 进行缓存,可以有效提高用户认证的速度
如果" "用户 OU 架构有调整,点击提交即可清除用户 DN 缓存" -#: settings/serializers/auth/ldap.py:97 +#: settings/serializers/auth/ldap.py:97 settings/serializers/auth/ldap_ha.py:79 msgid "Search paged size (piece)" msgstr "搜索分页数量 (条)" +#: settings/serializers/auth/ldap_ha.py:23 +#: settings/serializers/auth/ldap_ha.py:85 +msgid "LDAP HA" +msgstr "LDAP 认证" + +#: settings/serializers/auth/ldap_ha.py:27 +msgid "LDAP HA server URI" +msgstr "LDAP HA 服务域名" + +#: settings/serializers/auth/ldap_ha.py:73 +msgid "" +"Caching the User DN obtained during user login authentication can " +"effectivelyimprove the speed of user authentication., 0 means no cache
If " +"the user OU structure has been adjusted, click Submit to clear the user DN " +"cache" +msgstr "" +"对用户登录认证时查询出的 User DN 进行缓存,可以有效提高用户认证的速度
如果" +"用户 OU 架构有调整,点击提交即可清除用户 DN 缓存" + #: settings/serializers/auth/oauth2.py:19 #: settings/serializers/auth/oauth2.py:22 msgid "OAuth2" @@ -7085,11 +7122,11 @@ msgid "" "in the workbench" msgstr "*! 如果启用,具有 RBAC 权限的用户将能够使用工作台中的所有工具" -#: settings/tasks/ldap.py:29 +#: settings/tasks/ldap.py:72 msgid "Periodic import ldap user" msgstr "周期导入 LDAP 用户" -#: settings/tasks/ldap.py:31 +#: settings/tasks/ldap.py:74 settings/tasks/ldap.py:86 msgid "" "\n" " When LDAP auto-sync is configured, this task will be invoked to " @@ -7099,11 +7136,16 @@ msgstr "" "\n" "当设置了LDAP自动同步,将调用该任务进行用户同步" -#: settings/tasks/ldap.py:74 +#: settings/tasks/ldap.py:84 + +msgid "Periodic import ldap ha user" +msgstr "周期导入 LDAP HA 用户" + +#: settings/tasks/ldap.py:120 msgid "Registration periodic import ldap user task" msgstr "注册周期导入 LDAP 用户 任务" -#: settings/tasks/ldap.py:76 +#: settings/tasks/ldap.py:122 msgid "" "\n" " When LDAP auto-sync parameters change, such as Crontab parameters, " @@ -7113,7 +7155,23 @@ msgid "" msgstr "" "\n" "当设置了LDAP自动同步参数发生变化时,比如Crontab参数,重新注册或更新ldap同步任" -"务调用该任务" +"务将调用该任务" + +#: settings/tasks/ldap.py:138 +msgid "Registration periodic import ldap ha user task" +msgstr "注册周期导入 LDAP HA 用户 任务" + +#: settings/tasks/ldap.py:140 +msgid "" +"\n" +" When LDAP HA auto-sync parameters change, such as Crontab " +"parameters, the LDAP HA sync task \n" +" will be re-registered or updated, and this task will be invoked\n" +" " +msgstr "" +"\n" +"当设置了LDAP HA 自动同步参数发生变化时,比如Crontab参数,重新注册或更新ldap ha 同步任" +"务将调用该任务" #: settings/templates/ldap/_msg_import_ldap_user.html:2 msgid "Sync task finish" @@ -7131,108 +7189,108 @@ msgstr "已同步用户" msgid "No user synchronization required" msgstr "没有用户需要同步" -#: settings/utils/ldap.py:496 +#: settings/utils/ldap.py:509 msgid "ldap:// or ldaps:// protocol is used." msgstr "使用 ldap:// 或 ldaps:// 协议" -#: settings/utils/ldap.py:507 +#: settings/utils/ldap.py:520 msgid "Host or port is disconnected: {}" msgstr "主机或端口不可连接: {}" -#: settings/utils/ldap.py:509 +#: settings/utils/ldap.py:522 msgid "The port is not the port of the LDAP service: {}" msgstr "端口不是LDAP服务端口: {}" -#: settings/utils/ldap.py:511 +#: settings/utils/ldap.py:524 msgid "Please add certificate: {}" msgstr "请添加证书" -#: settings/utils/ldap.py:515 settings/utils/ldap.py:542 -#: settings/utils/ldap.py:572 settings/utils/ldap.py:600 +#: settings/utils/ldap.py:528 settings/utils/ldap.py:555 +#: settings/utils/ldap.py:585 settings/utils/ldap.py:613 msgid "Unknown error: {}" msgstr "未知错误: {}" -#: settings/utils/ldap.py:529 +#: settings/utils/ldap.py:542 msgid "Bind DN or Password incorrect" msgstr "绑定DN或密码错误" -#: settings/utils/ldap.py:536 +#: settings/utils/ldap.py:549 msgid "Please enter Bind DN: {}" msgstr "请输入绑定DN: {}" -#: settings/utils/ldap.py:538 +#: settings/utils/ldap.py:551 msgid "Please enter Password: {}" msgstr "请输入密码: {}" -#: settings/utils/ldap.py:540 +#: settings/utils/ldap.py:553 msgid "Please enter correct Bind DN and Password: {}" msgstr "请输入正确的绑定DN和密码: {}" -#: settings/utils/ldap.py:558 +#: settings/utils/ldap.py:571 msgid "Invalid User OU or User search filter: {}" msgstr "不合法的用户OU或用户过滤器: {}" -#: settings/utils/ldap.py:589 +#: settings/utils/ldap.py:602 msgid "LDAP User attr map not include: {}" msgstr "LDAP属性映射没有包含: {}" -#: settings/utils/ldap.py:596 +#: settings/utils/ldap.py:609 msgid "LDAP User attr map is not dict" msgstr "LDAP属性映射不合法" -#: settings/utils/ldap.py:615 +#: settings/utils/ldap.py:628 msgid "LDAP authentication is not enabled" msgstr "LDAP认证没有启用" -#: settings/utils/ldap.py:633 +#: settings/utils/ldap.py:646 msgid "Error (Invalid LDAP server): {}" msgstr "错误 (不合法的LDAP服务器地址): {}" -#: settings/utils/ldap.py:635 +#: settings/utils/ldap.py:648 msgid "Error (Invalid Bind DN): {}" msgstr "错误 (不合法的绑定DN): {}" -#: settings/utils/ldap.py:637 +#: settings/utils/ldap.py:650 msgid "Error (Invalid LDAP User attr map): {}" msgstr "错误 (不合法的LDAP属性映射): {}" -#: settings/utils/ldap.py:639 +#: settings/utils/ldap.py:652 msgid "Error (Invalid User OU or User search filter): {}" msgstr "错误 (不合法的用户OU或用户过滤器): {}" -#: settings/utils/ldap.py:641 +#: settings/utils/ldap.py:654 msgid "Error (Not enabled LDAP authentication): {}" msgstr "错误 (没有启用LDAP认证): {}" -#: settings/utils/ldap.py:643 +#: settings/utils/ldap.py:656 msgid "Error (Unknown): {}" msgstr "错误 (未知): {}" -#: settings/utils/ldap.py:646 +#: settings/utils/ldap.py:659 msgid "Succeed: Match {} users" msgstr "成功匹配 {} 个用户" -#: settings/utils/ldap.py:679 +#: settings/utils/ldap.py:689 msgid "Authentication failed (configuration incorrect): {}" msgstr "认证失败 (配置错误): {}" -#: settings/utils/ldap.py:683 +#: settings/utils/ldap.py:693 msgid "Authentication failed (username or password incorrect): {}" msgstr "认证失败 (用户名或密码不正确): {}" -#: settings/utils/ldap.py:685 +#: settings/utils/ldap.py:695 msgid "Authentication failed (Unknown): {}" msgstr "认证失败: (未知): {}" -#: settings/utils/ldap.py:688 +#: settings/utils/ldap.py:698 msgid "Authentication success: {}" msgstr "认证成功: {}" -#: settings/ws.py:203 +#: settings/ws.py:201 msgid "No LDAP user was found" msgstr "没有获取到 LDAP 用户" -#: settings/ws.py:209 +#: settings/ws.py:207 msgid "Total {}, success {}, failure {}" msgstr "总共 {},成功 {},失败 {}" diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index c352f7739..0e5843560 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -288,6 +288,26 @@ class Config(dict): 'AUTH_LDAP_USER_LOGIN_ONLY_IN_USERS': False, 'AUTH_LDAP_OPTIONS_OPT_REFERRALS': -1, + # Auth LDAP HA settings + 'AUTH_LDAP_HA': False, + 'AUTH_LDAP_HA_SERVER_URI': 'ldap://localhost:389', + 'AUTH_LDAP_HA_BIND_DN': 'cn=admin,dc=jumpserver,dc=org', + 'AUTH_LDAP_HA_BIND_PASSWORD': '', + 'AUTH_LDAP_HA_SEARCH_OU': 'ou=tech,dc=jumpserver,dc=org', + 'AUTH_LDAP_HA_SEARCH_FILTER': '(cn=%(user)s)', + 'AUTH_LDAP_HA_START_TLS': False, + 'AUTH_LDAP_HA_USER_ATTR_MAP': {"username": "cn", "name": "sn", "email": "mail"}, + 'AUTH_LDAP_HA_CONNECT_TIMEOUT': 10, + 'AUTH_LDAP_HA_CACHE_TIMEOUT': 3600 * 24 * 30, + 'AUTH_LDAP_HA_SEARCH_PAGED_SIZE': 1000, + 'AUTH_LDAP_HA_SYNC_IS_PERIODIC': False, + 'AUTH_LDAP_HA_SYNC_INTERVAL': None, + 'AUTH_LDAP_HA_SYNC_CRONTAB': None, + 'AUTH_LDAP_HA_SYNC_ORG_IDS': [DEFAULT_ID], + 'AUTH_LDAP_HA_SYNC_RECEIVERS': [], + 'AUTH_LDAP_HA_USER_LOGIN_ONLY_IN_USERS': False, + 'AUTH_LDAP_HA_OPTIONS_OPT_REFERRALS': -1, + # OpenID 配置参数 # OpenID 公有配置参数 (version <= 1.5.8 或 version >= 1.5.8) 'AUTH_OPENID': False, diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index c75a2f275..2385962b1 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -53,6 +53,44 @@ AUTH_LDAP_SYNC_ORG_IDS = CONFIG.AUTH_LDAP_SYNC_ORG_IDS AUTH_LDAP_SYNC_RECEIVERS = CONFIG.AUTH_LDAP_SYNC_RECEIVERS AUTH_LDAP_USER_LOGIN_ONLY_IN_USERS = CONFIG.AUTH_LDAP_USER_LOGIN_ONLY_IN_USERS +# Auth LDAP HA settings +AUTH_LDAP_HA = CONFIG.AUTH_LDAP_HA +AUTH_LDAP_HA_SERVER_URI = CONFIG.AUTH_LDAP_HA_SERVER_URI +AUTH_LDAP_HA_BIND_DN = CONFIG.AUTH_LDAP_HA_BIND_DN +AUTH_LDAP_HA_BIND_PASSWORD = CONFIG.AUTH_LDAP_HA_BIND_PASSWORD +AUTH_LDAP_HA_SEARCH_OU = CONFIG.AUTH_LDAP_HA_SEARCH_OU +AUTH_LDAP_HA_SEARCH_FILTER = CONFIG.AUTH_LDAP_HA_SEARCH_FILTER +AUTH_LDAP_HA_START_TLS = CONFIG.AUTH_LDAP_HA_START_TLS +AUTH_LDAP_HA_USER_ATTR_MAP = CONFIG.AUTH_LDAP_HA_USER_ATTR_MAP +AUTH_LDAP_HA_USER_QUERY_FIELD = 'username' +AUTH_LDAP_HA_GLOBAL_OPTIONS = { + ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER, + ldap.OPT_REFERRALS: CONFIG.AUTH_LDAP_HA_OPTIONS_OPT_REFERRALS +} +LDAP_HA_CACERT_FILE = os.path.join(PROJECT_DIR, "data", "certs", "ldap_ha_ca.pem") +if os.path.isfile(LDAP_HA_CACERT_FILE): + AUTH_LDAP_HA_GLOBAL_OPTIONS[ldap.OPT_X_TLS_CACERTFILE] = LDAP_CACERT_FILE +LDAP_HA_CERT_FILE = os.path.join(PROJECT_DIR, "data", "certs", "ldap_ha_cert.pem") +if os.path.isfile(LDAP_HA_CERT_FILE): + AUTH_LDAP_HA_GLOBAL_OPTIONS[ldap.OPT_X_TLS_CERTFILE] = LDAP_HA_CERT_FILE +LDAP_HA_KEY_FILE = os.path.join(PROJECT_DIR, "data", "certs", "ldap_ha_cert.key") +if os.path.isfile(LDAP_HA_KEY_FILE): + AUTH_LDAP_HA_GLOBAL_OPTIONS[ldap.OPT_X_TLS_KEYFILE] = LDAP_HA_KEY_FILE +AUTH_LDAP_HA_CONNECTION_OPTIONS = { + ldap.OPT_TIMEOUT: CONFIG.AUTH_LDAP_HA_CONNECT_TIMEOUT, + ldap.OPT_NETWORK_TIMEOUT: CONFIG.AUTH_LDAP_HA_CONNECT_TIMEOUT +} +AUTH_LDAP_HA_CACHE_TIMEOUT = CONFIG.AUTH_LDAP_HA_CACHE_TIMEOUT +AUTH_LDAP_HA_ALWAYS_UPDATE_USER = True + +AUTH_LDAP_HA_SEARCH_PAGED_SIZE = CONFIG.AUTH_LDAP_HA_SEARCH_PAGED_SIZE +AUTH_LDAP_HA_SYNC_IS_PERIODIC = CONFIG.AUTH_LDAP_HA_SYNC_IS_PERIODIC +AUTH_LDAP_HA_SYNC_INTERVAL = CONFIG.AUTH_LDAP_HA_SYNC_INTERVAL +AUTH_LDAP_HA_SYNC_CRONTAB = CONFIG.AUTH_LDAP_HA_SYNC_CRONTAB +AUTH_LDAP_HA_SYNC_ORG_IDS = CONFIG.AUTH_LDAP_HA_SYNC_ORG_IDS +AUTH_LDAP_HA_SYNC_RECEIVERS = CONFIG.AUTH_LDAP_HA_SYNC_RECEIVERS +AUTH_LDAP_HA_USER_LOGIN_ONLY_IN_USERS = CONFIG.AUTH_LDAP_HA_USER_LOGIN_ONLY_IN_USERS + # ============================================================================== # 认证 OpenID 配置参数 # 参考: https://django-oidc-rp.readthedocs.io/en/stable/settings.html @@ -212,6 +250,7 @@ RBAC_BACKEND = 'rbac.backends.RBACBackend' AUTH_BACKEND_MODEL = 'authentication.backends.base.JMSModelBackend' AUTH_BACKEND_PUBKEY = 'authentication.backends.pubkey.PublicKeyAuthBackend' AUTH_BACKEND_LDAP = 'authentication.backends.ldap.LDAPAuthorizationBackend' +AUTH_BACKEND_LDAP_HA = 'authentication.backends.ldap.LDAPHAAuthorizationBackend' AUTH_BACKEND_OIDC_PASSWORD = 'authentication.backends.oidc.OIDCAuthPasswordBackend' AUTH_BACKEND_OIDC_CODE = 'authentication.backends.oidc.OIDCAuthCodeBackend' AUTH_BACKEND_RADIUS = 'authentication.backends.radius.RadiusBackend' @@ -232,7 +271,7 @@ AUTHENTICATION_BACKENDS = [ # 只做权限校验 RBAC_BACKEND, # 密码形式 - AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_LDAP, AUTH_BACKEND_RADIUS, + AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_LDAP, AUTH_BACKEND_LDAP_HA, AUTH_BACKEND_RADIUS, # 跳转形式 AUTH_BACKEND_CAS, AUTH_BACKEND_OIDC_PASSWORD, AUTH_BACKEND_OIDC_CODE, AUTH_BACKEND_SAML2, AUTH_BACKEND_OAUTH2, diff --git a/apps/settings/api/ldap.py b/apps/settings/api/ldap.py index 3c596cf19..1f853060b 100644 --- a/apps/settings/api/ldap.py +++ b/apps/settings/api/ldap.py @@ -26,12 +26,14 @@ class LDAPUserListApi(generics.ListAPIView): def get_queryset_from_cache(self): search_value = self.request.query_params.get('search') - users = LDAPCacheUtil().search(search_value=search_value) + category = self.request.query_params.get('category') + users = LDAPCacheUtil(category=category).search(search_value=search_value) return users def get_queryset_from_server(self): search_value = self.request.query_params.get('search') - users = LDAPServerUtil().search(search_value=search_value) + category = self.request.query_params.get('category') + users = LDAPServerUtil(category=category).search(search_value=search_value) return users def get_queryset(self): diff --git a/apps/settings/api/settings.py b/apps/settings/api/settings.py index d543bbb2c..dfd5653d7 100644 --- a/apps/settings/api/settings.py +++ b/apps/settings/api/settings.py @@ -36,6 +36,7 @@ class SettingsApi(generics.RetrieveUpdateAPIView): 'security_password': serializers.SecurityPasswordRuleSerializer, 'security_login_limit': serializers.SecurityLoginLimitSerializer, 'ldap': serializers.LDAPSettingSerializer, + 'ldap_ha': serializers.LDAPHASettingSerializer, 'email': serializers.EmailSettingSerializer, 'email_content': serializers.EmailContentSettingSerializer, 'wecom': serializers.WeComSettingSerializer, diff --git a/apps/settings/serializers/auth/__init__.py b/apps/settings/serializers/auth/__init__.py index b5f567618..c1d6ab8f2 100644 --- a/apps/settings/serializers/auth/__init__.py +++ b/apps/settings/serializers/auth/__init__.py @@ -4,6 +4,7 @@ from .dingtalk import * from .feishu import * from .lark import * from .ldap import * +from .ldap_ha import * from .oauth2 import * from .oidc import * from .passkey import * diff --git a/apps/settings/serializers/auth/base.py b/apps/settings/serializers/auth/base.py index abfbf79b4..771ea6162 100644 --- a/apps/settings/serializers/auth/base.py +++ b/apps/settings/serializers/auth/base.py @@ -11,6 +11,7 @@ class AuthSettingSerializer(serializers.Serializer): PREFIX_TITLE = _('Authentication') AUTH_LDAP = serializers.BooleanField(required=False, label=_('LDAP Auth')) + AUTH_LDAP_HA = serializers.BooleanField(required=False, label=_('LDAP Auth HA')) AUTH_CAS = serializers.BooleanField(required=False, label=_('CAS Auth')) AUTH_OPENID = serializers.BooleanField(required=False, label=_('OPENID Auth')) AUTH_SAML2 = serializers.BooleanField(default=False, label=_("SAML2 Auth")) diff --git a/apps/settings/serializers/auth/ldap_ha.py b/apps/settings/serializers/auth/ldap_ha.py new file mode 100644 index 000000000..c653beeda --- /dev/null +++ b/apps/settings/serializers/auth/ldap_ha.py @@ -0,0 +1,94 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from common.serializers.fields import EncryptedField +from .base import OrgListField + +__all__ = ['LDAPHATestConfigSerializer', 'LDAPHASettingSerializer'] + + +class LDAPHATestConfigSerializer(serializers.Serializer): + AUTH_LDAP_HA_SERVER_URI = serializers.CharField(max_length=1024) + AUTH_LDAP_HA_BIND_DN = serializers.CharField(max_length=1024, required=False, allow_blank=True) + AUTH_LDAP_HA_BIND_PASSWORD = EncryptedField(required=False, allow_blank=True) + AUTH_LDAP_HA_SEARCH_OU = serializers.CharField() + AUTH_LDAP_HA_SEARCH_FILTER = serializers.CharField() + AUTH_LDAP_HA_USER_ATTR_MAP = serializers.JSONField() + AUTH_LDAP_HA_START_TLS = serializers.BooleanField(required=False) + AUTH_LDAP_HA = serializers.BooleanField(required=False) + + +class LDAPHASettingSerializer(serializers.Serializer): + # encrypt_fields 现在使用 write_only 来判断了 + PREFIX_TITLE = _('LDAP HA') + + AUTH_LDAP_HA_SERVER_URI = serializers.CharField( + required=True, max_length=1024, label=_('Server'), + help_text=_('LDAP HA server URI') + ) + AUTH_LDAP_HA_BIND_DN = serializers.CharField( + required=False, max_length=1024, label=_('Bind DN'), + help_text=_('Binding Distinguished Name') + ) + AUTH_LDAP_HA_BIND_PASSWORD = EncryptedField( + max_length=1024, required=False, label=_('Password'), + help_text=_('Binding password') + ) + AUTH_LDAP_HA_SEARCH_OU = serializers.CharField( + max_length=1024, allow_blank=True, required=False, label=_('Search OU'), + help_text=_( + 'User Search Base, if there are multiple OUs, you can separate them with the `|` symbol' + ) + ) + AUTH_LDAP_HA_SEARCH_FILTER = serializers.CharField( + max_length=1024, required=True, label=_('Search filter'), + help_text=_('Selection could include (cn|uid|sAMAccountName=%(user)s)') + ) + AUTH_LDAP_HA_USER_ATTR_MAP = serializers.JSONField( + required=True, label=_('User attribute'), + help_text=_( + 'User attribute mapping, where the `key` is the JumpServer user attribute name and the ' + '`value` is the LDAP service user attribute name' + ) + ) + AUTH_LDAP_HA_SYNC_IS_PERIODIC = serializers.BooleanField( + required=False, label=_('Periodic run') + ) + AUTH_LDAP_HA_SYNC_CRONTAB = serializers.CharField( + required=False, max_length=128, allow_null=True, allow_blank=True, + label=_('Crontab') + ) + AUTH_LDAP_HA_SYNC_INTERVAL = serializers.IntegerField( + required=False, default=24, allow_null=True, label=_('Interval') + ) + AUTH_LDAP_HA_CONNECT_TIMEOUT = serializers.IntegerField( + min_value=1, max_value=300, + required=False, label=_('Connect timeout (s)'), + ) + AUTH_LDAP_HA_CACHE_TIMEOUT = serializers.IntegerField( + min_value=0, max_value=3600 * 24 * 30 * 12, + default=3600 * 24 * 30, + required=False, label=_('User DN cache timeout (s)'), + help_text=_( + 'Caching the User DN obtained during user login authentication can effectively' + 'improve the speed of user authentication., 0 means no cache
' + 'If the user OU structure has been adjusted, click Submit to clear the user DN cache' + ) + ) + AUTH_LDAP_HA_SEARCH_PAGED_SIZE = serializers.IntegerField( + required=False, label=_('Search paged size (piece)') + ) + AUTH_LDAP_HA_SYNC_RECEIVERS = serializers.ListField( + required=False, label=_('Recipient'), max_length=36 + ) + + AUTH_LDAP_HA = serializers.BooleanField(required=False, label=_('LDAP HA')) + AUTH_LDAP_HA_SYNC_ORG_IDS = OrgListField() + + def post_save(self): + keys = ['AUTH_LDAP_HA_SYNC_IS_PERIODIC', 'AUTH_LDAP_HA_SYNC_INTERVAL', 'AUTH_LDAP_HA_SYNC_CRONTAB'] + kwargs = {k: self.validated_data[k] for k in keys if k in self.validated_data} + if not kwargs: + return + from settings.tasks import import_ldap_ha_user_periodic + import_ldap_ha_user_periodic(**kwargs) diff --git a/apps/settings/tasks/ldap.py b/apps/settings/tasks/ldap.py index 86faa47cb..8a455cfaa 100644 --- a/apps/settings/tasks/ldap.py +++ b/apps/settings/tasks/ldap.py @@ -1,5 +1,4 @@ # coding: utf-8 -# import time from celery import shared_task from django.conf import settings @@ -16,45 +15,44 @@ from settings.notifications import LDAPImportMessage from users.models import User from ..utils import LDAPSyncUtil, LDAPServerUtil, LDAPImportUtil -__all__ = ['sync_ldap_user', 'import_ldap_user_periodic', 'import_ldap_user'] +__all__ = [ + 'sync_ldap_user', 'import_ldap_user_periodic', 'import_ldap_ha_user_periodic', + 'import_ldap_user', 'import_ldap_ha_user' +] logger = get_logger(__file__) -def sync_ldap_user(): - LDAPSyncUtil().perform_sync() +def sync_ldap_user(category='ldap'): + LDAPSyncUtil(category=category).perform_sync() -@shared_task( - verbose_name=_('Periodic import ldap user'), - description=_( - """ - When LDAP auto-sync is configured, this task will be invoked to synchronize users - """ - ) -) -def import_ldap_user(): +def perform_import(category, util_server): start_time = time.time() time_start_display = local_now_display() - logger.info("Start import ldap user task") - util_server = LDAPServerUtil() + logger.info(f"Start import {category} ldap user task") + util_import = LDAPImportUtil() users = util_server.search() + if settings.XPACK_ENABLED: - org_ids = settings.AUTH_LDAP_SYNC_ORG_IDS + org_ids = getattr(settings, f"AUTH_{category.upper()}_SYNC_ORG_IDS") default_org = None else: - # 社区版默认导入Default组织 org_ids = [Organization.DEFAULT_ID] default_org = Organization.default() + orgs = list(set([Organization.get_instance(org_id, default=default_org) for org_id in org_ids])) new_users, errors = util_import.perform_import(users, orgs) + if errors: - logger.error("Imported LDAP users errors: {}".format(errors)) + logger.error(f"Imported {category} LDAP users errors: {errors}") else: - logger.info('Imported {} users successfully'.format(len(users))) - if settings.AUTH_LDAP_SYNC_RECEIVERS: - user_ids = settings.AUTH_LDAP_SYNC_RECEIVERS + logger.info(f"Imported {len(users)} {category} users successfully") + + receivers_setting = f"AUTH_{category.upper()}_SYNC_RECEIVERS" + if getattr(settings, receivers_setting, None): + user_ids = getattr(settings, receivers_setting) recipient_list = User.objects.filter(id__in=list(user_ids)) end_time = time.time() extra_kwargs = { @@ -71,33 +69,84 @@ def import_ldap_user(): @shared_task( - verbose_name=_('Registration periodic import ldap user task'), + verbose_name=_('Periodic import ldap user'), description=_( """ - When LDAP auto-sync parameters change, such as Crontab parameters, the LDAP sync task - will be re-registered or updated, and this task will be invoked + When LDAP auto-sync is configured, this task will be invoked to synchronize users """ ) ) -@after_app_ready_start -def import_ldap_user_periodic(**kwargs): - task_name = 'import_ldap_user_periodic' - interval = kwargs.get('AUTH_LDAP_SYNC_INTERVAL', settings.AUTH_LDAP_SYNC_INTERVAL) - enabled = kwargs.get('AUTH_LDAP_SYNC_IS_PERIODIC', settings.AUTH_LDAP_SYNC_IS_PERIODIC) - crontab = kwargs.get('AUTH_LDAP_SYNC_CRONTAB', settings.AUTH_LDAP_SYNC_CRONTAB) +def import_ldap_user(): + perform_import('ldap', LDAPServerUtil()) + + +@shared_task( + verbose_name=_('Periodic import ldap ha user'), + description=_( + """ + When LDAP auto-sync is configured, this task will be invoked to synchronize users + """ + ) +) +def import_ldap_ha_user(): + perform_import('ldap_ha', LDAPServerUtil(category='ldap_ha')) + + +def register_periodic_task(task_name, task_func, interval_key, enabled_key, crontab_key, **kwargs): + interval = kwargs.get(interval_key, settings.AUTH_LDAP_SYNC_INTERVAL) + enabled = kwargs.get(enabled_key, settings.AUTH_LDAP_SYNC_IS_PERIODIC) + crontab = kwargs.get(crontab_key, settings.AUTH_LDAP_SYNC_CRONTAB) + if isinstance(interval, int): interval = interval * 3600 else: interval = None + if crontab: - # 优先使用 crontab - interval = None + interval = None # 优先使用 crontab + tasks = { task_name: { - 'task': import_ldap_user.name, + 'task': task_func.name, 'interval': interval, 'crontab': crontab, 'enabled': enabled } } create_or_update_celery_periodic_tasks(tasks) + + +@shared_task( + verbose_name=_('Registration periodic import ldap user task'), + description=_( + """ + When LDAP auto-sync parameters change, such as Crontab parameters, the LDAP sync task + will be re-registered or updated, and this task will be invoked + """ + ) +) +@after_app_ready_start +def import_ldap_user_periodic(**kwargs): + register_periodic_task( + 'import_ldap_user_periodic', import_ldap_user, + 'AUTH_LDAP_SYNC_INTERVAL', 'AUTH_LDAP_SYNC_IS_PERIODIC', + 'AUTH_LDAP_SYNC_CRONTAB', **kwargs + ) + + +@shared_task( + verbose_name=_('Registration periodic import ldap ha user task'), + description=_( + """ + When LDAP HA auto-sync parameters change, such as Crontab parameters, the LDAP HA sync task + will be re-registered or updated, and this task will be invoked + """ + ) +) +@after_app_ready_start +def import_ldap_ha_user_periodic(**kwargs): + register_periodic_task( + 'import_ldap_ha_user_periodic', import_ldap_ha_user, + 'AUTH_LDAP_HA_SYNC_INTERVAL', 'AUTH_LDAP_HA_SYNC_IS_PERIODIC', + 'AUTH_LDAP_HA_SYNC_CRONTAB', **kwargs + ) diff --git a/apps/settings/utils/ldap.py b/apps/settings/utils/ldap.py index 6f0fc97de..ca0a4f75a 100644 --- a/apps/settings/utils/ldap.py +++ b/apps/settings/utils/ldap.py @@ -24,7 +24,8 @@ from ldap3.core.exceptions import ( LDAPAttributeError, ) -from authentication.backends.ldap import LDAPAuthorizationBackend, LDAPUser +from authentication.backends.ldap import LDAPAuthorizationBackend, LDAPUser, \ + LDAPHAAuthorizationBackend from common.const import LDAP_AD_ACCOUNT_DISABLE from common.db.utils import close_old_connections from common.utils import timeit, get_logger @@ -46,7 +47,7 @@ LDAP_USE_CACHE_FLAGS = [1, '1', 'true', 'True', True] class LDAPConfig(object): - def __init__(self, config=None): + def __init__(self, config=None, category='ldap'): self.server_uri = None self.bind_dn = None self.password = None @@ -55,6 +56,7 @@ class LDAPConfig(object): self.search_filter = None self.attr_map = None self.auth_ldap = None + self.category = category if isinstance(config, dict): self.load_from_config(config) else: @@ -71,25 +73,26 @@ class LDAPConfig(object): self.auth_ldap = config.get('auth_ldap') def load_from_settings(self): - self.server_uri = settings.AUTH_LDAP_SERVER_URI - self.bind_dn = settings.AUTH_LDAP_BIND_DN - self.password = settings.AUTH_LDAP_BIND_PASSWORD - self.use_ssl = settings.AUTH_LDAP_START_TLS - self.search_ou = settings.AUTH_LDAP_SEARCH_OU - self.search_filter = settings.AUTH_LDAP_SEARCH_FILTER - self.attr_map = settings.AUTH_LDAP_USER_ATTR_MAP - self.auth_ldap = settings.AUTH_LDAP + prefix = 'AUTH_LDAP' if self.category == 'ldap' else 'AUTH_LDAP_HA' + self.server_uri = getattr(settings, f"{prefix}_SERVER_URI") + self.bind_dn = getattr(settings, f"{prefix}_BIND_DN") + self.password = getattr(settings, f"{prefix}_BIND_PASSWORD") + self.use_ssl = getattr(settings, f"{prefix}_START_TLS") + self.search_ou = getattr(settings, f"{prefix})_SEARCH_OU") + self.search_filter = getattr(settings, f"{prefix}_SEARCH_FILTER") + self.attr_map = getattr(settings, f"{prefix}_USER_ATTR_MAP") + self.auth_ldap = getattr(settings, prefix) class LDAPServerUtil(object): - def __init__(self, config=None): + def __init__(self, config=None, category='ldap'): if isinstance(config, dict): self.config = LDAPConfig(config=config) elif isinstance(config, LDAPConfig): self.config = config else: - self.config = LDAPConfig() + self.config = LDAPConfig(category=category) self._conn = None self._paged_size = self.get_paged_size() self.search_users = None @@ -230,25 +233,29 @@ class LDAPServerUtil(object): class LDAPCacheUtil(object): - CACHE_KEY_USERS = 'CACHE_KEY_LDAP_USERS' - def __init__(self): + def __init__(self, category='ldap'): self.search_users = None self.search_value = None + self.category = category + if self.category == 'ldap': + self.cache_key_users = 'CACHE_KEY_LDAP_USERS' + else: + self.cache_key_users = 'CACHE_KEY_LDAP_HA_USERS' def set_users(self, users): logger.info('Set ldap users to cache, count: {}'.format(len(users))) - cache.set(self.CACHE_KEY_USERS, users, None) + cache.set(self.cache_key_users, users, None) def get_users(self): - users = cache.get(self.CACHE_KEY_USERS) + users = cache.get(self.cache_key_users) count = users if users is None else len(users) logger.info('Get ldap users from cache, count: {}'.format(count)) return users def delete_users(self): logger.info('Delete ldap users from cache') - cache.delete(self.CACHE_KEY_USERS) + cache.delete(self.cache_key_users) def filter_users(self, users): if users is None: @@ -288,10 +295,11 @@ class LDAPSyncUtil(object): TASK_STATUS_IS_RUNNING = 'RUNNING' TASK_STATUS_IS_OVER = 'OVER' - def __init__(self): - self.server_util = LDAPServerUtil() - self.cache_util = LDAPCacheUtil() + def __init__(self, category='ldap'): + self.server_util = LDAPServerUtil(category=category) + self.cache_util = LDAPCacheUtil(category=category) self.task_error_msg = None + self.category = category def clear_cache(self): logger.info('Clear ldap sync cache') @@ -347,7 +355,7 @@ class LDAPSyncUtil(object): def perform_sync(self): logger.info('Start perform sync ldap users from server to cache') try: - ok, msg = LDAPTestUtil().test_config() + ok, msg = LDAPTestUtil(category=self.category).test_config() if not ok: raise self.LDAPSyncUtilException(msg) self.sync() @@ -377,6 +385,7 @@ class LDAPImportUtil(object): user['email'] = self.get_user_email(user) if user['username'] not in ['admin']: user['source'] = User.Source.ldap.value + user.pop('status', None) obj, created = User.objects.update_or_create( username=user['username'], defaults=user ) @@ -476,9 +485,13 @@ class LDAPTestUtil(object): class LDAPBeforeLoginCheckError(LDAPExceptionError): pass - def __init__(self, config=None): - self.config = LDAPConfig(config) + def __init__(self, config=None, category='ldap'): + self.config = LDAPConfig(config, category) self.user_entries = [] + if category == 'ldap': + self.backend = LDAPAuthorizationBackend() + else: + self.backend = LDAPHAAuthorizationBackend() def _test_connection_bind(self, authentication=None, user=None, password=None): server = Server(self.config.server_uri) @@ -656,15 +669,12 @@ class LDAPTestUtil(object): if not cache.get(CACHE_KEY_LDAP_TEST_CONFIG_TASK_STATUS): self.test_config() - backend = LDAPAuthorizationBackend() - ok, msg = backend.pre_check(username, password) + ok, msg = self.backend.pre_check(username, password) if not ok: raise self.LDAPBeforeLoginCheckError(msg) - @staticmethod - def _test_login_auth(username, password): - backend = LDAPAuthorizationBackend() - ldap_user = LDAPUser(backend, username=username.strip()) + def _test_login_auth(self, username, password): + ldap_user = LDAPUser(self.backend, username=username.strip()) ldap_user._authenticate_user_dn(password) def _test_login(self, username, password): diff --git a/apps/settings/ws.py b/apps/settings/ws.py index e5762e1f5..586a7d4e4 100644 --- a/apps/settings/ws.py +++ b/apps/settings/ws.py @@ -3,16 +3,17 @@ import json import asyncio -from asgiref.sync import sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.core.cache import cache from django.conf import settings from django.utils.translation import gettext_lazy as _, activate from django.utils import translation +from urllib.parse import parse_qs from common.db.utils import close_old_connections from common.utils import get_logger from settings.serializers import ( + LDAPHATestConfigSerializer, LDAPTestConfigSerializer, LDAPTestLoginSerializer ) @@ -101,8 +102,12 @@ class ToolsWebsocket(AsyncJsonWebsocketConsumer): class LdapWebsocket(AsyncJsonWebsocketConsumer): + category: str + async def connect(self): user = self.scope["user"] + query = parse_qs(self.scope['query_string'].decode()) + self.category = query.get('category', ['ldap'])[0] if user.is_authenticated: await self.accept() else: @@ -125,30 +130,21 @@ class LdapWebsocket(AsyncJsonWebsocketConsumer): await self.close() close_old_connections() - @staticmethod - def get_ldap_config(serializer): - server_uri = serializer.validated_data["AUTH_LDAP_SERVER_URI"] - bind_dn = serializer.validated_data["AUTH_LDAP_BIND_DN"] - password = serializer.validated_data["AUTH_LDAP_BIND_PASSWORD"] - use_ssl = serializer.validated_data.get("AUTH_LDAP_START_TLS", False) - search_ou = serializer.validated_data["AUTH_LDAP_SEARCH_OU"] - search_filter = serializer.validated_data["AUTH_LDAP_SEARCH_FILTER"] - attr_map = serializer.validated_data["AUTH_LDAP_USER_ATTR_MAP"] - auth_ldap = serializer.validated_data.get('AUTH_LDAP', False) - - if not password: - password = settings.AUTH_LDAP_BIND_PASSWORD + def get_ldap_config(self, serializer): + prefix = 'AUTH_LDAP_' if self.category == 'ldap' else 'AUTH_LDAP_HA_' config = { - 'server_uri': server_uri, - 'bind_dn': bind_dn, - 'password': password, - 'use_ssl': use_ssl, - 'search_ou': search_ou, - 'search_filter': search_filter, - 'attr_map': attr_map, - 'auth_ldap': auth_ldap + 'server_uri': serializer.validated_data.get(f"{prefix}SERVER_URI"), + 'bind_dn': serializer.validated_data.get(f"{prefix}BIND_DN"), + 'password': (serializer.validated_data.get(f"{prefix}BIND_PASSWORD") or + getattr(settings, f"{prefix}BIND_PASSWORD")), + 'use_ssl': serializer.validated_data.get(f"{prefix}START_TLS", False), + 'search_ou': serializer.validated_data.get(f"{prefix}SEARCH_OU"), + 'search_filter': serializer.validated_data.get(f"{prefix}SEARCH_FILTER"), + 'attr_map': serializer.validated_data.get(f"{prefix}USER_ATTR_MAP"), + 'auth_ldap': serializer.validated_data.get(f"{prefix.rstrip('_')}", False) } + return config @staticmethod @@ -160,7 +156,10 @@ class LdapWebsocket(AsyncJsonWebsocketConsumer): cache.set(task_key, TASK_STATUS_IS_OVER, ttl) def run_testing_config(self, data): - serializer = LDAPTestConfigSerializer(data=data) + if self.category == 'ldap': + serializer = LDAPTestConfigSerializer(data=data) + else: + serializer = LDAPHATestConfigSerializer(data=data) if not serializer.is_valid(): self.send_msg(msg=f'error: {str(serializer.errors)}') config = self.get_ldap_config(serializer) @@ -175,14 +174,13 @@ class LdapWebsocket(AsyncJsonWebsocketConsumer): self.send_msg(msg=f'error: {str(serializer.errors)}') username = serializer.validated_data['username'] password = serializer.validated_data['password'] - ok, msg = LDAPTestUtil().test_login(username, password) + ok, msg = LDAPTestUtil(category=self.category).test_login(username, password) return ok, msg - @staticmethod - def run_sync_user(data): - sync_util = LDAPSyncUtil() + def run_sync_user(self, data): + sync_util = LDAPSyncUtil(category=self.category) sync_util.clear_cache() - sync_ldap_user() + sync_ldap_user(category=self.category) msg = sync_util.get_task_error_msg() ok = False if msg else True return ok, msg @@ -215,7 +213,7 @@ class LdapWebsocket(AsyncJsonWebsocketConsumer): return ok, msg def set_users_status(self, import_users, errors): - util = LDAPCacheUtil() + util = LDAPCacheUtil(category=self.category) all_users = util.get_users() import_usernames = [u['username'] for u in import_users] errors_mapper = {k: v for err in errors for k, v in err.items()} @@ -225,7 +223,7 @@ class LdapWebsocket(AsyncJsonWebsocketConsumer): user['status'] = {'error': errors_mapper[username]} elif username in import_usernames: user['status'] = ImportStatus.ok - LDAPCacheUtil().set_users(all_users) + LDAPCacheUtil(category=self.category).set_users(all_users) @staticmethod def get_orgs(org_ids): @@ -235,12 +233,11 @@ class LdapWebsocket(AsyncJsonWebsocketConsumer): orgs = [current_org] return orgs - @staticmethod - def get_ldap_users(username_list, cache_police): + def get_ldap_users(self, username_list, cache_police): if '*' in username_list: - users = LDAPServerUtil().search() + users = LDAPServerUtil(category=self.category).search() elif cache_police in LDAP_USE_CACHE_FLAGS: - users = LDAPCacheUtil().search(search_users=username_list) + users = LDAPCacheUtil(category=self.category).search(search_users=username_list) else: - users = LDAPServerUtil().search(search_users=username_list) + users = LDAPServerUtil(category=self.category).search(search_users=username_list) return users diff --git a/apps/users/models/user/_source.py b/apps/users/models/user/_source.py index 2840caa8a..2c7237c80 100644 --- a/apps/users/models/user/_source.py +++ b/apps/users/models/user/_source.py @@ -10,6 +10,7 @@ from django.utils.translation import gettext_lazy as _ class Source(models.TextChoices): local = "local", _("Local") ldap = "ldap", "LDAP/AD" + ldap_ha = "ldap_ha", "LDAP/AD (HA)" openid = "openid", "OpenID" radius = "radius", "Radius" cas = "cas", "CAS" @@ -55,6 +56,7 @@ class SourceMixin: mapper = { cls.Source.local: True, cls.Source.ldap: settings.AUTH_LDAP, + cls.Source.ldap_ha: settings.AUTH_LDAP_HA, cls.Source.openid: settings.AUTH_OPENID, cls.Source.radius: settings.AUTH_RADIUS, cls.Source.cas: settings.AUTH_CAS,