From 9c840bd2a9147c636c72395028717f9cc0032b89 Mon Sep 17 00:00:00 2001 From: nannan00 <17491932+nannan00@users.noreply.github.com> Date: Mon, 13 Nov 2023 01:16:48 -0600 Subject: [PATCH] feat(bklogin): call bk-user api, not access shared db (#1376) --- .github/workflows/bk-user.yml | 15 +- .pre-commit-config.yaml | 22 +- .../bklogin/authentication/api_views.py | 13 +- .../bklogin/authentication/constants.py | 2 +- .../bklogin/authentication/manager.py | 17 +- .../authentication/migrations/0001_initial.py | 7 +- src/bk-login/bklogin/authentication/models.py | 4 +- src/bk-login/bklogin/authentication/urls.py | 4 +- src/bk-login/bklogin/authentication/views.py | 197 ++++++++--------- src/bk-login/bklogin/bkuser/constants.py | 40 ---- src/bk-login/bklogin/bkuser/models.py | 156 -------------- src/bk-login/bklogin/common/error_codes.py | 3 + .../bklogin/{bkuser => component}/__init__.py | 0 .../apps.py => component/bk_user/__init__.py} | 6 - src/bk-login/bklogin/component/bk_user/api.py | 111 ++++++++++ .../bk_user/constants.py} | 20 +- .../bklogin/component/bk_user/models.py | 62 ++++++ src/bk-login/bklogin/component/http.py | 201 ++++++++++++++++++ src/bk-login/bklogin/settings.py | 12 +- src/bk-login/pages/src/router/index.ts | 4 +- .../pages/src/views/components/password.vue | 2 +- src/bk-login/poetry.lock | 127 ++--------- src/bk-login/pyproject.toml | 11 +- .../bkuser/apis/login/authentications.py | 31 +++ src/bk-user/bkuser/apis/login/mixins.py | 23 ++ src/bk-user/bkuser/apis/login/serializers.py | 94 ++++++++ src/bk-user/bkuser/apis/login/urls.py | 40 ++++ src/bk-user/bkuser/apis/login/views.py | 164 ++++++++++++++ .../bkuser/apis/web/tenant/serializers.py | 4 +- .../bkuser/apps/data_source/initializers.py | 3 +- ...nfo.py => 0010_datasourcesensitiveinfo.py} | 2 +- src/bk-user/bkuser/apps/data_source/models.py | 4 + src/bk-user/bkuser/apps/idp/data_models.py | 80 ++++++- src/bk-user/bkuser/apps/idp/handlers.py | 29 ++- src/bk-user/bkuser/biz/validators.py | 4 +- src/bk-user/bkuser/common/error_codes.py | 3 + src/bk-user/bkuser/component/login.py | 2 +- src/bk-user/bkuser/plugins/general/http.py | 4 +- src/bk-user/bkuser/plugins/local/parser.py | 4 +- src/bk-user/bkuser/settings.py | 2 + src/bk-user/bkuser/urls.py | 2 + src/bk-user/poetry.lock | 127 ++--------- src/bk-user/pyproject.toml | 11 +- src/bk-user/tests/apps/idp/__init__.py | 10 + .../tests/apps/idp/test_data_models.py | 90 ++++++++ src/idp-plugins/idp_plugins/base.py | 5 +- src/idp-plugins/idp_plugins/constants.py | 2 +- src/idp-plugins/idp_plugins/exceptions.py | 4 + src/idp-plugins/idp_plugins/http.py | 167 ++++++++++++--- src/idp-plugins/idp_plugins/local/client.py | 74 +++++++ .../idp_plugins/local/db_models.py | 53 ----- src/idp-plugins/idp_plugins/local/plugin.py | 22 +- src/idp-plugins/idp_plugins/local/settings.py | 15 ++ src/idp-plugins/idp_plugins/wecom/client.py | 6 +- src/idp-plugins/idp_plugins/wecom/plugin.py | 6 +- src/idp-plugins/poetry.lock | 123 ++--------- src/idp-plugins/pyproject.toml | 11 +- 57 files changed, 1403 insertions(+), 854 deletions(-) delete mode 100644 src/bk-login/bklogin/bkuser/constants.py delete mode 100644 src/bk-login/bklogin/bkuser/models.py rename src/bk-login/bklogin/{bkuser => component}/__init__.py (100%) rename src/bk-login/bklogin/{bkuser/apps.py => component/bk_user/__init__.py} (82%) create mode 100644 src/bk-login/bklogin/component/bk_user/api.py rename src/bk-login/bklogin/{bkuser/data_models.py => component/bk_user/constants.py} (66%) create mode 100644 src/bk-login/bklogin/component/bk_user/models.py create mode 100644 src/bk-login/bklogin/component/http.py create mode 100644 src/bk-user/bkuser/apis/login/authentications.py create mode 100644 src/bk-user/bkuser/apis/login/mixins.py create mode 100644 src/bk-user/bkuser/apis/login/serializers.py create mode 100644 src/bk-user/bkuser/apis/login/urls.py create mode 100644 src/bk-user/bkuser/apis/login/views.py rename src/bk-user/bkuser/apps/data_source/migrations/{0009_datasourcesensitiveinfo.py => 0010_datasourcesensitiveinfo.py} (93%) create mode 100644 src/bk-user/tests/apps/idp/__init__.py create mode 100644 src/bk-user/tests/apps/idp/test_data_models.py create mode 100644 src/idp-plugins/idp_plugins/local/client.py delete mode 100644 src/idp-plugins/idp_plugins/local/db_models.py create mode 100644 src/idp-plugins/idp_plugins/local/settings.py diff --git a/.github/workflows/bk-user.yml b/.github/workflows/bk-user.yml index 20b60746e..81b38f7fa 100644 --- a/.github/workflows/bk-user.yml +++ b/.github/workflows/bk-user.yml @@ -22,18 +22,21 @@ jobs: run: | ln -s $(pwd)/src/idp-plugins/idp_plugins $(pwd)/src/bk-login/bklogin ln -s $(pwd)/src/idp-plugins/idp_plugins $(pwd)/src/bk-user/bkuser - - name: Format with black + - name: Format & Lint with ruff run: | - pip install black==23.7.0 click==8.1.6 - black src/bk-user --config=src/bk-user/pyproject.toml - - name: Lint with ruff - run: | - pip install ruff==0.0.277 + pip install ruff==0.1.4 + ruff format src/bk-user --config=src/bk-user/pyproject.toml ruff src/bk-user --config=src/bk-user/pyproject.toml + ruff format src/bk-login --config=src/bk-login/pyproject.toml + ruff src/bk-login --config=src/bk-login/pyproject.toml + ruff format src/idp-plugins --config=src/idp-plugins/pyproject.toml + ruff src/idp-plugins --config=src/idp-plugins/pyproject.toml - name: Lint with mypy run: | pip install mypy==1.6.1 types-requests==2.31.0.2 types-setuptools==57.4.18 types-dataclasses==0.1.7 types-redis==3.5.18 types-PyMySQL==1.1.0.1 types-six==0.1.9 types-toml==0.1.5 types-pytz==2023.3.0.0 types-urllib3==1.26.25.14 mypy src/bk-user --config-file=src/bk-user/pyproject.toml + mypy src/bk-login --config-file=src/bk-login/pyproject.toml + mypy src/idp-plugins --config-file=src/idp-plugins/pyproject.toml test: strategy: fail-fast: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1d041a91c..101774f32 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,11 +49,11 @@ repos: entry: bash -c "if [[ -d pre_commit_hooks ]]; then pre_commit_hooks/ip.sh $@; fi" - repo: local hooks: - - id: black - name: black + - id: format + name: ruff-formatter language: python types: [python] - entry: black --config=src/bk-user/pyproject.toml + entry: ruff format --config=src/bk-user/pyproject.toml --force-exclude files: src/bk-user/ - id: ruff name: ruff @@ -74,11 +74,11 @@ repos: entry: bash -c "cd src/bk-user && lint-imports" - repo: local hooks: - - id: black - name: black + - id: format + name: ruff-formatter language: python types: [python] - entry: black --config=src/bk-login/pyproject.toml + entry: ruff format --config=src/bk-login/pyproject.toml --force-exclude files: src/bk-login/ - id: ruff name: ruff @@ -99,18 +99,18 @@ repos: entry: bash -c "cd src/bk-login && lint-imports" - repo: local hooks: - - id: black - name: black + - id: format + name: ruff-formatter language: python types: [python] - entry: black --config=src/idp-plugins/pyproject.toml - files: src/idp-plugins/ + entry: ruff format --config=src/bk-plugins/pyproject.toml --force-exclude + files: src/bk-plugins/ - id: ruff name: ruff language: python types: [python] entry: ruff --config=src/idp-plugins/pyproject.toml --force-exclude --fix - files: src/bk-login/ + files: src/idp-plugins/ - id: mypy name: mypy language: python diff --git a/src/bk-login/bklogin/authentication/api_views.py b/src/bk-login/bklogin/authentication/api_views.py index 39647dca6..06e10a4cf 100644 --- a/src/bk-login/bklogin/authentication/api_views.py +++ b/src/bk-login/bklogin/authentication/api_views.py @@ -9,12 +9,11 @@ specific language governing permissions and limitations under the License. """ from django.conf import settings -from django.utils.translation import gettext_lazy as _ from django.views.generic import View -from bklogin.bkuser.models import TenantUser from bklogin.common.error_codes import error_codes from bklogin.common.response import APISuccessResponse +from bklogin.component.bk_user import api as bk_user_api from .manager import BkTokenManager @@ -38,16 +37,14 @@ def get(self, request, *args, **kwargs): if not ok: raise error_codes.VALIDATION_ERROR.f(msg) - user = TenantUser.objects.filter(id=username).first() - if not user: - raise error_codes.OBJECT_NOT_FOUND.f(_("用户({})查询不到").format(username)) + user = bk_user_api.get_tenant_user(username) return APISuccessResponse( { - "bk_username": username, + "bk_username": user.id, "tenant_id": user.tenant_id, - "full_name": user.data_source_user.full_name, - "source_username": user.data_source_user.username, + "full_name": user.full_name, + "source_username": user.username, "language": user.language, "time_zone": user.time_zone, } diff --git a/src/bk-login/bklogin/authentication/constants.py b/src/bk-login/bklogin/authentication/constants.py index 3bd711691..a6655819d 100644 --- a/src/bk-login/bklogin/authentication/constants.py +++ b/src/bk-login/bklogin/authentication/constants.py @@ -12,4 +12,4 @@ SIGN_IN_TENANT_ID_SESSION_KEY = "sign_in_tenant_id" -ALLOWED_SIGN_IN_TENANT_USER_IDS_SESSION_KEY = "allowed_sign_in_tenant_user_ids" +ALLOWED_SIGN_IN_TENANT_USERS_SESSION_KEY = "allowed_sign_in_tenant_users" diff --git a/src/bk-login/bklogin/authentication/manager.py b/src/bk-login/bklogin/authentication/manager.py index 3f7d7d38c..efc59d5ae 100644 --- a/src/bk-login/bklogin/authentication/manager.py +++ b/src/bk-login/bklogin/authentication/manager.py @@ -19,7 +19,6 @@ from blue_krill.encrypt.handler import EncryptHandler from django.conf import settings from django.utils import timezone -from django.utils.encoding import force_bytes from django.utils.translation import gettext_lazy as _ from .models import BkToken @@ -33,9 +32,9 @@ class BkTokenProcessor: 生成并加密Token & 解密Token """ - def __init__(self, encrypt_secret_key: bytes): - # Token加密密钥 - self.encrypt_secret_key = encrypt_secret_key + def __init__(self): + # 加密器,默认读取django settings里配置的加密密钥和加密类 + self.crypter = EncryptHandler() @staticmethod def _salt(length: int = 8) -> str: @@ -49,7 +48,7 @@ def generate(self, username: str, expires_at: int) -> str: plain_token = "%s|%s|%s" % (expires_at, username, self._salt()) # 加密 - return EncryptHandler(secret_key=self.encrypt_secret_key).encrypt(plain_token) + return self.crypter.encrypt(plain_token) def parse(self, bk_token: str) -> Tuple[str, int]: """ @@ -57,7 +56,7 @@ def parse(self, bk_token: str) -> Tuple[str, int]: :return: username, expires_at """ try: - plain_bk_token = EncryptHandler(secret_key=self.encrypt_secret_key).decrypt(bk_token) + plain_bk_token = self.crypter.decrypt(bk_token) except Exception: logger.exception("参数 bk_token [%s] 解析失败", bk_token) plain_bk_token = "" @@ -84,7 +83,7 @@ def parse(self, bk_token: str) -> Tuple[str, int]: class BkTokenManager: def __init__(self): # Token加密密钥 - self.bk_token_processor = BkTokenProcessor(encrypt_secret_key=force_bytes(settings.ENCRYPT_SECRET_KEY)) + self.bk_token_processor = BkTokenProcessor() # Token 过期间隔 self.cookie_age = settings.BK_TOKEN_COOKIE_AGE # Token 无操作失效间隔 @@ -114,7 +113,7 @@ def get_bk_token(self, username: str) -> Tuple[str, datetime.datetime]: bk_token = self.bk_token_processor.generate(username, expires_at) # DB记录 try: - BkToken.objects.create(token=bk_token, inactive_expire_time=inactive_expires_at) + BkToken.objects.create(token=bk_token, inactive_expires_at=inactive_expires_at) except Exception: # noqa: PERF203 logger.exception("Login ticket failed to be saved during ticket generation") # 循环结束前将bk_token置空后重新生成 @@ -167,6 +166,6 @@ def is_bk_token_valid(self, bk_token: str) -> Tuple[bool, str, str]: try: BkToken.objects.filter(token=bk_token).update(inactive_expires_at=now + self.inactive_age) except Exception: - logger.exception("update inactive_expire_time fail") + logger.exception("update inactive_expires_at fail") return True, username, "" diff --git a/src/bk-login/bklogin/authentication/migrations/0001_initial.py b/src/bk-login/bklogin/authentication/migrations/0001_initial.py index 834062593..a292b5d99 100644 --- a/src/bk-login/bklogin/authentication/migrations/0001_initial.py +++ b/src/bk-login/bklogin/authentication/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.21 on 2023-09-27 02:34 +# Generated by Django 3.2.21 on 2023-11-09 11:26 from django.db import migrations, models @@ -15,9 +15,14 @@ class Migration(migrations.Migration): name='BkToken', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ('token', models.CharField(db_index=True, max_length=255, unique=True, verbose_name='登录票据')), ('is_logout', models.BooleanField(default=False, verbose_name='票据是否已经执行过退出登录操作')), ('inactive_expires_at', models.IntegerField(default=0, verbose_name='无操作失效时间戳')), ], + options={ + 'abstract': False, + }, ), ] diff --git a/src/bk-login/bklogin/authentication/models.py b/src/bk-login/bklogin/authentication/models.py index 1deffc0d1..38d86feda 100644 --- a/src/bk-login/bklogin/authentication/models.py +++ b/src/bk-login/bklogin/authentication/models.py @@ -10,8 +10,10 @@ """ from django.db import models +from bklogin.common.models import TimestampedModel -class BkToken(models.Model): + +class BkToken(TimestampedModel): """ 登录票据 """ diff --git a/src/bk-login/bklogin/authentication/urls.py b/src/bk-login/bklogin/authentication/urls.py index 5aefdacba..eba17690e 100644 --- a/src/bk-login/bklogin/authentication/urls.py +++ b/src/bk-login/bklogin/authentication/urls.py @@ -16,8 +16,10 @@ urlpatterns = [ # 登录入口 path("", views.LoginView.as_view()), + # 登录小窗入口 + path("plain/", views.LoginView.as_view()), # 前端页面(选择登录的用户) - path("pages/users", TemplateView.as_view(template_name="index.html")), + path("page/users/", TemplateView.as_view(template_name="index.html")), # ------------------------------------------ 租户 & 登录方式选择 ------------------------------------------ # 租户配置 path("tenant-global-settings/", views.TenantGlobalSettingRetrieveApi.as_view()), diff --git a/src/bk-login/bklogin/authentication/views.py b/src/bk-login/bklogin/authentication/views.py index 268f98de9..a2f74ff13 100644 --- a/src/bk-login/bklogin/authentication/views.py +++ b/src/bk-login/bklogin/authentication/views.py @@ -22,17 +22,21 @@ from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie from django.views.generic import View -from bklogin.bkuser.constants import IdpStatus -from bklogin.bkuser.data_models import DataSourceMatchRuleList -from bklogin.bkuser.models import DataSourceUser, Idp, Tenant, TenantUser from bklogin.common.error_codes import error_codes from bklogin.common.request import parse_request_body_json from bklogin.common.response import APISuccessResponse +from bklogin.component.bk_user import api as bk_user_api +from bklogin.component.bk_user.constants import IdpStatus from bklogin.idp_plugins.base import BaseCredentialIdpPlugin, BaseFederationIdpPlugin, get_plugin_cls from bklogin.idp_plugins.constants import AllowedHttpMethodEnum, BuiltinActionEnum -from bklogin.idp_plugins.exceptions import InvalidParamError, ParseRequestBodyError, UnexpectedDataError - -from .constants import ALLOWED_SIGN_IN_TENANT_USER_IDS_SESSION_KEY, REDIRECT_FIELD_NAME, SIGN_IN_TENANT_ID_SESSION_KEY +from bklogin.idp_plugins.exceptions import ( + InvalidParamError, + ParseRequestBodyError, + UnexpectedDataError, + ValidationError, +) + +from .constants import ALLOWED_SIGN_IN_TENANT_USERS_SESSION_KEY, REDIRECT_FIELD_NAME, SIGN_IN_TENANT_ID_SESSION_KEY from .manager import BkTokenManager @@ -84,8 +88,8 @@ def get(self, request, *args, **kwargs): """ 租户的全局配置,即所有租户的公共配置 """ - # FIXME: 支持全局配置后调整从DB读取配置 - return APISuccessResponse(data={"tenant_visible": settings.TENANT_VISIBLE}) + global_setting = bk_user_api.get_global_setting() + return APISuccessResponse(data=global_setting.model_dump(include={"tenant_visible"})) class TenantListApi(View): @@ -97,16 +101,14 @@ def get(self, request, *args, **kwargs): tenant_ids_str = request.GET.get("tenant_ids", "") tenant_ids = [i for i in tenant_ids_str.split(",") if i] - # 检查租户是否可见 - if not tenant_ids and not settings.TENANT_VISIBLE: + # 无tenant_ids表示需要获取全部租户,这时候需要检查租户是否可见 + global_setting = bk_user_api.get_global_setting() + if not tenant_ids and not global_setting.tenant_visible: raise error_codes.NO_PERMISSION.f(_("租户信息不可见")) - tenants = Tenant.objects.all() - # 过滤 - if tenant_ids: - tenants = tenants.filter(id__in=tenant_ids) + tenants = bk_user_api.list_tenant(tenant_ids) - return APISuccessResponse(data=[{"id": t.id, "name": t.name, "logo": t.logo} for t in tenants]) + return APISuccessResponse(data=[t.model_dump(include={"id", "name", "logo"}) for t in tenants]) class TenantRetrieveApi(View): @@ -115,11 +117,11 @@ def get(self, request, *args, **kwargs): 通过租户ID,查询单个租户信息 """ tenant_id = kwargs["tenant_id"] - tenant = Tenant.objects.filter(id=tenant_id).first() + tenant = bk_user_api.get_tenant(tenant_id) if tenant is None: - raise error_codes.OBJECT_NOT_FOUND.f(_("租户({})未找到").format(tenant_id)) + raise error_codes.OBJECT_NOT_FOUND.f(f"租户 {tenant_id} 不存在", replace=True) - return APISuccessResponse(data={"id": tenant.id, "name": tenant.name, "logo": tenant.logo}) + return APISuccessResponse(data=tenant.model_dump(include={"id", "name", "logo"})) class SignInTenantCreateApi(View): @@ -135,7 +137,9 @@ def post(self, request, *args, **kwargs): raise error_codes.VALIDATION_ERROR.f(_("tenant_id参数必填")) # 校验租户是否存在 - if not Tenant.objects.filter(id=tenant_id).exists(): + tenants = bk_user_api.list_tenant() + tenant_id_set = {i.id for i in tenants} + if tenant_id not in tenant_id_set: raise error_codes.OBJECT_NOT_FOUND.f(_("租户({})未找到").format(tenant_id)) # session记录登录的租户 @@ -154,34 +158,29 @@ def get(self, request, *args, **kwargs): if not sign_in_tenant_id: raise error_codes.NO_PERMISSION.f(_("未选择需要登录的租户")) - # 查询租户是否存在 - if not Tenant.objects.filter(id=sign_in_tenant_id).exists(): - raise error_codes.OBJECT_NOT_FOUND.f(_("租户({})未找到").format(sign_in_tenant_id)) - # 查询本租户配置的认证源 - idps = Idp.objects.filter(owner_tenant_id=sign_in_tenant_id, status=IdpStatus.ENABLED) - # TODO: 考虑是否过滤掉,没有配置匹配数据源的认证源? - # TODO: 查询租户协同其他租户数据源对应的认证源 - data = [ - { - "id": i.id, - "name": i.name, - "plugin": { - "id": i.plugin.id, - "name": i.plugin.name, - "category": i.plugin.category, - }, - } - for i in idps - ] - - return APISuccessResponse(data=data) + idps = bk_user_api.list_idp(sign_in_tenant_id) + + return APISuccessResponse( + data=[i.model_dump(include={"id", "name", "plugin"}) for i in idps if i.status == IdpStatus.ENABLED], + ) + + +class IdpBasicInfo(pydantic.BaseModel): + """认证源基础信息""" + + id: str + name: str + plugin_id: str + plugin_name: str class PluginErrorContext(pydantic.BaseModel): """插件异常上下文,用于打印日志时所需的上下文信息""" - idp: Idp + # 插件信息 + idp: IdpBasicInfo + action: str http_method: str @@ -203,14 +202,19 @@ def dispatch(self, request, *args, **kwargs): action = kwargs["action"] http_method = request.method.lower() - # 查询插件 - idp = Idp.objects.filter(id=idp_id, owner_tenant_id=sign_in_tenant_id).first() - if idp is None: - raise error_codes.OBJECT_NOT_FOUND.f(_("租户({})不存在该认证源({})").format(sign_in_tenant_id, idp_id)) + # 获取认证源信息 + idp = bk_user_api.get_idp(idp_id) + # 判断是否当前登录租户所属数据源 + # TODO: 后续协同租户的数据源,需要调整判断关系 + if idp.owner_tenant_id != sign_in_tenant_id: + raise error_codes.NO_PERMISSION.f(_("非当前登录租户所配置的认证源")) + + if idp.status != IdpStatus.ENABLED: + raise error_codes.NO_PERMISSION.f(_("当前认证源未启用,无法通过该认证源登录")) # (1) 获取插件 try: - plugin_cls = get_plugin_cls(idp.plugin_id) + plugin_cls = get_plugin_cls(idp.plugin.id) except NotImplementedError as error: raise error_codes.PLUGIN_SYSTEM_ERROR.f( _("认证源[{}]获取插件[{}]失败, {}").format(idp.name, idp.plugin.name, error), @@ -232,15 +236,20 @@ def dispatch(self, request, *args, **kwargs): _("认证源[{}]加载插件[{}]失败, {}").format(idp.name, idp.plugin.name, error), ) + idp_info = IdpBasicInfo(id=idp.id, name=idp.name, plugin_id=idp.plugin.id, plugin_name=idp.plugin.name) # (3)dispatch # FIXME: 如何对身份凭证类的认证进行手动csrf校验,或者如何添加csrf_protect装饰器 # 身份凭证类型 if isinstance(plugin, BaseCredentialIdpPlugin): - return self._dispatch_credential_idp_plugin(plugin, request, sign_in_tenant_id, idp, action, http_method) + return self._dispatch_credential_idp_plugin( + plugin, request, sign_in_tenant_id, idp_info, action, http_method + ) # 联邦身份类型 if isinstance(plugin, BaseFederationIdpPlugin): - return self._dispatch_federation_idp_plugin(plugin, request, sign_in_tenant_id, idp, action, http_method) + return self._dispatch_federation_idp_plugin( + plugin, request, sign_in_tenant_id, idp_info, action, http_method + ) return HttpResponseNotFound() @@ -250,7 +259,7 @@ def wrap_plugin_error(self, context: PluginErrorContext, func: Callable, *func_a return func(*func_args, **func_kwargs) except ParseRequestBodyError as e: raise error_codes.INVALID_ARGUMENT.f(str(e), replace=True) - except InvalidParamError as e: + except (InvalidParamError, ValidationError) as e: raise error_codes.VALIDATION_ERROR.f(str(e), replace=True) except UnexpectedDataError as e: raise error_codes.UNEXPECTED_DATA_ERROR.f(str(e), replace=True) @@ -260,14 +269,20 @@ def wrap_plugin_error(self, context: PluginErrorContext, func: Callable, *func_a context.idp.id, context.action, context.http_method, - context.idp.plugin.id, + context.idp.plugin_id, ) raise error_codes.PLUGIN_SYSTEM_ERROR.f( - _("认证源[{}]执行插件[{}]失败, {}").format(context.idp.name, context.idp.plugin.name), + _("认证源[{}]执行插件[{}]失败, {}").format(context.idp.name, context.idp.plugin_name), ) def _dispatch_credential_idp_plugin( - self, plugin: BaseCredentialIdpPlugin, request, sign_in_tenant_id: str, idp: Idp, action: str, http_method: str + self, + plugin: BaseCredentialIdpPlugin, + request, + sign_in_tenant_id: str, + idp: IdpBasicInfo, + action: str, + http_method: str, ): """ 身份凭证类的插件执行请求分配 @@ -280,9 +295,9 @@ def _dispatch_credential_idp_plugin( user_infos = self.wrap_plugin_error(plugin_error_context, plugin.authenticate_credentials, request=request) # 使用认证源获得的用户信息,匹配认证出对应的租户用户列表 - tenant_user_ids = self._auth_backend(request, sign_in_tenant_id, idp, user_infos) + tenant_users = self._auth_backend(request, sign_in_tenant_id, idp.id, user_infos) # 记录支持登录的租户用户 - request.session[ALLOWED_SIGN_IN_TENANT_USER_IDS_SESSION_KEY] = tenant_user_ids + request.session[ALLOWED_SIGN_IN_TENANT_USERS_SESSION_KEY] = tenant_users # 身份凭证认证直接返回成功即可,由前端重定向路由到用户列表选择页面 return APISuccessResponse() @@ -291,7 +306,13 @@ def _dispatch_credential_idp_plugin( ) def _dispatch_federation_idp_plugin( - self, plugin: BaseFederationIdpPlugin, request, sign_in_tenant_id: str, idp: Idp, action: str, http_method: str + self, + plugin: BaseFederationIdpPlugin, + request, + sign_in_tenant_id: str, + idp: IdpBasicInfo, + action: str, + http_method: str, ): """ 联邦认证类的插件执行请求分配 @@ -318,11 +339,11 @@ def _dispatch_federation_idp_plugin( user_info = self.wrap_plugin_error(plugin_error_context, plugin.handle_callback, request=request) # 使用认证源获得的用户信息,匹配认证出对应的租户用户列表 - tenant_user_ids = self._auth_backend(request, sign_in_tenant_id, idp, user_info) + tenant_users = self._auth_backend(request, sign_in_tenant_id, idp.id, user_info) # 记录支持登录的租户用户 - request.session[ALLOWED_SIGN_IN_TENANT_USER_IDS_SESSION_KEY] = tenant_user_ids + request.session[ALLOWED_SIGN_IN_TENANT_USERS_SESSION_KEY] = tenant_users # 联邦认证则重定向到前端选择账号页面 - return HttpResponseRedirect(redirect_to="pages/users") + return HttpResponseRedirect(redirect_to="page/users/") return self.wrap_plugin_error( plugin_error_context, plugin.dispatch_extension, action=action, http_method=http_method, request=request @@ -333,51 +354,19 @@ def _get_complete_action_url(self, idp_id: str, action: str) -> str: return urljoin(settings.BK_LOGIN_URL, f"auth/idps/{idp_id}/actions/{action}/") def _auth_backend( - self, request, sign_in_tenant_id: str, idp: Idp, user_infos: Dict[str, Any] | List[Dict[str, Any]] - ) -> List[str]: + self, request, sign_in_tenant_id: str, idp_id: str, user_infos: Dict[str, Any] | List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """认证:认证源数据与数据源匹配""" if isinstance(user_infos, dict): user_infos = [user_infos] - # FIXME: 查询是绑定匹配还是直接匹配, - # 一般社会化登录都得通过绑定匹配方式,比如QQ,用户得先绑定后才能使用QQ登录 - # 直接匹配,一般是企业身份登录方式, - # 比如企业内部SAML2.0登录,认证后获取到的用户字段,能直接与数据源里的用户数据字段匹配 - # 认证源配置里的与数据源的匹配规则 - data_source_match_rules = DataSourceMatchRuleList.validate_python(idp.data_source_match_rules) - # 逐规则匹配,查询用户 - matched_data_source_user_ids = [] - for rule in data_source_match_rules: - # 规则里target_field为数据源的用户字段名,source_field为认证源的用户字段名 - # 构造过滤条件,从user_infos里获取字段,并映射为数据源目标字段值 - target_field_values = [u.get(rule.source_field) for u in user_infos if u.get(rule.source_field)] - if not target_field_values: - continue - # 转换为Django Queryset可使用的过滤条件:{"target_filed_in": [...]} 或 {"target_field": xxx} - filter_content: Dict[str, Any | List[Any]] = ( - {f"{rule.target_field}__in": target_field_values} - if len(target_field_values) > 1 - else {rule.target_field: target_field_values[0]} - ) - # 查询匹配的数据源用户 - data_source_user_ids = DataSourceUser.objects.filter( - data_source_id=rule.data_source_id, **filter_content - ).values_list("id", flat=True) - if data_source_user_ids: - matched_data_source_user_ids.extend(data_source_user_ids) - - # 根据数据源用户匹配对应租户用户 - tenant_user_ids = list( - TenantUser.objects.filter( - tenant_id=sign_in_tenant_id, data_source_user_id__in=matched_data_source_user_ids - ).values_list("id", flat=True) - ) - if not tenant_user_ids: + tenant_users = bk_user_api.list_matched_tencent_user(sign_in_tenant_id, idp_id, user_infos) + if not tenant_users: raise error_codes.OBJECT_NOT_FOUND.f( _("认证成功,但用户在租户({})下未有对应账号").format(sign_in_tenant_id), ) - return tenant_user_ids + return [i.model_dump(include={"id", "username", "full_name"}) for i in tenant_users] class TenantUserListApi(View): @@ -391,22 +380,13 @@ def get(self, request, *args, **kwargs): raise error_codes.NO_PERMISSION.f(_("未选择需要登录的租户")) # Session里获取已认证过的租户用户 - tenant_user_ids = request.session.get(ALLOWED_SIGN_IN_TENANT_USER_IDS_SESSION_KEY) - if not tenant_user_ids: + tenant_users = request.session.get(ALLOWED_SIGN_IN_TENANT_USERS_SESSION_KEY) + if not tenant_users: raise error_codes.NO_PERMISSION.f(_("未经过用户认证步骤")) - tenant_users = TenantUser.objects.filter(tenant_id=sign_in_tenant_id, id__in=tenant_user_ids).select_related( - "data_source_user" - ) - # TODO: 查询每个租户用户的状态 - return APISuccessResponse( - data=[ - {"id": i.id, "username": i.data_source_user.username, "full_name": i.data_source_user.full_name} - for i in tenant_users - ] - ) + return APISuccessResponse(data=tenant_users) class SignInTenantUserCreateApi(View): @@ -421,7 +401,8 @@ def post(self, request, *args, **kwargs): if not user_id: raise error_codes.VALIDATION_ERROR.f(_("user_id 参数必填")) - tenant_user_ids = request.session.get(ALLOWED_SIGN_IN_TENANT_USER_IDS_SESSION_KEY) or [] + tenant_users = request.session.get(ALLOWED_SIGN_IN_TENANT_USERS_SESSION_KEY) or [] + tenant_user_ids = {i["id"] for i in tenant_users} if user_id not in tenant_user_ids: raise error_codes.NO_PERMISSION.f(_("该用户不可登录")) diff --git a/src/bk-login/bklogin/bkuser/constants.py b/src/bk-login/bklogin/bkuser/constants.py deleted file mode 100644 index 32d918c6a..000000000 --- a/src/bk-login/bklogin/bkuser/constants.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- 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 blue_krill.data_types.enum import EnumField, StructuredEnum -from django.utils.translation import gettext_lazy as _ - - -class DataSourceStatus(str, StructuredEnum): - """数据源状态""" - - ENABLED = EnumField("enabled", label=_("启用")) - DISABLED = EnumField("disabled", label=_("未启用")) - - -class IdpStatus(str, StructuredEnum): - """认证源状态""" - - ENABLED = EnumField("enabled", label=_("启用")) - DISABLED = EnumField("disabled", label=_("未启用")) - - -class AllowBindScopeObjectType(str, StructuredEnum): - """社会化认证源,允许绑定的范围对象类型""" - - USER = EnumField("user", label=_("用户")) - DEPARTMENT = EnumField("department", label=_("部门")) - DATA_SOURCE = EnumField("data_source", label=_("数据源")) - TENANT = EnumField("tenant", label=_("租户")) - ANY = EnumField("*", label=_("任意")) - - -# 社会化认证源,允许绑定的范围为任意对象ID -ANY_ALLOW_BIND_SCOPE_OBJECT_ID = "*" diff --git a/src/bk-login/bklogin/bkuser/models.py b/src/bk-login/bklogin/bkuser/models.py deleted file mode 100644 index d21cc78ff..000000000 --- a/src/bk-login/bklogin/bkuser/models.py +++ /dev/null @@ -1,156 +0,0 @@ -# -*- 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.db import models - -from bklogin.common.models import AuditedModel, TimestampedModel - -from .constants import DataSourceStatus, IdpStatus - - -class Tenant(TimestampedModel): - id = models.CharField("租户唯一标识", primary_key=True, max_length=128) - name = models.CharField("租户名称", max_length=128, unique=True) - logo = models.TextField("Logo", null=True, blank=True, default="") - is_default = models.BooleanField("是否默认租户", default=False) - - class Meta: - db_table = "tenant_tenant" - ordering = ["created_at"] - - -class DataSourcePlugin(models.Model): - """ - 数据源插件 - DB初始化内置插件:local/mad/ldap - """ - - id = models.CharField("数据源插件唯一标识", primary_key=True, max_length=128) - name = models.CharField("数据源插件名称", max_length=128, unique=True) - description = models.TextField("描述", default="", blank=True) - logo = models.TextField("Logo", null=True, blank=True, default="") - - class Meta: - db_table = "data_source_datasourceplugin" - - -class DataSource(AuditedModel): - name = models.CharField("数据源名称", max_length=128, unique=True) - owner_tenant_id = models.CharField("归属租户", max_length=64, db_index=True) - status = models.CharField( - "数据源状态", - max_length=32, - choices=DataSourceStatus.get_choices(), - default=DataSourceStatus.ENABLED, - ) - # Note: 数据源插件被删除的前提是,插件没有被任何数据源使用 - plugin = models.ForeignKey(DataSourcePlugin, on_delete=models.PROTECT) - plugin_config = models.JSONField("插件配置", default=dict) - # 同步任务启用/禁用配置、周期配置等 - sync_config = models.JSONField("同步任务配置", default=dict) - # 字段映射,外部数据源提供商,用户数据字段映射到租户用户数据字段 - field_mapping = models.JSONField("用户字段映射", default=list) - - class Meta: - db_table = "data_source_datasource" - ordering = ["id"] - - -class DataSourceUser(TimestampedModel): - data_source = models.ForeignKey(DataSource, on_delete=models.PROTECT, db_constraint=False) - code = models.CharField("用户标识", max_length=128) - - # ----------------------- 内置字段相关 ----------------------- - username = models.CharField("用户名", max_length=128) - full_name = models.CharField("姓名", max_length=128) - email = models.EmailField("邮箱", null=True, blank=True, default="") - phone = models.CharField("手机号", null=True, blank=True, default="", max_length=32) - phone_country_code = models.CharField("手机国际区号", max_length=16, null=True, blank=True) - logo = models.TextField("Logo", max_length=256, null=True, blank=True, default="") - - # ----------------------- 其他 ----------------------- - extras = models.JSONField("自定义字段", default=dict) - - # ----------------------- 状态相关 ----------------------- - # TODO: (1) 用户管理里涉及的功能状态 (2)企业本身的员工状态 - - class Meta: - db_table = "data_source_datasourceuser" - ordering = ["id"] - - -class IdpPlugin(models.Model): - """认证源插件""" - - id = models.CharField("认证源插件唯一标识", primary_key=True, max_length=128) - name = models.CharField("认证源插件名称", max_length=128, unique=True) - description = models.TextField("描述", default="", blank=True) - logo = models.TextField("Logo", null=True, blank=True, default="") - - class Meta: - db_table = "idp_idpplugin" - - -class Idp(AuditedModel): - """认证源""" - - # 登录回调场景下,该 ID 是 URL Path 的一部分 - id = models.CharField("认证源标识", primary_key=True, max_length=128) - name = models.CharField("认证源名称", max_length=128) - owner_tenant_id = models.CharField("归属租户", max_length=64, db_index=True) - status = models.CharField("认证源状态", max_length=32, choices=IdpStatus.get_choices(), default=IdpStatus.ENABLED) - # Note: 认证源插件被删除的前提是,插件没有被任何认证源使用 - plugin = models.ForeignKey(IdpPlugin, on_delete=models.PROTECT) - plugin_config = models.JSONField("插件配置", default=dict) - # 认证源与数据源的匹配规则 - data_source_match_rules = models.JSONField("匹配规则", default=list) - # 允许关联社会化认证源的租户组织架构范围 - allow_bind_scopes = models.JSONField("允许范围", default=list) - - class Meta: - db_table = "idp_idp" - ordering = ["created_at"] - - -class TenantUser(TimestampedModel): - """ - 租户用户即蓝鲸用户 - """ - - tenant = models.ForeignKey(Tenant, on_delete=models.DO_NOTHING, db_constraint=False) - data_source_user = models.ForeignKey(DataSourceUser, on_delete=models.DO_NOTHING, db_constraint=False) - - # 冗余字段 - data_source = models.ForeignKey(DataSource, on_delete=models.DO_NOTHING, db_constraint=False) - - # Note: 值:对于新用户则为uuid,对于迁移则兼容旧版本 username@domain或username - # 兼容旧版本:对外 id/username/bk_username 这3个字段,值是一样的 - id = models.CharField("蓝鲸用户对外唯一标识", primary_key=True, max_length=128) - - # 蓝鲸特有 - language = models.CharField("语言", default="zh-cn", max_length=32) - time_zone = models.CharField("时区", default="Asia/Shanghai", max_length=32) - - # wx_userid/wx_openid 兼容旧版本迁移 - wx_userid = models.CharField("微信ID", null=True, blank=True, default="", max_length=64) - wx_openid = models.CharField("微信公众号OpenID", null=True, blank=True, default="", max_length=64) - - # 账号有效期相关 - account_expired_at = models.DateTimeField("账号过期时间", null=True, blank=True) - - # 手机&邮箱相关:手机号&邮箱都可以继承数据源或自定义 - is_inherited_phone = models.BooleanField("是否继承数据源手机号", default=True) - custom_phone = models.CharField("自定义手机号", max_length=32, null=True, blank=True, default="") - custom_phone_country_code = models.CharField("自定义手机号的国际区号", max_length=16, null=True, blank=True) - is_inherited_email = models.BooleanField("是否继承数据源邮箱", default=True) - custom_email = models.EmailField("自定义邮箱", null=True, blank=True, default="") - - class Meta: - db_table = "tenant_tenantuser" diff --git a/src/bk-login/bklogin/common/error_codes.py b/src/bk-login/bklogin/common/error_codes.py index 201a7d14c..370cc270b 100644 --- a/src/bk-login/bklogin/common/error_codes.py +++ b/src/bk-login/bklogin/common/error_codes.py @@ -69,6 +69,9 @@ class ErrorCodes: ) NOT_SUPPORTED = ErrorCode(_("不支持")) + # 调用系统API异常 + REMOTE_REQUEST_ERROR = ErrorCode(_("调用系统API异常")) + # 实例化一个全局对象 error_codes = ErrorCodes() diff --git a/src/bk-login/bklogin/bkuser/__init__.py b/src/bk-login/bklogin/component/__init__.py similarity index 100% rename from src/bk-login/bklogin/bkuser/__init__.py rename to src/bk-login/bklogin/component/__init__.py diff --git a/src/bk-login/bklogin/bkuser/apps.py b/src/bk-login/bklogin/component/bk_user/__init__.py similarity index 82% rename from src/bk-login/bklogin/bkuser/apps.py rename to src/bk-login/bklogin/component/bk_user/__init__.py index 13f562b99..1060b7bf4 100644 --- a/src/bk-login/bklogin/bkuser/apps.py +++ b/src/bk-login/bklogin/component/bk_user/__init__.py @@ -8,9 +8,3 @@ 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.apps import AppConfig - - -class BkUserConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "bklogin.bkuser" diff --git a/src/bk-login/bklogin/component/bk_user/api.py b/src/bk-login/bklogin/component/bk_user/api.py new file mode 100644 index 000000000..dc3ded776 --- /dev/null +++ b/src/bk-login/bklogin/component/bk_user/api.py @@ -0,0 +1,111 @@ +# -*- 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 logging +from typing import Any, Callable, Dict, List +from urllib.parse import urljoin + +from django.conf import settings +from requests.auth import HTTPBasicAuth + +from bklogin.common.error_codes import error_codes +from bklogin.component.http import HttpStatusCode, http_get, http_post + +from .models import GlobalSetting, IdpDetailInfo, IdpInfo, TenantInfo, TenantUserDetailInfo, TenantUserInfo + +logger = logging.getLogger(__name__) + + +def _call_bk_user_api(http_func, url_path: str, allow_error_status_func: Callable[[HttpStatusCode], bool], **kwargs): + """调用用户管理接口""" + url = urljoin(settings.BK_USER_API_URL, url_path) + # 内部 API 认证 + kwargs.setdefault("auth", HTTPBasicAuth(settings.BK_USER_APP_CODE, settings.BK_USER_APP_SECRET)) + + status, resp_data = http_func(url, **kwargs) + if status.is_invalid: + logger.error( + "bk_user api failed, %s %s, kwargs: %s, error: %s", http_func.__name__, url, kwargs, resp_data["error"] + ) + raise error_codes.REMOTE_REQUEST_ERROR.f( + f"request bk_user api fail! Request=[{http_func.__name__} {url_path} error={resp_data['error']}" + ) + + # 对于预期内的状态码,这里不直接抛异常,直接返回 + if allow_error_status_func(status) or status.is_success: + return resp_data + + error = resp_data.get("error") + logger.error("bk_user api error, %s %s, data: %s, error: %s", http_func.__name__, url, kwargs, error) + raise error_codes.REMOTE_REQUEST_ERROR.f( + f"request bk_user api error! " f"Request=[{http_func.__name__} {url_path} Response[error={error}]" + ) + + +def _call_bk_user_api_20x(http_func, url_path: str, **kwargs): + """只允许20x的用户管理接口""" + return _call_bk_user_api(http_func, url_path, allow_error_status_func=lambda s: False, **kwargs)["data"] + + +def get_global_setting() -> GlobalSetting: + """获取全局配置""" + data = _call_bk_user_api_20x(http_get, "/api/v1/login/global-settings/") + return GlobalSetting(**data) + + +def list_tenant(tenant_ids: List[str] | None = None) -> List[TenantInfo]: + """查询租户列表,支持过滤""" + params = {} + if tenant_ids: + params["tenant_ids"] = ",".join(tenant_ids) + + data = _call_bk_user_api_20x(http_get, "/api/v1/login/tenants/", params=params) + return [TenantInfo(**i) for i in data] + + +def get_tenant(tenant_id: str) -> TenantInfo | None: + """通过租户 ID 获取租户信息""" + resp = _call_bk_user_api( + http_get, + f"/api/v1/login/tenants/{tenant_id}/", + allow_error_status_func=lambda s: s.is_not_found, + ) + if resp.get("error"): + return None + + return TenantInfo(**resp["data"]) + + +def list_idp(tenant_id: str) -> List[IdpInfo]: + """获取租户关联的认证源""" + data = _call_bk_user_api_20x(http_get, f"/api/v1/login/tenants/{tenant_id}/idps/") + return [IdpInfo(**i) for i in data] + + +def get_idp(idp_id: str) -> IdpDetailInfo: + """获取IDP信息""" + data = _call_bk_user_api_20x(http_get, f"/api/v1/login/idps/{idp_id}/") + return IdpDetailInfo(**data) + + +def list_matched_tencent_user(tenant_id: str, idp_id: str, idp_users: List[Dict[str, Any]]) -> List[TenantUserInfo]: + """根据IDP用户查询匹配的租户用户""" + data = _call_bk_user_api_20x( + http_post, + f"/api/v1/login/tenants/{tenant_id}/idps/{idp_id}/matched-tenant-users/", + json={"idp_users": idp_users}, + ) + return [TenantUserInfo(**i) for i in data] + + +def get_tenant_user(tenant_user_id: str) -> TenantUserDetailInfo: + """通过租户用户ID获取租户用户信息""" + data = _call_bk_user_api_20x(http_get, f"/api/v1/login/tenant-users/{tenant_user_id}/") + return TenantUserDetailInfo(**data) diff --git a/src/bk-login/bklogin/bkuser/data_models.py b/src/bk-login/bklogin/component/bk_user/constants.py similarity index 66% rename from src/bk-login/bklogin/bkuser/data_models.py rename to src/bk-login/bklogin/component/bk_user/constants.py index 82f25b583..424763d4b 100644 --- a/src/bk-login/bklogin/bkuser/data_models.py +++ b/src/bk-login/bklogin/component/bk_user/constants.py @@ -8,20 +8,12 @@ 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 typing import List +from blue_krill.data_types.enum import EnumField, StructuredEnum +from django.utils.translation import gettext_lazy as _ -from pydantic import BaseModel, TypeAdapter +class IdpStatus(str, StructuredEnum): + """认证源状态""" -class DataSourceMatchRule(BaseModel): - """认证源与数据源匹配规则""" - - # 认证源原始字段 - source_field: str - # 匹配的数据源 ID - data_source_id: int - # 匹配的数据源字段 - target_field: str - - -DataSourceMatchRuleList = TypeAdapter(List[DataSourceMatchRule]) + ENABLED = EnumField("enabled", label=_("启用")) + DISABLED = EnumField("disabled", label=_("未启用")) diff --git a/src/bk-login/bklogin/component/bk_user/models.py b/src/bk-login/bklogin/component/bk_user/models.py new file mode 100644 index 000000000..06de01c92 --- /dev/null +++ b/src/bk-login/bklogin/component/bk_user/models.py @@ -0,0 +1,62 @@ +# -*- 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 typing import Any, Dict + +from pydantic import BaseModel + +from .constants import IdpStatus + + +class GlobalSetting(BaseModel): + """全局配置""" + + tenant_visible: bool + + +class TenantInfo(BaseModel): + """租户信息""" + + id: str + name: str + logo: str = "" + + +class IdpPluginInfo(BaseModel): + id: str + name: str + + +class IdpInfo(BaseModel): + """认证源基本信息""" + + id: str + name: str + status: IdpStatus + plugin: IdpPluginInfo + + +class IdpDetailInfo(IdpInfo): + """认证源详情""" + + owner_tenant_id: str + plugin_config: Dict[str, Any] + + +class TenantUserInfo(BaseModel): + id: str + username: str + full_name: str + + +class TenantUserDetailInfo(TenantUserInfo): + tenant_id: str + language: str + time_zone: str diff --git a/src/bk-login/bklogin/component/http.py b/src/bk-login/bklogin/component/http.py new file mode 100644 index 000000000..6a9f38966 --- /dev/null +++ b/src/bk-login/bklogin/component/http.py @@ -0,0 +1,201 @@ +# -*- 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 logging +import time +from typing import Dict, Tuple +from urllib.parse import urlparse + +import requests +from requests.adapters import HTTPAdapter + +logger = logging.getLogger(__name__) +# 定义慢请求耗时,单位毫秒 +SLOW_REQUEST_LATENCY = 100 +# 连接池里连接最大数量 +REQUESTS_POOL_CONNECTIONS = 20 +# 连接池最大数量 +REQUESTS_POOL_MAXSIZE = 20 + +session = requests.Session() +adapter = HTTPAdapter(pool_connections=REQUESTS_POOL_CONNECTIONS, pool_maxsize=REQUESTS_POOL_MAXSIZE) +session.mount("https://", adapter) +session.mount("http://", adapter) + + +class HttpStatusCode: + def __init__(self, status_code: int): + self.code = status_code + + @property + def is_invalid(self) -> bool: + return self.code < 0 # noqa: PLR2004 + + @property + def is_success(self) -> bool: + return 200 <= self.code <= 299 # noqa: PLR2004 + + @property + def is_redirect(self) -> bool: + return 300 <= self.code <= 399 # noqa: PLR2004 + + @property + def is_client_error(self) -> bool: + return 400 <= self.code <= 499 # noqa: PLR2004 + + @property + def is_server_error(self) -> bool: + return 500 <= self.code <= 599 # noqa: PLR2004 + + @property + def is_unauthorized(self) -> bool: + return self.code == 401 # noqa: PLR2004 + + @property + def is_forbidden(self) -> bool: + return self.code == 403 # noqa: PLR2004 + + @property + def is_not_found(self) -> bool: + return self.code == 404 # noqa: PLR2004 + + +# 定义无效请求的Http状态码 +INVALID_REQUEST_STATUS_CODE = HttpStatusCode(status_code=-1) +# Request Body 非JSON格式 +INVALID_JSON_STATUS_CODE = HttpStatusCode(status_code=-2) + + +def _http_request(method: str, url: str, **kwargs) -> Tuple[HttpStatusCode, Dict]: + """ + 通用的Http接口请求,目前只支持JSON格式的Body数据返回,对于其他格式的返回,都认为是调用失败 + :param method: http请求method,大写,GET/POST/DELETE/PUT/PATCH/HEAD + :param url: http 请求URL + :param kwargs: 与Requests库一致的请求参数(params/json/data/headers/auth/verify/timeout等等) + :return http_status_code, response_body_data: + 只要是Response Body为json格式数据,都会返回有效的Http状态码,表示请求发送和接收成功,不关注业务逻辑 + - status_code < 0: 表示非预期请求,无效请求 + - status_code > 0: 表示正常请求且Response Body为JSON格式的状态码 + - data: status_code < 0时,包含error字段,描述非预期请求的原因, + status_code > 0时,则为经JSON解析后的Response Body数据 + """ + # 添加JSON Header + headers = kwargs.get("headers") or {} + headers.setdefault("Content-Type", "application/json") + kwargs["headers"] = headers + + # 默认30秒超时 + kwargs.setdefault("timeout", 30) + # 默认不校验证书 + kwargs.setdefault("verify", False) + + st = time.time() + + if method not in ["GET", "POST", "DELETE", "PUT", "PATCH", "HEAD"]: + return INVALID_REQUEST_STATUS_CODE, {"error": f"request method - {method} not supported"} + + try: + resp = session.request(method, url, **kwargs) + except requests.exceptions.RequestException as e: + logger.exception("http request error! %s %s, kwargs: %s", method, url, kwargs) + return INVALID_REQUEST_STATUS_CODE, {"error": str(e)} + + # 记录耗时,单位 ms + latency = int((time.time() - st) * 1000) + # 记录慢请求,默认大于 100ms 即为慢请求 + if latency > SLOW_REQUEST_LATENCY: + logger.warning("http slow request! method: %s, url: %s, latency: %dms", method, url, latency) + + # 只支持JSON格式的Body数据返回 + try: + return HttpStatusCode(resp.status_code), resp.json() + except Exception as e: + content = resp.content[:256] if resp.content else "" + logger.exception( + "http request fail, response.body not json!" + " %s %s, kwargs: %s, response.status_code: %s, response.body: %s", + method, + url, + str(kwargs), + resp.status_code, + content, + ) + return INVALID_JSON_STATUS_CODE, { + "error": ( + f"http response body not json, http status code is {resp.status_code}! " + f"{method} {urlparse(url).path}, response.body={content}, error:{e}" + ) + } + + +def _http_request_only_20x(method: str, url: str, **kwargs) -> Tuple[bool, Dict]: + """只支持20x且Response Body为JSON的请求,其他均为异常请求""" + status, resp_data = _http_request(method, url, **kwargs) + if status.is_success: + return True, resp_data + + # 无效请求 + if status.is_invalid: + return False, resp_data + + # 非 20x 请求 + logger.error( + "http response status code is %s, not 20x! %s %s, kwargs: %s, response.body: %s", + status.code, + method, + url, + str(kwargs), + resp_data, + ) + return False, { + "error": f"status_code is {status.code}, not 20x! {method} {urlparse(url).path}, response.body={resp_data}", + } + + +# 标准的 API 请求, JSON 响应 +def http_get(url, **kwargs): + return _http_request(method="GET", url=url, **kwargs) + + +def http_post(url, **kwargs): + return _http_request(method="POST", url=url, **kwargs) + + +def http_put(url, **kwargs): + return _http_request(method="PUT", url=url, **kwargs) + + +def http_patch(url, **kwargs): + return _http_request(method="PATCH", url=url, **kwargs) + + +def http_delete(url, **kwargs): + return _http_request(method="DELETE", url=url, **kwargs) + + +# 只允许 20x 的 API 请求,JSON响应 +def http_get_20x(url, **kwargs): + return _http_request_only_20x(method="GET", url=url, **kwargs) + + +def http_post_20x(url, **kwargs): + return _http_request_only_20x(method="POST", url=url, **kwargs) + + +def http_put_20x(url, **kwargs): + return _http_request_only_20x(method="PUT", url=url, **kwargs) + + +def http_patch_20x(url, **kwargs): + return _http_request_only_20x(method="PATCH", url=url, **kwargs) + + +def http_delete_20x(url, **kwargs): + return _http_request_only_20x(method="DELETE", url=url, **kwargs) diff --git a/src/bk-login/bklogin/settings.py b/src/bk-login/bklogin/settings.py index f174058c9..378b95046 100644 --- a/src/bk-login/bklogin/settings.py +++ b/src/bk-login/bklogin/settings.py @@ -40,7 +40,6 @@ "django.contrib.messages", "django.contrib.staticfiles", "bklogin.authentication", - "bklogin.bkuser", ] MIDDLEWARE = [ @@ -117,8 +116,7 @@ BK_APP_SECRET = env.str("BK_APP_SECRET") # Django SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = BK_APP_SECRET -# BKToken等DB加密所需要的Key(32位随机字符串) -ENCRYPT_SECRET_KEY = env.str("ENCRYPT_SECRET_KEY") + # 蓝鲸数据库内容加密私钥 # 使用 `from cryptography.fernet import Fernet; Fernet.generate_key()` 生成随机秘钥 # 详情查看:https://cryptography.io/en/latest/fernet/ @@ -155,6 +153,11 @@ # 无操作的失效期,默认2个小时. 长时间无操作, BkToken自动过期(Note: 调整为) BK_TOKEN_INACTIVE_AGE = env.int("BK_TOKEN_INACTIVE_AGE", default=60 * 60 * 2) +# 用户管理相关信息 +BK_USER_APP_CODE = env.str("BK_USER_APP_CODE", default="bk_user") +BK_USER_APP_SECRET = env.str("BK_USER_APP_SECRET") +BK_USER_API_URL = os.environ.get("BK_USER_API_URL", "http://bk-user") + # ------------------------------------------ 日志配置 ------------------------------------------ # 日志配置 @@ -233,6 +236,3 @@ }, }, } - -# ------------------------------------------ FIXME: 临时配置 ------------------------------------------ -TENANT_VISIBLE = env.bool("TENANT_VISIBLE", default=False) diff --git a/src/bk-login/pages/src/router/index.ts b/src/bk-login/pages/src/router/index.ts index ee0fbb997..480eb2723 100644 --- a/src/bk-login/pages/src/router/index.ts +++ b/src/bk-login/pages/src/router/index.ts @@ -15,11 +15,11 @@ export default createRouter({ component: Home, }, { - path: '/index', + path: '/plain/', component: Home, }, { - path: '/users', + path: '/page/users/', component: User, }, ], diff --git a/src/bk-login/pages/src/views/components/password.vue b/src/bk-login/pages/src/views/components/password.vue index 9cd6a4cf7..1b771e718 100644 --- a/src/bk-login/pages/src/views/components/password.vue +++ b/src/bk-login/pages/src/views/components/password.vue @@ -65,7 +65,7 @@ const handleLogin = () => { }, ).then(() => { loading.value = false; - router.push('/users'); + router.push('/page/users/'); }) .catch((error) => { errorMessage.value = error?.message || '登录失败'; diff --git a/src/bk-login/poetry.lock b/src/bk-login/poetry.lock index e4c92216b..879908390 100644 --- a/src/bk-login/poetry.lock +++ b/src/bk-login/poetry.lock @@ -76,57 +76,6 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" -[[package]] -name = "black" -version = "23.9.1" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.8" -files = [ - {file = "black-23.9.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301"}, - {file = "black-23.9.1-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100"}, - {file = "black-23.9.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71"}, - {file = "black-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7"}, - {file = "black-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80"}, - {file = "black-23.9.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f"}, - {file = "black-23.9.1-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe"}, - {file = "black-23.9.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186"}, - {file = "black-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f"}, - {file = "black-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300"}, - {file = "black-23.9.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948"}, - {file = "black-23.9.1-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855"}, - {file = "black-23.9.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204"}, - {file = "black-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377"}, - {file = "black-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573"}, - {file = "black-23.9.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c"}, - {file = "black-23.9.1-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325"}, - {file = "black-23.9.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393"}, - {file = "black-23.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9"}, - {file = "black-23.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f"}, - {file = "black-23.9.1-py3-none-any.whl", hash = "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9"}, - {file = "black-23.9.1.tar.gz", hash = "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[package.source] -type = "legacy" -url = "https://mirrors.tencent.com/pypi/simple" -reference = "tencent" - [[package]] name = "blue-krill" version = "2.0.2" @@ -1547,42 +1496,6 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" -[[package]] -name = "pathspec" -version = "0.11.2" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, - {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, -] - -[package.source] -type = "legacy" -url = "https://mirrors.tencent.com/pypi/simple" -reference = "tencent" - -[[package]] -name = "platformdirs" -version = "3.10.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = false -python-versions = ">=3.7" -files = [ - {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, - {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, -] - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] - -[package.source] -type = "legacy" -url = "https://mirrors.tencent.com/pypi/simple" -reference = "tencent" - [[package]] name = "pluggy" version = "1.3.0" @@ -2007,28 +1920,28 @@ reference = "tencent" [[package]] name = "ruff" -version = "0.0.290" -description = "An extremely fast Python linter, written in Rust." +version = "0.1.4" +description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.0.290-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:0e2b09ac4213b11a3520221083866a5816616f3ae9da123037b8ab275066fbac"}, - {file = "ruff-0.0.290-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:4ca6285aa77b3d966be32c9a3cd531655b3d4a0171e1f9bf26d66d0372186767"}, - {file = "ruff-0.0.290-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35e3550d1d9f2157b0fcc77670f7bb59154f223bff281766e61bdd1dd854e0c5"}, - {file = "ruff-0.0.290-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d748c8bd97874f5751aed73e8dde379ce32d16338123d07c18b25c9a2796574a"}, - {file = "ruff-0.0.290-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:982af5ec67cecd099e2ef5e238650407fb40d56304910102d054c109f390bf3c"}, - {file = "ruff-0.0.290-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bbd37352cea4ee007c48a44c9bc45a21f7ba70a57edfe46842e346651e2b995a"}, - {file = "ruff-0.0.290-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d9be6351b7889462912e0b8185a260c0219c35dfd920fb490c7f256f1d8313e"}, - {file = "ruff-0.0.290-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75cdc7fe32dcf33b7cec306707552dda54632ac29402775b9e212a3c16aad5e6"}, - {file = "ruff-0.0.290-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb07f37f7aecdbbc91d759c0c09870ce0fb3eed4025eebedf9c4b98c69abd527"}, - {file = "ruff-0.0.290-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2ab41bc0ba359d3f715fc7b705bdeef19c0461351306b70a4e247f836b9350ed"}, - {file = "ruff-0.0.290-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:150bf8050214cea5b990945b66433bf9a5e0cef395c9bc0f50569e7de7540c86"}, - {file = "ruff-0.0.290-py3-none-musllinux_1_2_i686.whl", hash = "sha256:75386ebc15fe5467248c039f5bf6a0cfe7bfc619ffbb8cd62406cd8811815fca"}, - {file = "ruff-0.0.290-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ac93eadf07bc4ab4c48d8bb4e427bf0f58f3a9c578862eb85d99d704669f5da0"}, - {file = "ruff-0.0.290-py3-none-win32.whl", hash = "sha256:461fbd1fb9ca806d4e3d5c745a30e185f7cf3ca77293cdc17abb2f2a990ad3f7"}, - {file = "ruff-0.0.290-py3-none-win_amd64.whl", hash = "sha256:f1f49f5ec967fd5778813780b12a5650ab0ebcb9ddcca28d642c689b36920796"}, - {file = "ruff-0.0.290-py3-none-win_arm64.whl", hash = "sha256:ae5a92dfbdf1f0c689433c223f8dac0782c2b2584bd502dfdbc76475669f1ba1"}, - {file = "ruff-0.0.290.tar.gz", hash = "sha256:949fecbc5467bb11b8db810a7fa53c7e02633856ee6bd1302b2f43adcd71b88d"}, + {file = "ruff-0.1.4-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:864958706b669cce31d629902175138ad8a069d99ca53514611521f532d91495"}, + {file = "ruff-0.1.4-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:9fdd61883bb34317c788af87f4cd75dfee3a73f5ded714b77ba928e418d6e39e"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4eaca8c9cc39aa7f0f0d7b8fe24ecb51232d1bb620fc4441a61161be4a17539"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a9a1301dc43cbf633fb603242bccd0aaa34834750a14a4c1817e2e5c8d60de17"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78e8db8ab6f100f02e28b3d713270c857d370b8d61871d5c7d1702ae411df683"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:80fea754eaae06335784b8ea053d6eb8e9aac75359ebddd6fee0858e87c8d510"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6bc02a480d4bfffd163a723698da15d1a9aec2fced4c06f2a753f87f4ce6969c"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9862811b403063765b03e716dac0fda8fdbe78b675cd947ed5873506448acea4"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58826efb8b3efbb59bb306f4b19640b7e366967a31c049d49311d9eb3a4c60cb"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fdfd453fc91d9d86d6aaa33b1bafa69d114cf7421057868f0b79104079d3e66e"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e8791482d508bd0b36c76481ad3117987301b86072158bdb69d796503e1c84a8"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01206e361021426e3c1b7fba06ddcb20dbc5037d64f6841e5f2b21084dc51800"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:645591a613a42cb7e5c2b667cbefd3877b21e0252b59272ba7212c3d35a5819f"}, + {file = "ruff-0.1.4-py3-none-win32.whl", hash = "sha256:99908ca2b3b85bffe7e1414275d004917d1e0dfc99d497ccd2ecd19ad115fd0d"}, + {file = "ruff-0.1.4-py3-none-win_amd64.whl", hash = "sha256:1dfd6bf8f6ad0a4ac99333f437e0ec168989adc5d837ecd38ddb2cc4a2e3db8a"}, + {file = "ruff-0.1.4-py3-none-win_arm64.whl", hash = "sha256:d98ae9ebf56444e18a3e3652b3383204748f73e247dea6caaf8b52d37e6b32da"}, + {file = "ruff-0.1.4.tar.gz", hash = "sha256:21520ecca4cc555162068d87c747b8f95e1e95f8ecfcbbe59e8dd00710586315"}, ] [package.source] @@ -2583,4 +2496,4 @@ reference = "tencent" [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.11" -content-hash = "00d31f96754e95fe5dcbe2a7dd84a36f3f0e2f4d6481539995cc6ce5e1a9f68d" +content-hash = "2e9b6e0ba3df54edfc368ef362bd9a5c1ad1370cb383636fc6eb5d145e4ff28e" diff --git a/src/bk-login/pyproject.toml b/src/bk-login/pyproject.toml index a11b4a0df..30ff22b09 100644 --- a/src/bk-login/pyproject.toml +++ b/src/bk-login/pyproject.toml @@ -35,22 +35,13 @@ pydantic = "2.3.0" blue-krill = "2.0.2" [tool.poetry.group.dev.dependencies] -ruff = "^0.0.290" -black = "^23.9.1" +ruff = "^0.1.4" mypy = "^1.5.1" types-requests = "^2.31.0.2" pytest = "^7.4.2" pytest-django = "^4.5.2" import-linter = "^1.11.1" -[tool.black] -line-length = 119 -force-exclude = ''' -/( - migrations -)/ -''' - [tool.mypy] ignore_missing_imports = true show_error_codes = true diff --git a/src/bk-user/bkuser/apis/login/authentications.py b/src/bk-user/bkuser/apis/login/authentications.py new file mode 100644 index 000000000..7b1088e3e --- /dev/null +++ b/src/bk-user/bkuser/apis/login/authentications.py @@ -0,0 +1,31 @@ +# -*- 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.conf import settings +from django.contrib.auth import get_user_model +from rest_framework.authentication import BasicAuthentication +from rest_framework.exceptions import AuthenticationFailed + + +class BkUserAppAuthentication(BasicAuthentication): + """ + 通过BKUser的AppCode/AppSecret Basic认证 + 主要用于项目的login服务调用user服务的服务间接口认证 + """ + + def authenticate_credentials(self, userid, password, request=None): + if userid != settings.BK_APP_CODE or password != settings.BK_APP_SECRET: + raise AuthenticationFailed("Invalid app_code/app_secret.") + + user_model = get_user_model() + user, _ = user_model.objects.get_or_create( + username="admin", defaults={"is_active": True, "is_staff": False, "is_superuser": False} + ) + return user, None diff --git a/src/bk-user/bkuser/apis/login/mixins.py b/src/bk-user/bkuser/apis/login/mixins.py new file mode 100644 index 000000000..0f0f29b2e --- /dev/null +++ b/src/bk-user/bkuser/apis/login/mixins.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 rest_framework.permissions import IsAuthenticated + +from .authentications import BkUserAppAuthentication + + +class LoginApiAccessControlMixin: + """ + 登录API的访问权限控制 + Note: 继承时,必须添加到第一个父类,否则可能会被其他父类的覆盖 + """ + + authentication_classes = [BkUserAppAuthentication] + permission_classes = [IsAuthenticated] diff --git a/src/bk-user/bkuser/apis/login/serializers.py b/src/bk-user/bkuser/apis/login/serializers.py new file mode 100644 index 000000000..19891d21a --- /dev/null +++ b/src/bk-user/bkuser/apis/login/serializers.py @@ -0,0 +1,94 @@ +# -*- 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 typing import List + +from rest_framework import serializers + +from bkuser.apps.idp.constants import IdpStatus +from bkuser.biz.validators import validate_data_source_user_username + + +class LocalUserCredentialAuthenticateInputSLZ(serializers.Serializer): + data_source_ids = serializers.ListField(help_text="指定查询的数据源ID列表", child=serializers.IntegerField()) + username = serializers.CharField(help_text="用户名", validators=[validate_data_source_user_username]) + password = serializers.CharField(help_text="密码") + + +class LocalUserCredentialAuthenticateOutputSLZ(serializers.Serializer): + data_source_id = serializers.IntegerField(help_text="数据源ID") + id = serializers.IntegerField(help_text="用户ID") + username = serializers.CharField(help_text="用户名") + + +class GlobalSettingRetrieveOutputSLZ(serializers.Serializer): + tenant_visible = serializers.BooleanField(help_text="租户可见性") + + +class TenantListInputSLZ(serializers.Serializer): + tenant_ids = serializers.CharField(help_text="指定查询的租户, 多个使用英文逗号分隔", required=False, default="") + + def validate_tenant_ids(self, value: str) -> List[str]: + """将使用英文逗号分隔的字符串转换为列表""" + if not value: + return [] + + return [i for i in value.split(",") if i] + + +class TenantListOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="租户 ID") + name = serializers.CharField(help_text="租户名称") + logo = serializers.CharField(help_text="租户 Logo") + + +class TenantRetrieveOutputSLZ(TenantListOutputSLZ): + ... + + +class IdpPluginOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="认证源插件 ID") + name = serializers.CharField(help_text="认证源插件名称") + + +class IdpListOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="认证源 ID") + name = serializers.CharField(help_text="认证源名称") + status = serializers.ChoiceField(help_text="状态", choices=IdpStatus.get_choices()) + plugin = IdpPluginOutputSLZ(help_text="认证源插件") + + +class IdpRetrieveOutputSLZ(IdpListOutputSLZ): + owner_tenant_id = serializers.CharField(help_text="归属的租户 ID") + plugin_config = serializers.JSONField(help_text="认证源插件配置") + + +class TenantUserMatchInputSLZ(serializers.Serializer): + idp_users = serializers.ListField( + help_text="认证源获取到的用户,支持多个", + child=serializers.JSONField(help_text="用户信息"), + min_length=1, + ) + + +class TenantUserMatchOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="用户 ID") + username = serializers.ReadOnlyField(help_text="用户名", source="data_source_user.username") + full_name = serializers.ReadOnlyField(help_text="用户姓名", source="data_source_user.full_name") + + +class TenantUserRetrieveOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="用户 ID") + username = serializers.ReadOnlyField(help_text="用户名", source="data_source_user.username") + full_name = serializers.ReadOnlyField(help_text="用户姓名", source="data_source_user.full_name") + language = serializers.CharField(help_text="语言") + time_zone = serializers.CharField(help_text="时区") + + tenant_id = serializers.CharField(help_text="用户所在租户 ID") diff --git a/src/bk-user/bkuser/apis/login/urls.py b/src/bk-user/bkuser/apis/login/urls.py new file mode 100644 index 000000000..5668241e9 --- /dev/null +++ b/src/bk-user/bkuser/apis/login/urls.py @@ -0,0 +1,40 @@ +# -*- 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.urls import path + +from . import views + +urlpatterns = [ + # 本地用户身份凭据校验 + path( + "local-user-credentials/authenticate/", + views.LocalUserCredentialAuthenticateApi.as_view(), + name="login.local_user_credentials.authenticate", + ), + # 全局配置 + path("global-settings/", views.GlobalSettingRetrieveApi.as_view(), name="login.global_setting.retrieve"), + # 租户列表 + path("tenants/", views.TenantListApi.as_view(), name="login.tenant.list"), + # 单个租户 + path("tenants//", views.TenantRetrieveApi.as_view(), name="login.tenant.retrieve"), + # 获取租户的认证源列表 + path("tenants//idps/", views.IdpListApi.as_view(), name="login.idp.list"), + # 单个认证源 + path("idps//", views.IdpRetrieveApi.as_view(), name="login.idp.retrieve"), + # 认证源匹配用户 + path( + "tenants//idps//matched-tenant-users/", + views.TenantUserMatchApi.as_view(), + name="login.matched_tenant_user.match", + ), + # 查询租户用户 + path("tenant-users//", views.TenantUserRetrieveApi.as_view(), name="login.tenant_user.retrieve"), +] diff --git a/src/bk-user/bkuser/apis/login/views.py b/src/bk-user/bkuser/apis/login/views.py new file mode 100644 index 000000000..95b238764 --- /dev/null +++ b/src/bk-user/bkuser/apis/login/views.py @@ -0,0 +1,164 @@ +# -*- 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 operator +from functools import reduce + +from django.utils.translation import gettext_lazy as _ +from rest_framework import generics +from rest_framework.response import Response + +from bkuser.apps.data_source.models import DataSourceUser, LocalDataSourceIdentityInfo +from bkuser.apps.idp.data_models import DataSourceMatchRuleList, convert_match_rules_to_queryset_filter +from bkuser.apps.idp.models import Idp +from bkuser.apps.tenant.models import Tenant, TenantUser +from bkuser.common.error_codes import error_codes + +from .mixins import LoginApiAccessControlMixin +from .serializers import ( + GlobalSettingRetrieveOutputSLZ, + IdpListOutputSLZ, + IdpRetrieveOutputSLZ, + LocalUserCredentialAuthenticateInputSLZ, + LocalUserCredentialAuthenticateOutputSLZ, + TenantListInputSLZ, + TenantListOutputSLZ, + TenantRetrieveOutputSLZ, + TenantUserMatchInputSLZ, + TenantUserMatchOutputSLZ, + TenantUserRetrieveOutputSLZ, +) + + +class LocalUserCredentialAuthenticateApi(LoginApiAccessControlMixin, generics.CreateAPIView): + """本地数据源用户的凭据认证""" + + def post(self, request, *args, **kwargs): + slz = LocalUserCredentialAuthenticateInputSLZ(data=request.data) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # TODO: 密码错误次数检测&锁定,如何实现? + # 不同数据源配置,key=(data_source_id, username),错误次数如何计算?如何锁定? + + # 由于密码是Hash并加盐, 无法直接查询DB匹配,只能一个个遍历匹配 + users = LocalDataSourceIdentityInfo.objects.filter( + data_source_id__in=data["data_source_ids"], username=data["username"] + ) + matched_users = [u for u in users if u.check_password(data["password"])] + + # 无任何匹配 + if not matched_users: + raise error_codes.USERNAME_OR_PASSWORD_WRONG_ERROR + + # Q: 为什么这里不对用户状态、数据源状态、“是否首次登录检测并强制修改” 等进行检测呢? + # A: [单一职责] 这里只对用户的凭证进行认证,并不是与登录绑定 + # 多租户下,认证后的数据源用户, + # 还需要根据匹配规则和租户信息等最终匹配到租户用户(即对外的蓝鲸用户),这些是在登录流程里的 + + # FIXME (nan): 密码过期检测,过期需要返回重置URI + + return Response(LocalUserCredentialAuthenticateOutputSLZ(instance=matched_users, many=True).data) + + +class GlobalSettingRetrieveApi(LoginApiAccessControlMixin, generics.RetrieveAPIView): + def get(self, request, *args, **kwargs): + # TODO: 待实现全局配置管理功能后调整 + return Response(GlobalSettingRetrieveOutputSLZ(instance={"tenant_visible": False}).data) + + +class TenantListApi(LoginApiAccessControlMixin, generics.ListAPIView): + pagination_class = None + serializer_class = TenantListOutputSLZ + + def get_queryset(self): + slz = TenantListInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + queryset = Tenant.objects.all() + if data["tenant_ids"]: + queryset = queryset.filter(id__in=data["tenant_ids"]) + + return queryset + + +class TenantRetrieveApi(LoginApiAccessControlMixin, generics.RetrieveAPIView): + serializer_class = TenantRetrieveOutputSLZ + queryset = Tenant.objects.all() + lookup_field = "id" + + +class IdpListApi(generics.ListAPIView, LoginApiAccessControlMixin): + pagination_class = None + serializer_class = IdpListOutputSLZ + + def get_queryset(self): + return Idp.objects.filter(owner_tenant_id=self.kwargs["tenant_id"]).select_related("plugin") + + +class IdpRetrieveApi(LoginApiAccessControlMixin, generics.RetrieveAPIView): + serializer_class = IdpRetrieveOutputSLZ + queryset = Idp.objects.all() + lookup_field = "id" + + +class TenantUserMatchApi(LoginApiAccessControlMixin, generics.CreateAPIView): + """通过IDP的用户信息匹配到蓝鲸用户""" + + def post(self, request, *args, **kwargs): + slz = TenantUserMatchInputSLZ(data=request.data) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 登录的租户 + tenant_id = kwargs["tenant_id"] + tenant = Tenant.objects.filter(id=tenant_id).first() + if not tenant: + raise error_codes.OBJECT_NOT_FOUND.f(_("租户 {} 不存在").format(tenant_id)) + + # 认证源 + idp_id = kwargs["idp_id"] + idp = Idp.objects.filter(owner_tenant_id=tenant_id, id=idp_id).first() + if not idp: + raise error_codes.OBJECT_NOT_FOUND.f(_("认证源 {} 不存在").format(idp_id)) + + # FIXME: 查询是绑定匹配还是直接匹配, + # 一般社会化登录都得通过绑定匹配方式,比如QQ,用户得先绑定后才能使用QQ登录 + # 直接匹配,一般是企业身份登录方式, + # 比如企业内部SAML2.0登录,认证后获取到的用户字段,能直接与数据源里的用户数据字段匹配 + # 认证源与数据源的匹配规则 + data_source_match_rules = DataSourceMatchRuleList.validate_python(idp.data_source_match_rules) + # 将规则转换为Django Queryset 过滤条件, 不同用户之间过滤逻辑是OR + conditions = [ + condition + for userinfo in data["idp_users"] + if (condition := convert_match_rules_to_queryset_filter(data_source_match_rules, userinfo)) + ] + + # 查询数据源用户 + data_source_user_ids = ( + DataSourceUser.objects.filter(reduce(operator.or_, conditions)).values_list("id", flat=True) + if conditions + else [] + ) + + # 查询租户用户 + tenant_users = TenantUser.objects.filter( + tenant_id=tenant_id, data_source_user_id__in=list(data_source_user_ids) + ).select_related("data_source_user") + + return Response(TenantUserMatchOutputSLZ(instance=tenant_users, many=True).data) + + +class TenantUserRetrieveApi(LoginApiAccessControlMixin, generics.RetrieveAPIView): + serializer_class = TenantUserRetrieveOutputSLZ + queryset = TenantUser.objects.all() + lookup_field = "id" diff --git a/src/bk-user/bkuser/apis/web/tenant/serializers.py b/src/bk-user/bkuser/apis/web/tenant/serializers.py index 0e00b8183..9a9e42a72 100644 --- a/src/bk-user/bkuser/apis/web/tenant/serializers.py +++ b/src/bk-user/bkuser/apis/web/tenant/serializers.py @@ -95,7 +95,9 @@ def validate_id(self, id: str) -> str: raise ValidationError(_("租户 ID {} 已被使用").format(id)) if not re.fullmatch(TENANT_ID_REGEX, id): - raise ValidationError(_("{} 不符合 租户ID 的命名规范: 由3-32位字母、数字、连接符(-)字符组成,以字母开头").format(id)) # noqa: E501 + raise ValidationError( + _("{} 不符合 租户ID 的命名规范: 由3-32位字母、数字、连接符(-)字符组成,以字母开头").format(id) + ) # noqa: E501 return id diff --git a/src/bk-user/bkuser/apps/data_source/initializers.py b/src/bk-user/bkuser/apps/data_source/initializers.py index de0717922..e0e5a9eb7 100644 --- a/src/bk-user/bkuser/apps/data_source/initializers.py +++ b/src/bk-user/bkuser/apps/data_source/initializers.py @@ -56,7 +56,8 @@ def __init__(self, data_source: DataSource): return self.password_provider = PasswordProvider( - self.plugin_cfg.password_rule, self.plugin_cfg.password_initial # type: ignore + self.plugin_cfg.password_rule, # type: ignore + self.plugin_cfg.password_initial, # type: ignore ) def initialize(self, users: Optional[List[DataSourceUser]] = None) -> Tuple[List[DataSourceUser], Dict[int, str]]: diff --git a/src/bk-user/bkuser/apps/data_source/migrations/0009_datasourcesensitiveinfo.py b/src/bk-user/bkuser/apps/data_source/migrations/0010_datasourcesensitiveinfo.py similarity index 93% rename from src/bk-user/bkuser/apps/data_source/migrations/0009_datasourcesensitiveinfo.py rename to src/bk-user/bkuser/apps/data_source/migrations/0010_datasourcesensitiveinfo.py index 7de371752..586f9b6ee 100644 --- a/src/bk-user/bkuser/apps/data_source/migrations/0009_datasourcesensitiveinfo.py +++ b/src/bk-user/bkuser/apps/data_source/migrations/0010_datasourcesensitiveinfo.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ - ('data_source', '0008_auto_20231024_0940'), + ('data_source', '0009_alter_localdatasourceidentityinfo_password'), ] operations = [ diff --git a/src/bk-user/bkuser/apps/data_source/models.py b/src/bk-user/bkuser/apps/data_source/models.py index 862642350..926e9bde6 100644 --- a/src/bk-user/bkuser/apps/data_source/models.py +++ b/src/bk-user/bkuser/apps/data_source/models.py @@ -15,6 +15,7 @@ from bkuser.apps.data_source.constants import DataSourceStatus from bkuser.common.constants import SENSITIVE_MASK +from bkuser.common.hashers.shortcuts import check_password from bkuser.common.models import AuditedModel, TimestampedModel from bkuser.plugins.base import get_plugin_cfg_cls from bkuser.plugins.constants import DataSourcePluginEnum @@ -158,6 +159,9 @@ class Meta: ("username", "data_source"), ] + def check_password(self, raw_password: str) -> bool: + return check_password(raw_password, self.password) + class DataSourceDepartment(TimestampedModel): """ diff --git a/src/bk-user/bkuser/apps/idp/data_models.py b/src/bk-user/bkuser/apps/idp/data_models.py index d128e7bd5..f010352bc 100644 --- a/src/bk-user/bkuser/apps/idp/data_models.py +++ b/src/bk-user/bkuser/apps/idp/data_models.py @@ -8,27 +8,97 @@ 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 typing import List +import operator +from functools import reduce +from typing import Any, Dict, List +from django.db.models import Q from pydantic import BaseModel, TypeAdapter from .constants import AllowBindScopeObjectType -class DataSourceMatchRule(BaseModel): - """认证源与数据源匹配规则""" +class FieldCompareRule(BaseModel): + """ + 数据源与认证源字段比较规则 + """ + # Note: 暂时只支持equal,后续可以支持not_equal等其他operator # 认证源原始字段 source_field: str - # 匹配的数据源 ID - data_source_id: int # 匹配的数据源字段 target_field: str +class DataSourceMatchRule(BaseModel): + """认证源与数据源匹配规则""" + + # 匹配的数据源 ID + data_source_id: int + # 字段匹配规则 + field_compare_rules: List[FieldCompareRule] + + def convert_to_queryset_filter(self, source_data: Dict[str, Any]) -> Q | None: + """ + 将匹配规则转换为Django QuerySet过滤条件 + :param source_data: 认证源数据 + :return Django Queryset Q 查询表达式 + example: + self: + { + "data_source_id": 1, + "field_compare_rules": [ + {"source_field": "user_id", "target_field": "username", "operator": "equal"}, + {"source_field": "telephone", "target_field": "phone", "operator": "equal"}, + ] + } + source_data: {"user_id": "zhangsan", "telephone": "12345678901", "company_email": "test@example.com"} + return: (Q(data_source_id=1) & Q(username="zhangsan") & Q(phone="12345678901")) + """ + conditions = [{"data_source_id": self.data_source_id}] + # 无字段比较,相当于无法匹配,直接返回 + if not self.field_compare_rules: + return None + + # 每个认证源字段与数据源字段的比较规则 + for rule in self.field_compare_rules: + # 数据里没有规则需要比较的字段,则一定无法匹配,所以无需继续 + if rule.source_field not in source_data: + return None + + conditions.append( + { + # Note: 目前仅仅是equal的比较操作符,所以这里暂时简单处理, + # 后续支持其他操作符再抽象出Converter来处理 + rule.target_field: source_data[rule.source_field], + } + ) + + return reduce(operator.and_, [Q(**c) for c in conditions]) + + DataSourceMatchRuleList = TypeAdapter(List[DataSourceMatchRule]) +def convert_match_rules_to_queryset_filter( + match_rules: List[DataSourceMatchRule], source_data: Dict[str, Any] +) -> Q | None: + """ + 将规则列表转换为Queryset查询条件 + 不同匹配规则之间的关系是OR, 匹配规则里不同字段的关系是AND + """ + q_list = [q for rule in match_rules if (q := rule.convert_to_queryset_filter(source_data))] + return reduce(operator.or_, q_list) if q_list else None + + +def gen_data_source_match_rule_of_local(data_source_id: int) -> DataSourceMatchRule: + """生成本地账密认证源的匹配规则""" + return DataSourceMatchRule( + data_source_id=data_source_id, + field_compare_rules=[FieldCompareRule(source_field="id", target_field="id")], + ) + + class AllowBindScope(BaseModel): """允许关联社会化认证源的租户组织架构范围""" diff --git a/src/bk-user/bkuser/apps/idp/handlers.py b/src/bk-user/bkuser/apps/idp/handlers.py index 09eb0afa5..10e51fd6a 100644 --- a/src/bk-user/bkuser/apps/idp/handlers.py +++ b/src/bk-user/bkuser/apps/idp/handlers.py @@ -8,6 +8,7 @@ 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.db import transaction from django.db.models.signals import post_save from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ @@ -19,46 +20,52 @@ from bkuser.plugins.local.models import LocalDataSourcePluginConfig from .constants import IdpStatus -from .data_models import DataSourceMatchRule, DataSourceMatchRuleList +from .data_models import DataSourceMatchRuleList, gen_data_source_match_rule_of_local from .models import Idp, IdpPlugin @receiver(post_save, sender=DataSource) def update_local_idp_of_tenant(sender, instance: DataSource, **kwargs): + transaction.on_commit(lambda: _update_local_idp_of_tenant(instance)) + + +def _update_local_idp_of_tenant(data_source: DataSource): """ 更新租户的本地账密登录认证源 对于每个租户,如果有本地数据源,则必须有本地账密认证源 该函数主要是根据本地数据源(status和enable_account_password_login)的变化更新租户的本地账密认证源配置和状态 """ # 非本地数据源不需要默认认证源 - if not instance.is_local: + if not data_source.is_local: return # 获取本地账密认证的插件 plugin = IdpPlugin.objects.get(id=BuiltinIdpPluginEnum.LOCAL) # 获取租户下的本地账密认证源 idp, __ = Idp.objects.get_or_create( - owner_tenant_id=instance.owner_tenant_id, plugin=plugin, defaults={"name": _("本地账密")} + owner_tenant_id=data_source.owner_tenant_id, plugin=plugin, defaults={"name": _("本地账密")} ) # 判断数据源 status和enable_account_password_login ,确定是否使用账密登录 - plugin_cfg = LocalDataSourcePluginConfig(**instance.plugin_config) - enable_login = bool(instance.status == DataSourceStatus.ENABLED and plugin_cfg.enable_account_password_login) + plugin_cfg = data_source.get_plugin_cfg() + assert isinstance(plugin_cfg, LocalDataSourcePluginConfig) + + enable_login = bool(data_source.status == DataSourceStatus.ENABLED and plugin_cfg.enable_account_password_login) # 根据数据源是否使用账密登录,修改认证源配置 idp_plugin_cfg = LocalIdpPluginConfig(**idp.plugin_config) data_source_match_rules = DataSourceMatchRuleList.validate_python(idp.data_source_match_rules) # 对于启用登录,则需要添加进配置 - if enable_login and instance.id not in idp_plugin_cfg.data_source_ids: - idp_plugin_cfg.data_source_ids.append(instance.id) + if enable_login and data_source.id not in idp_plugin_cfg.data_source_ids: + idp_plugin_cfg.data_source_ids.append(data_source.id) data_source_match_rules.append( - DataSourceMatchRule(source_field="id", data_source_id=instance.id, target_field="id") + gen_data_source_match_rule_of_local(data_source.id), ) # 对于不启用登录,则需要删除配置 - if not enable_login and instance.id in idp_plugin_cfg.data_source_ids: - idp_plugin_cfg.data_source_ids = [i for i in idp_plugin_cfg.data_source_ids if i != instance.id] - data_source_match_rules = [i for i in data_source_match_rules if i.data_source_id != instance.id] + if not enable_login and data_source.id in idp_plugin_cfg.data_source_ids: + idp_plugin_cfg.data_source_ids = [i for i in idp_plugin_cfg.data_source_ids if i != data_source.id] + data_source_match_rules = [i for i in data_source_match_rules if i.data_source_id != data_source.id] # 保存 idp.plugin_config = idp_plugin_cfg.model_dump() diff --git a/src/bk-user/bkuser/biz/validators.py b/src/bk-user/bkuser/biz/validators.py index d61f25cb4..bd6ce13ed 100644 --- a/src/bk-user/bkuser/biz/validators.py +++ b/src/bk-user/bkuser/biz/validators.py @@ -22,5 +22,7 @@ def validate_data_source_user_username(value): if not re.fullmatch(DATA_SOURCE_USERNAME_REGEX, value): raise ValidationError( - _("{} 不符合 用户名 的命名规范: 由3-32位字母、数字、下划线(_)、点(.)、连接符(-)字符组成,以字母或数字开头及结尾").format(value), # noqa: E501 + _( + "{} 不符合 用户名 的命名规范: 由3-32位字母、数字、下划线(_)、点(.)、连接符(-)字符组成,以字母或数字开头及结尾" # noqa: E501 + ).format(value), ) diff --git a/src/bk-user/bkuser/common/error_codes.py b/src/bk-user/bkuser/common/error_codes.py index 0743f8ee2..6770abeb0 100644 --- a/src/bk-user/bkuser/common/error_codes.py +++ b/src/bk-user/bkuser/common/error_codes.py @@ -69,6 +69,9 @@ class ErrorCodes: # 调用外部系统API REMOTE_REQUEST_ERROR = ErrorCode(_("调用外部系统API异常")) + # 用户账密 + USERNAME_OR_PASSWORD_WRONG_ERROR = ErrorCode(_("用户名或密码错误")) + # 数据源插件 DATA_SOURCE_PLUGIN_NOT_DEFAULT_CONFIG = ErrorCode(_("当前数据源插件未提供默认配置")) diff --git a/src/bk-user/bkuser/component/login.py b/src/bk-user/bkuser/component/login.py index dff600e4e..58329bba7 100644 --- a/src/bk-user/bkuser/component/login.py +++ b/src/bk-user/bkuser/component/login.py @@ -34,7 +34,7 @@ def _call_login_api(http_func, url_path, **kwargs): } ) - url = urljoin(settings.BK_LOGIN_URL, url_path) + url = urljoin(settings.BK_LOGIN_API_URL, url_path) ok, resp_data = http_func(url, **kwargs) if not ok: diff --git a/src/bk-user/bkuser/plugins/general/http.py b/src/bk-user/bkuser/plugins/general/http.py index 82285e908..a52899e81 100644 --- a/src/bk-user/bkuser/plugins/general/http.py +++ b/src/bk-user/bkuser/plugins/general/http.py @@ -149,7 +149,9 @@ def fetch_first_item(url: str, headers: Dict[str, str], params: Dict[str, Any], resp_data = resp.json() except JSONDecodeError: # noqa: PERF203 raise RespDataFormatError( - _("数据源 API {} 参数 {} 返回非 Json 格式,响应内容 {}").format(url, stringify_params(params), resp.content) # noqa: E501 + _("数据源 API {} 参数 {} 返回非 Json 格式,响应内容 {}").format( + url, stringify_params(params), resp.content + ) # noqa: E501 ) results = resp_data.get("results", []) diff --git a/src/bk-user/bkuser/plugins/local/parser.py b/src/bk-user/bkuser/plugins/local/parser.py index 815a9ea11..adcf37925 100644 --- a/src/bk-user/bkuser/plugins/local/parser.py +++ b/src/bk-user/bkuser/plugins/local/parser.py @@ -134,7 +134,9 @@ def _validate_and_prepare(self): # noqa: C901 # 6. 检查用户名是否合法 if not USERNAME_REGEX.fullmatch(username): raise InvalidUsername( - _("用户名 {} 不符合命名规范: 由3-32位字母、数字、下划线(_)、点(.)、连接符(-)字符组成,以字母或数字开头").format(username) # noqa: E501 + _( + "用户名 {} 不符合命名规范: 由3-32位字母、数字、下划线(_)、点(.)、连接符(-)字符组成,以字母或数字开头" # noqa: E501 + ).format(username) ) # 7. 检查用户不能是自己的 leader diff --git a/src/bk-user/bkuser/settings.py b/src/bk-user/bkuser/settings.py index bcf8eda61..93789164d 100644 --- a/src/bk-user/bkuser/settings.py +++ b/src/bk-user/bkuser/settings.py @@ -201,6 +201,8 @@ BK_LOGIN_PLAIN_WINDOW_HEIGHT = env.int("BK_LOGIN_PLAIN_WINDOW_HEIGHT", default=415) # 登录回调地址参数Key BK_LOGIN_CALLBACK_URL_PARAM_KEY = env.str("BK_LOGIN_CALLBACK_URL_PARAM_KEY", default="c_url") +# 登录API URL +BK_LOGIN_API_URL = env.str("BK_LOGIN_API_URL", default="http://bk-login") # bk esb api url BK_COMPONENT_API_URL = env.str("BK_COMPONENT_API_URL") diff --git a/src/bk-user/bkuser/urls.py b/src/bk-user/bkuser/urls.py index ae6b547ba..6792ea5ff 100644 --- a/src/bk-user/bkuser/urls.py +++ b/src/bk-user/bkuser/urls.py @@ -21,6 +21,8 @@ urlpatterns = [ # 产品功能API path("api/v1/web/", include("bkuser.apis.web.urls")), + # 提供给登录服务使用的内部API + path("api/v1/login/", include("bkuser.apis.login.urls")), # 用于监控相关的,比如ping/healthz/sentry/metrics/otel等等 path("", include("bkuser.monitoring.urls")), ] diff --git a/src/bk-user/poetry.lock b/src/bk-user/poetry.lock index 7f5db96ea..1e088fcd3 100644 --- a/src/bk-user/poetry.lock +++ b/src/bk-user/poetry.lock @@ -127,57 +127,6 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" -[[package]] -name = "black" -version = "23.9.1" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.8" -files = [ - {file = "black-23.9.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301"}, - {file = "black-23.9.1-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100"}, - {file = "black-23.9.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71"}, - {file = "black-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7"}, - {file = "black-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80"}, - {file = "black-23.9.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f"}, - {file = "black-23.9.1-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe"}, - {file = "black-23.9.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186"}, - {file = "black-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f"}, - {file = "black-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300"}, - {file = "black-23.9.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948"}, - {file = "black-23.9.1-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855"}, - {file = "black-23.9.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204"}, - {file = "black-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377"}, - {file = "black-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573"}, - {file = "black-23.9.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c"}, - {file = "black-23.9.1-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325"}, - {file = "black-23.9.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393"}, - {file = "black-23.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9"}, - {file = "black-23.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f"}, - {file = "black-23.9.1-py3-none-any.whl", hash = "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9"}, - {file = "black-23.9.1.tar.gz", hash = "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[package.source] -type = "legacy" -url = "https://mirrors.tencent.com/pypi/simple" -reference = "tencent" - [[package]] name = "blue-krill" version = "2.0.0" @@ -2105,22 +2054,6 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" -[[package]] -name = "pathspec" -version = "0.11.2" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, - {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, -] - -[package.source] -type = "legacy" -url = "https://mirrors.tencent.com/pypi/simple" -reference = "tencent" - [[package]] name = "phonenumbers" version = "8.13.18" @@ -2137,26 +2070,6 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" -[[package]] -name = "platformdirs" -version = "3.10.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = false -python-versions = ">=3.7" -files = [ - {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, - {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, -] - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] - -[package.source] -type = "legacy" -url = "https://mirrors.tencent.com/pypi/simple" -reference = "tencent" - [[package]] name = "pluggy" version = "1.3.0" @@ -2723,28 +2636,28 @@ reference = "tencent" [[package]] name = "ruff" -version = "0.0.277" -description = "An extremely fast Python linter, written in Rust." +version = "0.1.4" +description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.0.277-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:3250b24333ef419b7a232080d9724ccc4d2da1dbbe4ce85c4caa2290d83200f8"}, - {file = "ruff-0.0.277-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:3e60605e07482183ba1c1b7237eca827bd6cbd3535fe8a4ede28cbe2a323cb97"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7baa97c3d7186e5ed4d5d4f6834d759a27e56cf7d5874b98c507335f0ad5aadb"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74e4b206cb24f2e98a615f87dbe0bde18105217cbcc8eb785bb05a644855ba50"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:479864a3ccd8a6a20a37a6e7577bdc2406868ee80b1e65605478ad3b8eb2ba0b"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:468bfb0a7567443cec3d03cf408d6f562b52f30c3c29df19927f1e0e13a40cd7"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f32ec416c24542ca2f9cc8c8b65b84560530d338aaf247a4a78e74b99cd476b4"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:14a7b2f00f149c5a295f188a643ac25226ff8a4d08f7a62b1d4b0a1dc9f9b85c"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9879f59f763cc5628aa01c31ad256a0f4dc61a29355c7315b83c2a5aac932b5"}, - {file = "ruff-0.0.277-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f612e0a14b3d145d90eb6ead990064e22f6f27281d847237560b4e10bf2251f3"}, - {file = "ruff-0.0.277-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:323b674c98078be9aaded5b8b51c0d9c424486566fb6ec18439b496ce79e5998"}, - {file = "ruff-0.0.277-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3a43fbe026ca1a2a8c45aa0d600a0116bec4dfa6f8bf0c3b871ecda51ef2b5dd"}, - {file = "ruff-0.0.277-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:734165ea8feb81b0d53e3bf523adc2413fdb76f1264cde99555161dd5a725522"}, - {file = "ruff-0.0.277-py3-none-win32.whl", hash = "sha256:88d0f2afb2e0c26ac1120e7061ddda2a566196ec4007bd66d558f13b374b9efc"}, - {file = "ruff-0.0.277-py3-none-win_amd64.whl", hash = "sha256:6fe81732f788894a00f6ade1fe69e996cc9e485b7c35b0f53fb00284397284b2"}, - {file = "ruff-0.0.277-py3-none-win_arm64.whl", hash = "sha256:2d4444c60f2e705c14cd802b55cd2b561d25bf4311702c463a002392d3116b22"}, - {file = "ruff-0.0.277.tar.gz", hash = "sha256:2dab13cdedbf3af6d4427c07f47143746b6b95d9e4a254ac369a0edb9280a0d2"}, + {file = "ruff-0.1.4-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:864958706b669cce31d629902175138ad8a069d99ca53514611521f532d91495"}, + {file = "ruff-0.1.4-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:9fdd61883bb34317c788af87f4cd75dfee3a73f5ded714b77ba928e418d6e39e"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4eaca8c9cc39aa7f0f0d7b8fe24ecb51232d1bb620fc4441a61161be4a17539"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a9a1301dc43cbf633fb603242bccd0aaa34834750a14a4c1817e2e5c8d60de17"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78e8db8ab6f100f02e28b3d713270c857d370b8d61871d5c7d1702ae411df683"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:80fea754eaae06335784b8ea053d6eb8e9aac75359ebddd6fee0858e87c8d510"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6bc02a480d4bfffd163a723698da15d1a9aec2fced4c06f2a753f87f4ce6969c"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9862811b403063765b03e716dac0fda8fdbe78b675cd947ed5873506448acea4"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58826efb8b3efbb59bb306f4b19640b7e366967a31c049d49311d9eb3a4c60cb"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fdfd453fc91d9d86d6aaa33b1bafa69d114cf7421057868f0b79104079d3e66e"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e8791482d508bd0b36c76481ad3117987301b86072158bdb69d796503e1c84a8"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01206e361021426e3c1b7fba06ddcb20dbc5037d64f6841e5f2b21084dc51800"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:645591a613a42cb7e5c2b667cbefd3877b21e0252b59272ba7212c3d35a5819f"}, + {file = "ruff-0.1.4-py3-none-win32.whl", hash = "sha256:99908ca2b3b85bffe7e1414275d004917d1e0dfc99d497ccd2ecd19ad115fd0d"}, + {file = "ruff-0.1.4-py3-none-win_amd64.whl", hash = "sha256:1dfd6bf8f6ad0a4ac99333f437e0ec168989adc5d837ecd38ddb2cc4a2e3db8a"}, + {file = "ruff-0.1.4-py3-none-win_arm64.whl", hash = "sha256:d98ae9ebf56444e18a3e3652b3383204748f73e247dea6caaf8b52d37e6b32da"}, + {file = "ruff-0.1.4.tar.gz", hash = "sha256:21520ecca4cc555162068d87c747b8f95e1e95f8ecfcbbe59e8dd00710586315"}, ] [package.source] @@ -3382,4 +3295,4 @@ reference = "tencent" [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.11" -content-hash = "7fde36fb99bf30d8f8f0a1ca84dc777607d2d585fee89966c519d02d7e0087c8" +content-hash = "0b5c47c4b412f77ea691826114ab4b41e88330e0e0639618ff7421ecaca2818c" diff --git a/src/bk-user/pyproject.toml b/src/bk-user/pyproject.toml index 3daad96d3..02f08f905 100644 --- a/src/bk-user/pyproject.toml +++ b/src/bk-user/pyproject.toml @@ -51,8 +51,7 @@ openpyxl = "3.1.2" tongsuopy-crayon = "1.0.2b6" [tool.poetry.group.dev.dependencies] -ruff = "^0.0.277" -black = "^23.7.0" +ruff = "^0.1.4" mypy = "^1.4.1" types-requests = "^2.31.0.1" pytest = "^7.4.0" @@ -60,14 +59,6 @@ pytest-django = "^4.5.2" types-pytz = "^2023.3.0.0" import-linter = "^1.11.1" -[tool.black] -line-length = 119 -force-exclude = ''' -/( - migrations -)/ -''' - [tool.mypy] ignore_missing_imports = true show_error_codes = true diff --git a/src/bk-user/tests/apps/idp/__init__.py b/src/bk-user/tests/apps/idp/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/apps/idp/__init__.py @@ -0,0 +1,10 @@ +# -*- 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. +""" diff --git a/src/bk-user/tests/apps/idp/test_data_models.py b/src/bk-user/tests/apps/idp/test_data_models.py new file mode 100644 index 000000000..c49d22893 --- /dev/null +++ b/src/bk-user/tests/apps/idp/test_data_models.py @@ -0,0 +1,90 @@ +# -*- 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 pytest +from bkuser.apps.idp.data_models import DataSourceMatchRule, FieldCompareRule +from django.db.models import Q + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize( + ("source_data", "excepted_queryset"), + [ + # valid + ( + {"user_id": "test_username", "phone": "1234567890123"}, + (Q(data_source_id=1) & Q(username="test_username") & Q(phone="1234567890123")), + ), + ( + {"user_id": "test_username", "phone": "1234567890123", "email": "111@qq.com"}, + (Q(data_source_id=1) & Q(username="test_username") & Q(phone="1234567890123")), + ), + # invalid + ( + {"user_id": "test_username"}, + None, + ), + ], +) +def test_convert_to_queryset_filter_for_source_data(source_data, excepted_queryset): + data_source_match_rule = DataSourceMatchRule( + data_source_id=1, + field_compare_rules=[ + FieldCompareRule(source_field="user_id", target_field="username"), + FieldCompareRule(source_field="phone", target_field="phone"), + ], + ) + queryset = data_source_match_rule.convert_to_queryset_filter(source_data) + + assert queryset == excepted_queryset + + +@pytest.mark.parametrize( + ("rule", "excepted_queryset"), + [ + # No field compare rule + ( + DataSourceMatchRule(data_source_id=1, field_compare_rules=[]), + None, + ), + # one field compare rule + ( + DataSourceMatchRule( + data_source_id=1, + field_compare_rules=[FieldCompareRule(source_field="user_id", target_field="username")], + ), + (Q(data_source_id=1) & Q(username="test_username")), + ), + # Mult field compare rule + ( + DataSourceMatchRule( + data_source_id=1, + field_compare_rules=[ + FieldCompareRule(source_field="user_id", target_field="username"), + FieldCompareRule(source_field="phone", target_field="phone"), + FieldCompareRule(source_field="email", target_field="private_email"), + ], + ), + ( + Q(data_source_id=1) + & Q(username="test_username") + & Q(phone="1234567890123") + & Q(private_email="111@qq.com") + ), + ), + ], +) +def test_convert_to_queryset_filter_for_rule(rule, excepted_queryset): + source_data = {"user_id": "test_username", "phone": "1234567890123", "email": "111@qq.com"} + + queryset = rule.convert_to_queryset_filter(source_data) + + assert queryset == excepted_queryset diff --git a/src/idp-plugins/idp_plugins/base.py b/src/idp-plugins/idp_plugins/base.py index 0cbab8438..5c4e485f5 100644 --- a/src/idp-plugins/idp_plugins/base.py +++ b/src/idp-plugins/idp_plugins/base.py @@ -15,7 +15,7 @@ from django.http import HttpRequest, HttpResponse, HttpResponseNotFound from pydantic import BaseModel -from .constants import CUSTOM_PLUGIN_ID_PREFIX, BuiltinIdpPluginIDs, PluginTypeEnum, AllowedHttpMethodEnum +from .constants import CUSTOM_PLUGIN_ID_PREFIX, AllowedHttpMethodEnum, BuiltinIdpPluginIDs, PluginTypeEnum from .models import DispatchConfigItem, TestConnectionResult logger = logging.getLogger(__name__) @@ -95,7 +95,8 @@ def build_login_uri(self, request: HttpRequest, callback_uri: str) -> str: """ 构建跳转到第三方登录的URL :param request: Django View的Request, 可获取Cookie/Body/QueryParam/FormParam/Header/Session 也可以设置Session - :param callback_uri: 一般跳转到第三方登录成功后需要回跳回来,callback_uri即为回跳回来的完整地址(包括http(s)协议和url路径) + :param callback_uri: 一般跳转到第三方登录成功后需要回跳回来, + callback_uri即为回跳回来的完整地址(包括http(s)协议和url路径) :return: 处理后的参数后重定向到第三方登录的URI """ ... diff --git a/src/idp-plugins/idp_plugins/constants.py b/src/idp-plugins/idp_plugins/constants.py index 8c4b57a30..2a7965efe 100644 --- a/src/idp-plugins/idp_plugins/constants.py +++ b/src/idp-plugins/idp_plugins/constants.py @@ -37,7 +37,7 @@ class BuiltinIdpPluginEnum(str, StructuredEnum): WECOM = EnumField("wecom", label=_("企业微信")) -BuiltinIdpPluginIDs = [i for i in BuiltinIdpPluginEnum] # type: ignore[attr-defined] +BuiltinIdpPluginIDs = list(BuiltinIdpPluginEnum) # type: ignore[attr-defined] class AllowedHttpMethodEnum(str, StructuredEnum): diff --git a/src/idp-plugins/idp_plugins/exceptions.py b/src/idp-plugins/idp_plugins/exceptions.py index 05ef099aa..ab338712c 100644 --- a/src/idp-plugins/idp_plugins/exceptions.py +++ b/src/idp-plugins/idp_plugins/exceptions.py @@ -26,5 +26,9 @@ class InvalidParamError(IdpPluginBaseError): """参数非合法""" +class ValidationError(IdpPluginBaseError): + """校验不通过""" + + class UnexpectedDataError(IdpPluginBaseError): """数据非预期""" diff --git a/src/idp-plugins/idp_plugins/http.py b/src/idp-plugins/idp_plugins/http.py index a4beaa51e..e71ad13f1 100644 --- a/src/idp-plugins/idp_plugins/http.py +++ b/src/idp-plugins/idp_plugins/http.py @@ -10,6 +10,7 @@ """ import logging import time +from typing import Dict, Tuple from urllib.parse import urlparse import requests @@ -29,7 +30,63 @@ session.mount("http://", adapter) -def _http_request(method, url, **kwargs): +class HttpStatusCode: + def __init__(self, status_code: int): + self.code = status_code + + @property + def is_invalid(self) -> bool: + return self.code < 0 # noqa: PLR2004 + + @property + def is_success(self) -> bool: + return 200 <= self.code <= 299 # noqa: PLR2004 + + @property + def is_redirect(self) -> bool: + return 300 <= self.code <= 399 # noqa: PLR2004 + + @property + def is_client_error(self) -> bool: + return 400 <= self.code <= 499 # noqa: PLR2004 + + @property + def is_server_error(self) -> bool: + return 500 <= self.code <= 599 # noqa: PLR2004 + + @property + def is_unauthorized(self) -> bool: + return self.code == 401 # noqa: PLR2004 + + @property + def is_forbidden(self) -> bool: + return self.code == 403 # noqa: PLR2004 + + @property + def is_not_found(self) -> bool: + return self.code == 404 # noqa: PLR2004 + + +# 定义无效请求的Http状态码 +INVALID_REQUEST_STATUS_CODE = HttpStatusCode(status_code=-1) +# Request Body 非JSON格式 +INVALID_JSON_STATUS_CODE = HttpStatusCode(status_code=-2) + + +def _http_request(method: str, url: str, **kwargs) -> Tuple[HttpStatusCode, Dict]: + """ + 通用的Http接口请求,目前只支持JSON格式的Body数据返回,对于其他格式的返回,都认为是调用失败 + :param method: http请求method,大写,GET/POST/DELETE/PUT/PATCH/HEAD + :param url: http 请求URL + :param kwargs: 与Requests库一致的请求参数(params/json/data/headers/auth/verify/timeout等等) + :return http_status_code, response_body_data: + 只要是Response Body为json格式数据,都会返回有效的Http状态码,表示请求发送和接收成功,不关注业务逻辑 + - status_code < 0: 表示非预期请求,无效请求 + - status_code > 0: 表示正常请求且Response Body为JSON格式的状态码 + - data: + status_code < 0时,包含error字段,描述非预期请求的原因 + status_code > 0时,则为经JSON解析后的Response Body数据 + """ # 添加JSON Header headers = kwargs.get("headers") or {} headers.setdefault("Content-Type", "application/json") @@ -43,46 +100,67 @@ def _http_request(method, url, **kwargs): st = time.time() if method not in ["GET", "POST", "DELETE", "PUT", "PATCH", "HEAD"]: - return False, {"error": "method not supported"} + return INVALID_REQUEST_STATUS_CODE, {"error": f"request method - {method} not supported"} try: resp = session.request(method, url, **kwargs) except requests.exceptions.RequestException as e: logger.exception("http request error! %s %s, kwargs: %s", method, url, kwargs) - return False, {"error": str(e)} - else: - # record - latency = int((time.time() - st) * 1000) - # greater than 100ms - if latency > SLOW_REQUEST_LATENCY: - logger.warning("http slow request! method: %s, url: %s, latency: %dms", method, url, latency) - - # 状态非20x,说明是异常请求 - if not (200 <= resp.status_code <= 299): # noqa: PLR2004 - content = resp.content[:256] if resp.content else "" - logger.error( - "http request fail! %s %s, kwargs: %s, response.status_code: %s, response.body: %s", - method, - url, - str(kwargs), - resp.status_code, - content, - ) - - return False, { - "error": ( - f"status_code is {resp.status_code}, not 20x! " - f"{method} {urlparse(url).path}, resp.body={content}" - ) - } - - try: - return True, resp.json() - except Exception as e: - logger.exception("http response body not json! %s %s, kwargs: %s", method, url, kwargs) - return False, {"error": str(e)} + return INVALID_REQUEST_STATUS_CODE, {"error": str(e)} + # 记录耗时,单位 ms + latency = int((time.time() - st) * 1000) + # 记录慢请求,默认大于 100ms 即为慢请求 + if latency > SLOW_REQUEST_LATENCY: + logger.warning("http slow request! method: %s, url: %s, latency: %dms", method, url, latency) + # 只支持JSON格式的Body数据返回 + try: + return HttpStatusCode(resp.status_code), resp.json() + except Exception as e: + content = resp.content[:256] if resp.content else "" + logging.exception( + "http request fail, response.body not json! " + "%s %s, kwargs: %s, response.status_code: %s, response.body: %s", + method, + url, + str(kwargs), + resp.status_code, + content, + ) + return INVALID_JSON_STATUS_CODE, { + "error": ( + f"http response body not json, http status code is {resp.status_code}! " + f"{method} {urlparse(url).path}, response.body={content}, error:{e}" + ) + } + + +def _http_request_only_20x(method: str, url: str, **kwargs) -> Tuple[bool, Dict]: + """只支持20x且Response Body为JSON的请求,其他均为异常请求""" + status, resp_data = _http_request(method, url, **kwargs) + if status.is_success: + return True, resp_data + + # 无效请求 + if status.is_invalid: + return False, resp_data + + # 非 20x 请求 + logger.error( + "http response status code is %s, not 20x! %s %s, kwargs: %s, response.body: %s", + status.code, + method, + url, + str(kwargs), + resp_data, + ) + return False, { + "error": f"status_code is {status.code}, not 20x! {method} {urlparse(url).path}, response.body={resp_data}", + } + + +# 标准的 API 请求, JSON 响应 def http_get(url, **kwargs): return _http_request(method="GET", url=url, **kwargs) @@ -101,3 +179,24 @@ def http_patch(url, **kwargs): def http_delete(url, **kwargs): return _http_request(method="DELETE", url=url, **kwargs) + + +# 只允许 20x 的 API 请求,JSON响应 +def http_get_20x(url, **kwargs): + return _http_request_only_20x(method="GET", url=url, **kwargs) + + +def http_post_20x(url, **kwargs): + return _http_request_only_20x(method="POST", url=url, **kwargs) + + +def http_put_20x(url, **kwargs): + return _http_request_only_20x(method="PUT", url=url, **kwargs) + + +def http_patch_20x(url, **kwargs): + return _http_request_only_20x(method="PATCH", url=url, **kwargs) + + +def http_delete_20x(url, **kwargs): + return _http_request_only_20x(method="DELETE", url=url, **kwargs) diff --git a/src/idp-plugins/idp_plugins/local/client.py b/src/idp-plugins/idp_plugins/local/client.py new file mode 100644 index 000000000..bf8a50cf5 --- /dev/null +++ b/src/idp-plugins/idp_plugins/local/client.py @@ -0,0 +1,74 @@ +# -*- 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 logging +from typing import Any, Dict, List +from urllib.parse import urljoin + +from requests.auth import HTTPBasicAuth + +from .settings import BK_USER_API_URL, BK_USER_APP_CODE, BK_USER_APP_SECRET +from ..exceptions import RequestAPIError, ValidationError +from ..http import http_post + +logger = logging.getLogger(__name__) + + +class BkUserAPIClient: + """请求蓝鲸用户管理接口的Client""" + + def __init__(self): + # 接口调用认证 + self.auth = HTTPBasicAuth(BK_USER_APP_CODE, BK_USER_APP_SECRET) + + def _call(self, http_func, url_path: str, **kwargs) -> Dict[str, Any]: + """调用用户管理接口""" + # FIXME (nan): 对于密码,是敏感信息,不应该出现在日志里 + url = urljoin(BK_USER_API_URL, url_path) + # API认证 + kwargs.setdefault("auth", self.auth) + status, resp_data = http_func(url, **kwargs) + if status.is_invalid: + logger.error( + "bk_user api failed, %s %s, kwargs: %s, error: %s", http_func.__name__, url, kwargs, resp_data["error"] + ) + raise RequestAPIError( + f"request bk_user api fail! Request=[{http_func.__name__} {url} error={resp_data['error']}" + ) + + # 除20x外,对于 40x 异常,需要根据 error code 进行处理的,所以这里不直接抛异常,直接原始返回 + if status.is_success or status.is_client_error: + return resp_data + + error = resp_data.get("error") + logger.error("bk_user api error, %s %s, data: %s, error: %s", http_func.__name__, url, kwargs, error) + raise RequestAPIError( + f"request bk_user api error! " f"Request=[{http_func.__name__} {url} Response[error={error}]" + ) + + def auth_credentials_of_local_user( + self, data_source_ids: List[int], username: str, password: str + ) -> List[Dict[str, Any]]: + """ + 认证指定本地数据源的用户凭据 + Note: 由于不同数据源有极小概率出现同名同密,所以可能会查询到多个用户 + :return 认证成功的用户列表,一般只会有一个用户 + """ + resp_data = self._call( + http_post, + "/api/v1/login/local-user-credentials/authenticate/", + json={"data_source_ids": data_source_ids, "username": username, "password": password}, + ) + + # FIXME: 后续支持调用点判断,不直接返回ValidationError,比如密码过期,获取error.data里的url进行重置密码 + if error := resp_data.get("error", {}): + raise ValidationError(error["message"]) + + return resp_data["data"] diff --git a/src/idp-plugins/idp_plugins/local/db_models.py b/src/idp-plugins/idp_plugins/local/db_models.py deleted file mode 100644 index de7046b25..000000000 --- a/src/idp-plugins/idp_plugins/local/db_models.py +++ /dev/null @@ -1,53 +0,0 @@ -# -*- 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 blue_krill.models.fields import EncryptField -from django.db import models - - -class DataSourceUser(models.Model): - data_source_id = models.BigIntegerField("数据源") - code = models.CharField("用户标识", max_length=128) - - # ----------------------- 内置字段相关 ----------------------- - username = models.CharField("用户名", max_length=128) - full_name = models.CharField("姓名", max_length=128) - email = models.EmailField("邮箱", null=True, blank=True, default="") - phone = models.CharField("手机号", null=True, blank=True, default="", max_length=32) - phone_country_code = models.CharField("手机国际区号", max_length=16, null=True, blank=True) - - class Meta: - # FIXME: 由于idp_plugins模块会被不同项目引入,model为了被Django App 加载,需要添加app_label, - # 同时由于不同项目自定义app不一样,所以这里临时使用公共且没有实际models&migrations的django.contrib.messages - app_label = "django.contrib.messages" - managed = False - db_table = "data_source_datasourceuser" - - -class LocalDataSourceIdentityInfo(models.Model): - """ - 本地数据源特有,认证相关信息 - """ - - user = models.OneToOneField(DataSourceUser, on_delete=models.CASCADE) - password = EncryptField(verbose_name="用户密码", null=True, blank=True, default="", max_length=255) - password_updated_at = models.DateTimeField("密码最后更新时间", null=True, blank=True) - password_expired_at = models.DateTimeField("密码过期时间", null=True, blank=True) - - # data_source_id/username为冗余字段,便于认证时快速匹配 - data_source_id = models.BigIntegerField("数据源") - username = models.CharField("用户名", max_length=128) - - class Meta: - # FIXME: 由于idp_plugins模块会被不同项目引入,model为了被Django App 加载,需要添加app_label, - # 同时由于不同项目自定义app不一样,所以这里临时使用公共且没有实际models&migrations的django.contrib.messages - app_label = "django.contrib.messages" - managed = False - db_table = "data_source_localdatasourceidentityinfo" diff --git a/src/idp-plugins/idp_plugins/local/plugin.py b/src/idp-plugins/idp_plugins/local/plugin.py index daf80f935..9c879c60e 100644 --- a/src/idp-plugins/idp_plugins/local/plugin.py +++ b/src/idp-plugins/idp_plugins/local/plugin.py @@ -14,9 +14,9 @@ from django.utils.translation import gettext_lazy as _ from pydantic import BaseModel -from .db_models import LocalDataSourceIdentityInfo -from ..exceptions import InvalidParamError, UnexpectedDataError +from .client import BkUserAPIClient from ..base import BaseCredentialIdpPlugin +from ..exceptions import InvalidParamError, UnexpectedDataError from ..models import TestConnectionResult from ..utils import parse_request_body_json @@ -37,6 +37,7 @@ class LocalIdpPlugin(BaseCredentialIdpPlugin): def __init__(self, cfg: LocalIdpPluginConfig): self.cfg = cfg + self.client = BkUserAPIClient() def test_connection(self) -> TestConnectionResult: raise NotImplementedError(_("本地认证源不支持连通性测试")) @@ -56,18 +57,5 @@ def authenticate_credentials(self, request: HttpRequest) -> List[Dict[str, Any]] if not self.cfg.data_source_ids: raise UnexpectedDataError(_("当前租户没有数据源允许账密登录")) - # TODO: 密码错误次数检测 - - # FIXME (nan): 待用户密码功能改造完成后重新调整校验密码方式 - users = LocalDataSourceIdentityInfo.objects.filter( - data_source_id__in=self.cfg.data_source_ids, username=username - ) - matched_users = [i for i in users if i.password == password] - - # TODO:是否需要判断密码过期呢? - - # 判断是否有用户匹配 - if len(matched_users) == 0: - raise InvalidParamError(_("用户名或密码不正确")) - - return [{"id": i.user.id} for i in matched_users] + # 调用用户管理接口校验 + return self.client.auth_credentials_of_local_user(self.cfg.data_source_ids, username, password) diff --git a/src/idp-plugins/idp_plugins/local/settings.py b/src/idp-plugins/idp_plugins/local/settings.py new file mode 100644 index 000000000..bea5ae796 --- /dev/null +++ b/src/idp-plugins/idp_plugins/local/settings.py @@ -0,0 +1,15 @@ +# -*- 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 os + +BK_USER_APP_CODE = os.environ.get("BK_USER_APP_CODE", default="bk_user") +BK_USER_APP_SECRET = os.environ.get("BK_USER_APP_SECRET", "") +BK_USER_API_URL = os.environ.get("BK_USER_API_URL", "http://bk-user") diff --git a/src/idp-plugins/idp_plugins/wecom/client.py b/src/idp-plugins/idp_plugins/wecom/client.py index 77ec26823..024640d5c 100644 --- a/src/idp-plugins/idp_plugins/wecom/client.py +++ b/src/idp-plugins/idp_plugins/wecom/client.py @@ -16,7 +16,7 @@ from .settings import WECOM_API_BASE_URL from ..exceptions import RequestAPIError, UnexpectedDataError -from ..http import http_get +from ..http import http_get_20x logger = logging.getLogger(__name__) @@ -75,7 +75,7 @@ def _get_access_token(self) -> Tuple[str, int]: """ params = {"corpid": self.corp_id, "corpsecret": self.secret} - resp_data = self._call(http_get, "/gettoken", params=params) + resp_data = self._call(http_get_20x, "/gettoken", params=params) return resp_data["access_token"], resp_data["expires_in"] @property @@ -91,7 +91,7 @@ def get_user_id_by_code(self, code: str) -> str: docs: https://developer.work.weixin.qq.com/document/path/98176 """ params = {"access_token": self.access_token, "code": code} - resp_data = self._call(http_get, "/auth/getuserinfo", params=params) + resp_data = self._call(http_get_20x, "/auth/getuserinfo", params=params) userid = resp_data.get("userid") if userid: return userid diff --git a/src/idp-plugins/idp_plugins/wecom/plugin.py b/src/idp-plugins/idp_plugins/wecom/plugin.py index 00dcabfd3..8d18a71a4 100644 --- a/src/idp-plugins/idp_plugins/wecom/plugin.py +++ b/src/idp-plugins/idp_plugins/wecom/plugin.py @@ -16,9 +16,9 @@ from pydantic import BaseModel from .client import WeComAPIClient -from ..exceptions import InvalidParamError from .settings import WECOM_OAUTH_URL from ..base import BaseFederationIdpPlugin +from ..exceptions import InvalidParamError from ..models import TestConnectionResult from ..utils import generate_random_str @@ -38,6 +38,7 @@ class WecomIdpPlugin(BaseFederationIdpPlugin): def __init__(self, cfg: WecomIdpPluginConfig): self.cfg = cfg + self.client = WeComAPIClient(self.cfg.corp_id, self.cfg.agent_id, self.cfg.secret) def test_connection(self) -> TestConnectionResult: # TODO: 测试调用企业微信网络是否OK @@ -82,7 +83,6 @@ def handle_callback(self, request: HttpRequest) -> Dict[str, Any]: raise InvalidParamError(_("code 参数不能为空")) # 通过code获取用户信息 - client = WeComAPIClient(self.cfg.corp_id, self.cfg.agent_id, self.cfg.secret) - user_id = client.get_user_id_by_code(code) + user_id = self.client.get_user_id_by_code(code) return {"user_id": user_id} diff --git a/src/idp-plugins/poetry.lock b/src/idp-plugins/poetry.lock index 83309b334..d71d93fc8 100644 --- a/src/idp-plugins/poetry.lock +++ b/src/idp-plugins/poetry.lock @@ -60,53 +60,6 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" -[[package]] -name = "black" -version = "23.10.1" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.8" -files = [ - {file = "black-23.10.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:ec3f8e6234c4e46ff9e16d9ae96f4ef69fa328bb4ad08198c8cee45bb1f08c69"}, - {file = "black-23.10.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:1b917a2aa020ca600483a7b340c165970b26e9029067f019e3755b56e8dd5916"}, - {file = "black-23.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c74de4c77b849e6359c6f01987e94873c707098322b91490d24296f66d067dc"}, - {file = "black-23.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:7b4d10b0f016616a0d93d24a448100adf1699712fb7a4efd0e2c32bbb219b173"}, - {file = "black-23.10.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b15b75fc53a2fbcac8a87d3e20f69874d161beef13954747e053bca7a1ce53a0"}, - {file = "black-23.10.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:e293e4c2f4a992b980032bbd62df07c1bcff82d6964d6c9496f2cd726e246ace"}, - {file = "black-23.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d56124b7a61d092cb52cce34182a5280e160e6aff3137172a68c2c2c4b76bcb"}, - {file = "black-23.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:3f157a8945a7b2d424da3335f7ace89c14a3b0625e6593d21139c2d8214d55ce"}, - {file = "black-23.10.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:cfcce6f0a384d0da692119f2d72d79ed07c7159879d0bb1bb32d2e443382bf3a"}, - {file = "black-23.10.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:33d40f5b06be80c1bbce17b173cda17994fbad096ce60eb22054da021bf933d1"}, - {file = "black-23.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:840015166dbdfbc47992871325799fd2dc0dcf9395e401ada6d88fe11498abad"}, - {file = "black-23.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:037e9b4664cafda5f025a1728c50a9e9aedb99a759c89f760bd83730e76ba884"}, - {file = "black-23.10.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:7cb5936e686e782fddb1c73f8aa6f459e1ad38a6a7b0e54b403f1f05a1507ee9"}, - {file = "black-23.10.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:7670242e90dc129c539e9ca17665e39a146a761e681805c54fbd86015c7c84f7"}, - {file = "black-23.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ed45ac9a613fb52dad3b61c8dea2ec9510bf3108d4db88422bacc7d1ba1243d"}, - {file = "black-23.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:6d23d7822140e3fef190734216cefb262521789367fbdc0b3f22af6744058982"}, - {file = "black-23.10.1-py3-none-any.whl", hash = "sha256:d431e6739f727bb2e0495df64a6c7a5310758e87505f5f8cde9ff6c0f2d7e4fe"}, - {file = "black-23.10.1.tar.gz", hash = "sha256:1f8ce316753428ff68749c65a5f7844631aa18c8679dfd3ca9dc1a289979c258"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[package.source] -type = "legacy" -url = "https://mirrors.tencent.com/pypi/simple" -reference = "tencent" - [[package]] name = "blue-krill" version = "2.0.2" @@ -725,42 +678,6 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" -[[package]] -name = "pathspec" -version = "0.11.2" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, - {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, -] - -[package.source] -type = "legacy" -url = "https://mirrors.tencent.com/pypi/simple" -reference = "tencent" - -[[package]] -name = "platformdirs" -version = "3.11.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = false -python-versions = ">=3.7" -files = [ - {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, - {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, -] - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] - -[package.source] -type = "legacy" -url = "https://mirrors.tencent.com/pypi/simple" -reference = "tencent" - [[package]] name = "pluggy" version = "1.3.0" @@ -1123,28 +1040,28 @@ reference = "tencent" [[package]] name = "ruff" -version = "0.1.2" -description = "An extremely fast Python linter, written in Rust." +version = "0.1.4" +description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.1.2-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:0d3ee66b825b713611f89aa35d16de984f76f26c50982a25d52cd0910dff3923"}, - {file = "ruff-0.1.2-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:f85f850a320ff532b8f93e8d1da6a36ef03698c446357c8c43b46ef90bb321eb"}, - {file = "ruff-0.1.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:809c6d4e45683696d19ca79e4c6bd3b2e9204fe9546923f2eb3b126ec314b0dc"}, - {file = "ruff-0.1.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46005e4abb268e93cad065244e17e2ea16b6fcb55a5c473f34fbc1fd01ae34cb"}, - {file = "ruff-0.1.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10cdb302f519664d5e2cf954562ac86c9d20ca05855e5b5c2f9d542228f45da4"}, - {file = "ruff-0.1.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f89ebcbe57a1eab7d7b4ceb57ddf0af9ed13eae24e443a7c1dc078000bd8cc6b"}, - {file = "ruff-0.1.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7344eaca057d4c32373c9c3a7afb7274f56040c225b6193dd495fcf69453b436"}, - {file = "ruff-0.1.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dffa25f6e03c4950b6ac6f216bc0f98a4be9719cb0c5260c8e88d1bac36f1683"}, - {file = "ruff-0.1.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42ddaea52cb7ba7c785e8593a7532866c193bc774fe570f0e4b1ccedd95b83c5"}, - {file = "ruff-0.1.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8533efda625bbec0bf27da2886bd641dae0c209104f6c39abc4be5b7b22de2a"}, - {file = "ruff-0.1.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b0b1b82221ba7c50e03b7a86b983157b5d3f4d8d4f16728132bdf02c6d651f77"}, - {file = "ruff-0.1.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6c1362eb9288f8cc95535294cb03bd4665c8cef86ec32745476a4e5c6817034c"}, - {file = "ruff-0.1.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ffa7ef5ded0563329a35bd5a1cfdae40f05a75c0cc2dd30f00b1320b1fb461fc"}, - {file = "ruff-0.1.2-py3-none-win32.whl", hash = "sha256:6e8073f85e47072256e2e1909f1ae515cf61ff5a4d24730a63b8b4ac24b6704a"}, - {file = "ruff-0.1.2-py3-none-win_amd64.whl", hash = "sha256:b836ddff662a45385948ee0878b0a04c3a260949905ad861a37b931d6ee1c210"}, - {file = "ruff-0.1.2-py3-none-win_arm64.whl", hash = "sha256:b0c42d00db5639dbd5f7f9923c63648682dd197bf5de1151b595160c96172691"}, - {file = "ruff-0.1.2.tar.gz", hash = "sha256:afd4785ae060ce6edcd52436d0c197628a918d6d09e3107a892a1bad6a4c6608"}, + {file = "ruff-0.1.4-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:864958706b669cce31d629902175138ad8a069d99ca53514611521f532d91495"}, + {file = "ruff-0.1.4-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:9fdd61883bb34317c788af87f4cd75dfee3a73f5ded714b77ba928e418d6e39e"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4eaca8c9cc39aa7f0f0d7b8fe24ecb51232d1bb620fc4441a61161be4a17539"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a9a1301dc43cbf633fb603242bccd0aaa34834750a14a4c1817e2e5c8d60de17"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78e8db8ab6f100f02e28b3d713270c857d370b8d61871d5c7d1702ae411df683"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:80fea754eaae06335784b8ea053d6eb8e9aac75359ebddd6fee0858e87c8d510"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6bc02a480d4bfffd163a723698da15d1a9aec2fced4c06f2a753f87f4ce6969c"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9862811b403063765b03e716dac0fda8fdbe78b675cd947ed5873506448acea4"}, + {file = "ruff-0.1.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58826efb8b3efbb59bb306f4b19640b7e366967a31c049d49311d9eb3a4c60cb"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fdfd453fc91d9d86d6aaa33b1bafa69d114cf7421057868f0b79104079d3e66e"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e8791482d508bd0b36c76481ad3117987301b86072158bdb69d796503e1c84a8"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01206e361021426e3c1b7fba06ddcb20dbc5037d64f6841e5f2b21084dc51800"}, + {file = "ruff-0.1.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:645591a613a42cb7e5c2b667cbefd3877b21e0252b59272ba7212c3d35a5819f"}, + {file = "ruff-0.1.4-py3-none-win32.whl", hash = "sha256:99908ca2b3b85bffe7e1414275d004917d1e0dfc99d497ccd2ecd19ad115fd0d"}, + {file = "ruff-0.1.4-py3-none-win_amd64.whl", hash = "sha256:1dfd6bf8f6ad0a4ac99333f437e0ec168989adc5d837ecd38ddb2cc4a2e3db8a"}, + {file = "ruff-0.1.4-py3-none-win_arm64.whl", hash = "sha256:d98ae9ebf56444e18a3e3652b3383204748f73e247dea6caaf8b52d37e6b32da"}, + {file = "ruff-0.1.4.tar.gz", hash = "sha256:21520ecca4cc555162068d87c747b8f95e1e95f8ecfcbbe59e8dd00710586315"}, ] [package.source] @@ -1453,4 +1370,4 @@ reference = "tencent" [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.11" -content-hash = "152c80e930b5628ed8c34deae1d0b30363b9458ac3267f758e7a154bc1b9ec34" +content-hash = "2878b1d8ce349bd3f7c6789b40ade6b704646e42e3a5b400126f674a3a954d48" diff --git a/src/idp-plugins/pyproject.toml b/src/idp-plugins/pyproject.toml index 84e889062..e5300a5e1 100644 --- a/src/idp-plugins/pyproject.toml +++ b/src/idp-plugins/pyproject.toml @@ -17,22 +17,13 @@ pydantic = "2.3.0" blue-krill = "2.0.2" [tool.poetry.group.dev.dependencies] -ruff = "^0.1.2" +ruff = "^0.1.4" mypy = "^1.6.1" -black = "^23.10.1" types-requests = "^2.31.0.10" pytest = "^7.4.3" pytest-django = "^4.5.2" import-linter = "^1.12.0" -[tool.black] -line-length = 119 -force-exclude = ''' -/( - migrations -)/ -''' - [tool.mypy] ignore_missing_imports = true show_error_codes = true