diff --git a/src/bk-user/bkuser/apps/data_source/handlers.py b/src/bk-user/bkuser/apps/data_source/handlers.py new file mode 100644 index 000000000..2e7a89818 --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/handlers.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from django.dispatch import receiver + +from bkuser.apps.data_source.models import DataSource +from bkuser.apps.data_source.signals import post_update_data_source + + +@receiver(post_update_data_source) +def initial_local_data_source_user_identity_info(sender, data_source: DataSource, **kwargs): + """ + TODO (su) 数据源更新后,需要检查是否是本地数据源,若是本地数据源且启用账密登录, + 则需要对没有账密信息的用户,进行密码的初始化 & 发送通知 + """ + pass diff --git a/src/bk-user/bkuser/apps/data_source/plugins/local/constants.py b/src/bk-user/bkuser/apps/data_source/plugins/local/constants.py index 380e7ea22..8fcf3a834 100644 --- a/src/bk-user/bkuser/apps/data_source/plugins/local/constants.py +++ b/src/bk-user/bkuser/apps/data_source/plugins/local/constants.py @@ -27,8 +27,7 @@ # 可选最长锁定时间:10年 MAX_LOCK_TIME = 10 * 365 * ONE_DAY_SECONDS -# 连续性限制上下限 -MIN_NOT_CONTINUOUS_COUNT = 5 +# 连续性限制上限 MAX_NOT_CONTINUOUS_COUNT = 10 # 重试密码次数上限 @@ -50,3 +49,12 @@ class NotificationMethod(str, StructuredEnum): EMAIL = EnumField("email", label=_("邮件通知")) SMS = EnumField("sms", label=_("短信通知")) + + +class NotificationScene(str, StructuredEnum): + """通知场景""" + + USER_INITIALIZE = EnumField("user_initialize", label=_("用户初始化")) + RESET_PASSWORD = EnumField("reset_password", label=_("重置密码")) + PASSWORD_EXPIRING = EnumField("password_expiring", label=_("密码即将过期")) + PASSWORD_EXPIRED = EnumField("password_expired", label=_("密码过期")) diff --git a/src/bk-user/bkuser/apps/data_source/plugins/local/models.py b/src/bk-user/bkuser/apps/data_source/plugins/local/models.py index afd1b6077..dba53c767 100644 --- a/src/bk-user/bkuser/apps/data_source/plugins/local/models.py +++ b/src/bk-user/bkuser/apps/data_source/plugins/local/models.py @@ -19,13 +19,13 @@ MAX_PASSWORD_LENGTH, MAX_PASSWORD_VALID_TIME, MAX_RESERVED_PREVIOUS_PASSWORD_COUNT, - MIN_NOT_CONTINUOUS_COUNT, NEVER_EXPIRE_TIME, PASSWORD_MAX_RETRIES, NotificationMethod, + NotificationScene, PasswordGenerateMethod, ) -from bkuser.common.passwd import PasswordRule, PasswordValidator +from bkuser.common.passwd import PasswordGenerateError, PasswordGenerator, PasswordRule, PasswordValidator from bkuser.utils.pydantic import stringify_pydantic_error @@ -47,7 +47,7 @@ class PasswordRuleConfig(BaseModel): # --- 连续性限制类 --- # 不允许连续出现位数 - not_continuous_count: int = Field(ge=MIN_NOT_CONTINUOUS_COUNT, le=MAX_NOT_CONTINUOUS_COUNT) + not_continuous_count: int = Field(default=0, ge=0, le=MAX_NOT_CONTINUOUS_COUNT) # 不允许键盘序 not_keyboard_order: bool # 不允许连续字母序 @@ -84,12 +84,40 @@ def to_rule(self) -> PasswordRule: ) +class NotificationTemplate(BaseModel): + """通知模板""" + + # 通知方式 如短信,邮件 + method: NotificationMethod + # 通知场景 如将过期,已过期 + scene: NotificationScene + # 模板标题 + title: Optional[str] = None + # 模板发送方 + sender: str + # 模板内容(text)格式 + content: str + # 模板内容(html)格式 + content_html: Optional[str] = None + + @model_validator(mode="after") + def validate_attrs(self) -> "NotificationTemplate": + if self.method == NotificationMethod.EMAIL: + if not self.title: + raise ValueError(_("邮件通知模板需要提供标题")) + + if not self.content_html: + raise ValueError(_("邮件通知模板需要提供 HTML 格式内容")) + + return self + + class NotificationConfig(BaseModel): """通知相关配置""" - methods: List[NotificationMethod] + enabled_methods: List[NotificationMethod] # 通知模板 - template: str + templates: List[NotificationTemplate] class PasswordInitialConfig(BaseModel): @@ -122,17 +150,25 @@ class LocalDataSourcePluginConfig(BaseModel): """本地数据源插件配置""" # 是否允许使用账密登录 - enable_login_by_password: bool + enable_account_password_login: bool # 密码生成规则 - password_rule: PasswordRuleConfig + password_rule: Optional[PasswordRuleConfig] = None # 密码初始化/修改规则 - password_initial: PasswordInitialConfig + password_initial: Optional[PasswordInitialConfig] = None # 密码到期规则 - password_expire: PasswordExpireConfig + password_expire: Optional[PasswordExpireConfig] = None @model_validator(mode="after") def validate_attrs(self) -> "LocalDataSourcePluginConfig": """插件配置合法性检查""" + # 如果没有开启账密登录,则不需要检查配置 + if not self.enable_account_password_login: + return self + + # 若启用账密登录,则各字段都需要配置上 + if not (self.password_rule and self.password_initial and self.password_expire): + raise ValueError(_("密码生成规则、初始密码设置、密码到期设置均不能为空")) + try: rule = self.password_rule.to_rule() except ValidationError as e: @@ -146,6 +182,12 @@ def validate_attrs(self) -> "LocalDataSourcePluginConfig": # 若配置固定密码,则需要检查是否符合定义的密码强度规则 ret = PasswordValidator(rule).validate(self.password_initial.fixed_password) if not ret.ok: - raise ValueError("固定密码的值不符合密码规则:{}".format(ret.exception_message)) + raise ValueError(_("固定密码的值不符合密码规则:{}").format(ret.exception_message)) + else: + # 随机生成密码的,校验下能否在有限次数内成功生成 + try: + PasswordGenerator(rule).generate() + except PasswordGenerateError: + raise ValueError(_("无法根据预设规则生成符合条件的密码,请调整规则")) return self diff --git a/src/bk-user/tests/apis/web/data_source/test_data_source.py b/src/bk-user/tests/apis/web/data_source/test_data_source.py index e3abe6eab..8cce58e12 100644 --- a/src/bk-user/tests/apis/web/data_source/test_data_source.py +++ b/src/bk-user/tests/apis/web/data_source/test_data_source.py @@ -13,7 +13,11 @@ import pytest from bkuser.apps.data_source.constants import DataSourcePluginEnum, DataSourceStatus from bkuser.apps.data_source.models import DataSource, DataSourcePlugin -from bkuser.apps.data_source.plugins.local.constants import NotificationMethod, PasswordGenerateMethod +from bkuser.apps.data_source.plugins.local.constants import ( + NotificationMethod, + NotificationScene, + PasswordGenerateMethod, +) from django.urls import reverse from rest_framework import status @@ -26,7 +30,7 @@ @pytest.fixture() def local_ds_plugin_config() -> Dict[str, Any]: return { - "enable_login_by_password": True, + "enable_account_password_login": True, "password_rule": { "min_length": 12, "contain_lowercase": True, @@ -49,15 +53,73 @@ def local_ds_plugin_config() -> Dict[str, Any]: "generate_method": PasswordGenerateMethod.RANDOM, "fixed_password": None, "notification": { - "methods": [NotificationMethod.EMAIL, NotificationMethod.SMS], - "template": "你的密码是 xxx", + "enabled_methods": [NotificationMethod.EMAIL, NotificationMethod.SMS], + "templates": [ + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.USER_INITIALIZE, + "title": "您的账户已经成功创建", + "sender": "蓝鲸智云", + "content": "您的账户已经成功创建,请尽快修改密码", + "content_html": "
您的账户已经成功创建,请尽快修改密码
", + }, + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.RESET_PASSWORD, + "title": "登录密码重置", + "sender": "蓝鲸智云", + "content": "点击以下链接以重置代码", + "content_html": "点击以下链接以重置代码
", + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.USER_INITIALIZE, + "sender": "蓝鲸智云", + "content": "您的账户已经成功创建,请尽快修改密码", + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.RESET_PASSWORD, + "sender": "蓝鲸智云", + "content": "点击以下链接以重置代码", + }, + ], }, }, "password_expire": { "remind_before_expire": [3600, 7200], "notification": { - "methods": [NotificationMethod.EMAIL, NotificationMethod.SMS], - "template": "密码即将过期,请尽快修改", + "enabled_methods": [NotificationMethod.EMAIL, NotificationMethod.SMS], + "templates": [ + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.PASSWORD_EXPIRING, + "title": "【蓝鲸智云】密码即将到期提醒!", + "sender": "蓝鲸智云", + "content": "您的密码即将到期!", + "content_html": "您的密码即将到期!
", + }, + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.PASSWORD_EXPIRED, + "title": "【蓝鲸智云】密码到期提醒!", + "sender": "蓝鲸智云", + "content": "点击以下链接以重置代码", + "content_html": "您的密码已到期!
", + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.PASSWORD_EXPIRING, + "sender": "蓝鲸智云", + "content": "您的密码即将到期!", + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.PASSWORD_EXPIRED, + "sender": "蓝鲸智云", + "content": "您的密码已到期!", + }, + ], }, }, } @@ -104,6 +166,17 @@ def test_create_local_data_source(self, api_client, local_ds_plugin_config): ) assert resp.status_code == status.HTTP_201_CREATED + def test_create_with_minimal_plugin_config(self, api_client): + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": DataSourcePluginEnum.LOCAL, + "plugin_config": {"enable_account_password_login": False}, + }, + ) + assert resp.status_code == status.HTTP_201_CREATED + def test_create_with_not_exist_plugin(self, api_client): resp = api_client.post( reverse("data_source.list_create"), @@ -124,8 +197,34 @@ def test_create_without_plugin_config(self, api_client): assert resp.status_code == status.HTTP_400_BAD_REQUEST assert "plugin_config: 该字段是必填项。" in resp.data["message"] + def test_create_with_broken_plugin_config(self, api_client, local_ds_plugin_config): + local_ds_plugin_config["password_initial"] = None + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": DataSourcePluginEnum.LOCAL, + "plugin_config": local_ds_plugin_config, + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "密码生成规则、初始密码设置、密码到期设置均不能为空" in resp.data["message"] + + def test_create_with_invalid_notification_template(self, api_client, local_ds_plugin_config): + local_ds_plugin_config["password_expire"]["notification"]["templates"][0]["title"] = None + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": DataSourcePluginEnum.LOCAL, + "plugin_config": local_ds_plugin_config, + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "邮件通知模板需要提供标题" in resp.data["message"] + def test_create_with_invalid_plugin_config(self, api_client, local_ds_plugin_config): - local_ds_plugin_config.pop("enable_login_by_password") + local_ds_plugin_config.pop("enable_account_password_login") resp = api_client.post( reverse("data_source.list_create"), data={ @@ -135,7 +234,7 @@ def test_create_with_invalid_plugin_config(self, api_client, local_ds_plugin_con }, ) assert resp.status_code == status.HTTP_400_BAD_REQUEST - assert "插件配置不合法:enable_login_by_password: Field required" in resp.data["message"] + assert "插件配置不合法:enable_account_password_login: Field required" in resp.data["message"] def test_create_without_required_field_mapping(self, api_client): """非本地数据源,需要字段映射配置""" @@ -167,7 +266,7 @@ def test_list_other_tenant_data_source(self, api_client, random_tenant, data_sou class TestDataSourceUpdateApi: def test_update_local_data_source(self, api_client, data_source, local_ds_plugin_config): - local_ds_plugin_config["enable_login_by_password"] = False + local_ds_plugin_config["enable_account_password_login"] = False resp = api_client.put( reverse("data_source.retrieve_update", kwargs={"id": data_source.id}), data={"plugin_config": local_ds_plugin_config}, @@ -175,16 +274,16 @@ def test_update_local_data_source(self, api_client, data_source, local_ds_plugin assert resp.status_code == status.HTTP_204_NO_CONTENT resp = api_client.get(reverse("data_source.retrieve_update", kwargs={"id": data_source.id})) - assert resp.data["plugin_config"]["enable_login_by_password"] is False + assert resp.data["plugin_config"]["enable_account_password_login"] is False def test_update_with_invalid_plugin_config(self, api_client, data_source, local_ds_plugin_config): - local_ds_plugin_config.pop("enable_login_by_password") + local_ds_plugin_config.pop("enable_account_password_login") resp = api_client.put( reverse("data_source.retrieve_update", kwargs={"id": data_source.id}), data={"plugin_config": local_ds_plugin_config}, ) assert resp.status_code == status.HTTP_400_BAD_REQUEST - assert "插件配置不合法:enable_login_by_password: Field required" in resp.data["message"] + assert "插件配置不合法:enable_account_password_login: Field required" in resp.data["message"] def test_update_without_required_field_mapping(self, api_client): """非本地数据源,需要字段映射配置"""