diff --git a/src/api/bkuser_core/api/web/category/views.py b/src/api/bkuser_core/api/web/category/views.py index 0750866ed..cd1c2f2e2 100644 --- a/src/api/bkuser_core/api/web/category/views.py +++ b/src/api/bkuser_core/api/web/category/views.py @@ -116,7 +116,6 @@ def get_queryset(self): category = get_category(category_id) namespace = self.kwargs["namespace"] metas = list_setting_metas(category.type, None, namespace) - return Setting.objects.filter(meta__in=metas, category_id=category_id) def post(self, request, *args, **kwargs): diff --git a/src/api/bkuser_core/api/web/constants.py b/src/api/bkuser_core/api/web/constants.py new file mode 100644 index 000000000..38ff6fbe8 --- /dev/null +++ b/src/api/bkuser_core/api/web/constants.py @@ -0,0 +1,12 @@ +# -*- 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. +""" + +EXCLUDE_SETTINGS_META_KEYS = ["password_rsa_private_key"] diff --git a/src/api/bkuser_core/api/web/password/serializers.py b/src/api/bkuser_core/api/web/password/serializers.py index 377565602..cb1549d34 100644 --- a/src/api/bkuser_core/api/web/password/serializers.py +++ b/src/api/bkuser_core/api/web/password/serializers.py @@ -12,6 +12,7 @@ from rest_framework import serializers from bkuser_core.api.web.serializers import Base64OrPlainField +from bkuser_core.api.web.utils import get_raw_password, get_token_handler class PasswordResetSendEmailInputSLZ(serializers.Serializer): @@ -22,7 +23,18 @@ class PasswordResetByTokenInputSLZ(serializers.Serializer): token = serializers.CharField(required=True, max_length=254) password = Base64OrPlainField(required=True, max_length=254) + def validate(self, attrs): + token_holder = get_token_handler(token=attrs["token"]) + profile = token_holder.profile + # 对于密码输入可能是明文也可能是密文,根据配置自动判断解析出明文(密文只是与前端加密传递,与后续逻辑无关) + attrs["password"] = get_raw_password(profile.category_id, attrs["password"]) + return attrs + class PasswordModifyInputSLZ(serializers.Serializer): old_password = Base64OrPlainField(required=True, max_length=254) new_password = Base64OrPlainField(required=True, max_length=254) + + +class PasswordListSettingsByTokenInputSLZ(serializers.Serializer): + token = serializers.CharField(required=True, max_length=254) diff --git a/src/api/bkuser_core/api/web/password/urls.py b/src/api/bkuser_core/api/web/password/urls.py index 92538a731..013e98ace 100644 --- a/src/api/bkuser_core/api/web/password/urls.py +++ b/src/api/bkuser_core/api/web/password/urls.py @@ -29,4 +29,9 @@ views.PasswordModifyApi.as_view(), name="password.modify", ), + path( + "settings/by_token/", + views.PasswordListSettingsByTokenApi.as_view(), + name="password.get_settings.by_token", + ), ] diff --git a/src/api/bkuser_core/api/web/password/views.py b/src/api/bkuser_core/api/web/password/views.py index 6fde3084d..473ede519 100644 --- a/src/api/bkuser_core/api/web/password/views.py +++ b/src/api/bkuser_core/api/web/password/views.py @@ -16,8 +16,20 @@ from rest_framework import generics, status from rest_framework.response import Response -from .serializers import PasswordModifyInputSLZ, PasswordResetByTokenInputSLZ, PasswordResetSendEmailInputSLZ -from bkuser_core.api.web.utils import get_operator, validate_password +from bkuser_core.api.web.category.serializers import CategorySettingOutputSLZ +from bkuser_core.api.web.password.serializers import ( + PasswordListSettingsByTokenInputSLZ, + PasswordModifyInputSLZ, + PasswordResetByTokenInputSLZ, + PasswordResetSendEmailInputSLZ, +) +from bkuser_core.api.web.utils import ( + get_category, + get_operator, + get_token_handler, + list_setting_metas, + validate_password, +) from bkuser_core.audit.constants import OperationType from bkuser_core.audit.utils import create_general_log from bkuser_core.categories.models import ProfileCategory @@ -27,6 +39,8 @@ from bkuser_core.profiles.signals import post_profile_update from bkuser_core.profiles.tasks import send_password_by_email from bkuser_core.profiles.utils import parse_username_domain +from bkuser_core.user_settings.constants import SettingsEnableNamespaces +from bkuser_core.user_settings.models import Setting logger = logging.getLogger(__name__) @@ -72,16 +86,9 @@ def post(self, request, *args, **kwargs): token = data["token"] pending_password = data["password"] - try: - token_holder = ProfileTokenHolder.objects.get(token=token, enabled=True) - except ProfileTokenHolder.DoesNotExist: - logger.info("token<%s> not exist in db", token) - raise error_codes.CANNOT_GET_TOKEN_HOLDER - - if token_holder.expired: - raise error_codes.PROFILE_TOKEN_EXPIRED - + token_holder = get_token_handler(token) profile = token_holder.profile + validate_password(profile, pending_password) profile.password = make_password(pending_password) profile.password_update_time = now() @@ -143,3 +150,24 @@ def post(self, request, *args, **kwargs): extra_values=modify_summary, ) return Response(status=status.HTTP_200_OK) + + +class PasswordListSettingsByTokenApi(generics.ListAPIView): + serializer_class = CategorySettingOutputSLZ + + def get(self, request, *args, **kwargs): + slz = PasswordListSettingsByTokenInputSLZ(data=request.query_params) + slz.is_valid(raise_exception=True) + + data = slz.validated_data + token = data["token"] + + token_holder = get_token_handler(token) + profile = token_holder.profile + + category = get_category(profile.category_id) + namespace = SettingsEnableNamespaces.PASSWORD.value + metas = list_setting_metas(category.type, None, namespace) + settings = Setting.objects.filter(meta__in=metas, category_id=profile.category_id) + + return Response(self.serializer_class(settings, many=True).data) diff --git a/src/api/bkuser_core/api/web/profile/serializers.py b/src/api/bkuser_core/api/web/profile/serializers.py index 08cbde9ef..7c75feae5 100644 --- a/src/api/bkuser_core/api/web/profile/serializers.py +++ b/src/api/bkuser_core/api/web/profile/serializers.py @@ -13,7 +13,7 @@ from rest_framework import serializers from bkuser_core.api.web.serializers import StringArrayField -from bkuser_core.api.web.utils import get_default_category_id +from bkuser_core.api.web.utils import get_default_category_id, get_raw_password from bkuser_core.profiles.models import Profile from bkuser_core.profiles.validators import validate_username @@ -105,12 +105,16 @@ class ProfileSearchOutputSLZ(serializers.Serializer): class ProfileUpdateInputSLZ(serializers.ModelSerializer): leader = serializers.ListField(child=serializers.IntegerField(), required=False) departments = serializers.ListField(child=serializers.IntegerField(), required=False) + password = serializers.CharField(required=False, write_only=True) class Meta: model = Profile # NOTE: 相对原来的api区别, 不支持extras/create_time/update_time更新 exclude = ["category_id", "username", "domain", "extras", "create_time", "update_time"] + def validate_password(self, password): + return get_raw_password(self.instance.category_id, password) + class ProfileCreateInputSLZ(serializers.ModelSerializer): category_id = serializers.IntegerField(required=False) diff --git a/src/api/bkuser_core/api/web/utils.py b/src/api/bkuser_core/api/web/utils.py index 6a3ca18c2..4829343d8 100644 --- a/src/api/bkuser_core/api/web/utils.py +++ b/src/api/bkuser_core/api/web/utils.py @@ -8,21 +8,24 @@ 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. """ +import base64 import logging from typing import Dict, Union from django.conf import settings +from bkuser_core.api.web.constants import EXCLUDE_SETTINGS_META_KEYS from bkuser_core.categories.models import ProfileCategory from bkuser_core.common.error_codes import error_codes from bkuser_core.departments.models import Department from bkuser_core.profiles.cache import get_extras_default_from_local_cache -from bkuser_core.profiles.models import DynamicFieldInfo, Profile +from bkuser_core.profiles.models import DynamicFieldInfo, Profile, ProfileTokenHolder from bkuser_core.profiles.password import PasswordValidator from bkuser_core.profiles.utils import check_former_passwords from bkuser_core.user_settings.exceptions import SettingHasBeenDisabledError from bkuser_core.user_settings.loader import ConfigProvider from bkuser_core.user_settings.models import SettingMeta +from bkuser_global.crypt import rsa_decrypt_password logger = logging.getLogger(__name__) @@ -82,7 +85,7 @@ def list_setting_metas(category_type: str, region: str, namespace: str) -> list: """ List setting metas. """ - queryset = SettingMeta.objects.filter(category_type=category_type) + queryset = SettingMeta.objects.filter(category_type=category_type).exclude(key__in=EXCLUDE_SETTINGS_META_KEYS) if region: queryset = queryset.filter(region=region) if namespace: @@ -152,3 +155,26 @@ def get_extras_with_default_values(extras_from_db: Union[dict, list]) -> dict: extras.update(formatted_extras) return extras + + +def get_raw_password(category_id: int, encrypted_password: str) -> str: + config_loader = ConfigProvider(category_id=category_id) + enable_rsa_encrypted = config_loader.get("enable_password_rsa_encrypted") + # 未开启,或者未配置rsa + if not enable_rsa_encrypted: + return encrypted_password + rsa_private_key = base64.b64decode(config_loader["password_rsa_private_key"]).decode() + return rsa_decrypt_password(encrypted_password, rsa_private_key) + + +def get_token_handler(token: str) -> ProfileTokenHolder: + try: + token_holder = ProfileTokenHolder.objects.get(token=token, enabled=True) + except ProfileTokenHolder.DoesNotExist: + logger.info("token<%s> not exist in db", token) + raise error_codes.CANNOT_GET_TOKEN_HOLDER + + if token_holder.expired: + raise error_codes.PROFILE_TOKEN_EXPIRED + + return token_holder diff --git a/src/api/bkuser_core/categories/management/commands/enable_pwd_rsa_encrypt.py b/src/api/bkuser_core/categories/management/commands/enable_pwd_rsa_encrypt.py new file mode 100644 index 000000000..1c9fd4018 --- /dev/null +++ b/src/api/bkuser_core/categories/management/commands/enable_pwd_rsa_encrypt.py @@ -0,0 +1,140 @@ +# -*- 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. +""" +import base64 +import logging +import traceback + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric import rsa as crypto_rsa +from django.core.management.base import BaseCommand +from django.db import transaction + +from bkuser_core.categories.constants import CategoryType +from bkuser_core.categories.models import ProfileCategory +from bkuser_core.user_settings.models import Setting, SettingMeta + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "enable category rsa" + + def add_arguments(self, parser): + parser.add_argument("--category_id", type=str, help="目录ID", required=True) + parser.add_argument("--random_flag", type=bool, default=True, help="是否随机生成") + parser.add_argument("--key_length", type=int, default=1024, help="随机密钥对的长度") + parser.add_argument("--private_key_file", type=str, default="", help="rsa私钥pem文件目录") + parser.add_argument("--public_key_file", type=str, default="", help="rsa公钥pem文件目录") + + def validate_rsa_secret(self, private_key_content: bytes, public_key_content: bytes) -> bool: + testing_msg = "Hello World !!!" + + private_key = serialization.load_pem_private_key(private_key_content, password=None, backend=default_backend()) + public_key = serialization.load_pem_public_key(public_key_content, backend=default_backend()) + + common_condition = padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None + ) + + # 先公钥加密 + encrypt_msg = public_key.encrypt(testing_msg.encode(), common_condition) + + # 解密 + decrypt_msg = private_key.decrypt(encrypt_msg, common_condition) + result_msg = decrypt_msg.decode() + + if result_msg != testing_msg: + return False + return True + + def create_rsa_secret(self, options: dict): + random_flag = options.get("random_flag") + if not random_flag: + # read the private_key and public key from the file + private_key_file = options.get("private_key_file") + public_key_file = options.get("public_key_file") + with open(private_key_file, "rb") as private_file: + private_key = private_file.read() + + with open(public_key_file, "rb") as public_file: + public_key = public_file.read() + + if not self.validate_rsa_secret(private_key, private_key): + self.stdout.write("These pem files do not matching") + raise Exception + else: + self.stdout.write("Private key and public key are creating randomly") + key_length = options.get("key_length") + # 随机生成rsa 秘钥对 + private_key_origin = crypto_rsa.generate_private_key( + public_exponent=65537, key_size=key_length, backend=default_backend() + ) + public_key_origin = private_key_origin.public_key() + + private_key = private_key_origin.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + public_key = public_key_origin.public_bytes( + encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.PKCS1 + ) + + # base64加密入库 + public_key = base64.b64encode(public_key).decode() + private_key = base64.b64encode(private_key).decode() + + return public_key, private_key + + def handle(self, *args, **options): + category_id = options.get("category_id") + self.stdout.write(f"enable category rsa: category_id={str(category_id)}") + + try: + public_key, private_key = self.create_rsa_secret(options) + category = ProfileCategory.objects.get(id=category_id) + if category.type != CategoryType.LOCAL.value: + self.stdout.write("Rsa setting only support the local category, please check your input") + return + + rsa_settings_filters = { + "enable_password_rsa_encrypted": True, + "password_rsa_private_key": private_key, + "password_rsa_public_key": public_key, + } + + meta_combo = {} + for key, value in rsa_settings_filters.items(): + meta = SettingMeta.objects.get(key=key) + meta_combo[meta] = value + + # 新增或更新该目录的user_setting设置:rsa配置 + with transaction.atomic(): + rsa_settings = [] + for meta, value in meta_combo.items(): + instance, _ = Setting.objects.get_or_create(meta=meta, category_id=category.id) + instance.value = value + rsa_settings.append(instance) + Setting.objects.bulk_update(rsa_settings, ["value"]) + + self.stdout.write(f"Category {category_id} Enable rsa successfully") + + except ProfileCategory.DoesNotExist: + self.stdout.write(f"Category is not exist( category_id={category_id} ), please check your input.") + return + + except Exception as e: + self.stdout.write(traceback.format_exc()) + self.stdout.write(f"Enable rsa failed: {e}") + return diff --git a/src/api/bkuser_core/user_settings/migrations/0019_alter_local_password_rsa_config.py b/src/api/bkuser_core/user_settings/migrations/0019_alter_local_password_rsa_config.py new file mode 100644 index 000000000..b52cabcf1 --- /dev/null +++ b/src/api/bkuser_core/user_settings/migrations/0019_alter_local_password_rsa_config.py @@ -0,0 +1,55 @@ +# -*- 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 __future__ import unicode_literals + +from bkuser_core.categories.constants import CategoryType +from bkuser_core.user_settings.constants import SettingsEnableNamespaces +from django.db import migrations + + +def forwards_func(apps, schema_editor): + """更新默认目录 密码-rsa加密模板配置""" + SettingMeta = apps.get_model("user_settings", "SettingMeta") + + local_password_rsa_settings = [ + dict( + key="enable_password_rsa_encrypted", + default=False, + example=False + ), + dict( + key="password_rsa_private_key", + default="", + example="LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpkZnNkZmRzZgotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=" + ), + dict( + key="password_rsa_public_key", + default="", + example="LS0tLS1CRUdJTiBSU0EgUFVCTElDS0VZLS0tLS0KZXJ0ZXJ0cmV0Ci0tLS0tRU5EIFJTQSBQVUJMSUNLRVktLS0tLQo=" + ) + ] + + for x in local_password_rsa_settings: + + meta, _ = SettingMeta.objects.get_or_create( + namespace=SettingsEnableNamespaces.PASSWORD.value, + category_type=CategoryType.LOCAL.value, + required=False, + **x + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("user_settings", "0018_alter_local_password_mail_config"), + ] + + operations = [migrations.RunPython(forwards_func)] diff --git a/src/api/poetry.lock b/src/api/poetry.lock index 6d4fa9220..a8ad2ac3c 100644 --- a/src/api/poetry.lock +++ b/src/api/poetry.lock @@ -1888,6 +1888,11 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent-mirrors" +[package.source] +type = "legacy" +url = "https://mirrors.tencent.com/pypi/simple" +reference = "tencent-mirrors" + [[package]] name = "pydantic" version = "1.9.0" @@ -2196,6 +2201,22 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent-mirrors" +[[package]] +name = "rsa" +version = "3.4.2" +description = "Pure-Python RSA implementation" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[package.source] +type = "legacy" +url = "https://mirrors.tencent.com/pypi/simple" +reference = "tencent-mirrors" + [[package]] name = "ruamel.yaml" version = "0.17.21" @@ -2664,7 +2685,7 @@ reference = "tencent-mirrors" [metadata] lock-version = "1.1" python-versions = "3.6.14" -content-hash = "a5df43ed5650d6e3aa47d75a0ec94bea78d86ecf964646bb92734b3fa84308e9" +content-hash = "c86527e46c8c3188a858fa0e9160ec24c9fa1d3a31fbf6eae8a3e0c3eff6ccfb" [metadata.files] aenum = [ @@ -3642,6 +3663,10 @@ requests = [ {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, ] +rsa = [ + {file = "rsa-3.4.2-py2.py3-none-any.whl", hash = "sha256:43f682fea81c452c98d09fc316aae12de6d30c4b5c84226642cf8f8fd1c93abd"}, + {file = "rsa-3.4.2.tar.gz", hash = "sha256:25df4e10c263fb88b5ace923dd84bf9aa7f5019687b5e55382ffcdb8bede9db5"}, +] "ruamel.yaml" = [ {file = "ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7"}, {file = "ruamel.yaml-0.17.21.tar.gz", hash = "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af"}, diff --git a/src/api/pyproject.toml b/src/api/pyproject.toml index b48791d0b..2fa406f96 100644 --- a/src/api/pyproject.toml +++ b/src/api/pyproject.toml @@ -55,6 +55,8 @@ opentelemetry-instrumentation-requests = "0.26b1" opentelemetry-instrumentation-celery = "0.26b1" opentelemetry-instrumentation-logging = "0.26b1" opentelemetry-exporter-jaeger = "1.7.1" +rsa = "3.4.2" + [tool.poetry.dev-dependencies] ipython = "^7.15.0"