Skip to content

Commit

Permalink
feat: open v1 api
Browse files Browse the repository at this point in the history
  • Loading branch information
nannan00 committed Aug 23, 2024
1 parent be06b43 commit 28c7565
Show file tree
Hide file tree
Showing 13 changed files with 543 additions and 0 deletions.
Empty file.
144 changes: 144 additions & 0 deletions src/bk-user/bkuser/apis/open_v1/authentications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved.
Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://opensource.org/licenses/MIT
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
"""

import base64
import logging

import jwt
from django.conf import settings
from django.contrib.auth import get_user_model
from rest_framework import exceptions
from rest_framework.authentication import BaseAuthentication

from bkuser.common.cache import cachedmethod
from bkuser.common.constants import BKNonEntityUser
from bkuser.component import esb

logger = logging.getLogger(__name__)


class ESBAuthentication(BaseAuthentication):
www_authenticate_realm = "api"

def authenticate(self, request):
credentials = self.get_credentials(request)

if not credentials:
return None

verified, payload = self.verify_credentials(credentials=credentials)
if not verified:
return None

username = self._get_username_from_jwt_payload(payload)
app_code = self._get_app_code_from_jwt_payload(payload)

request.bk_app_code = app_code # 获取到调用 app_code

return self._get_or_create_user(username), None

def authenticate_header(self, request):
return '{0} realm="{1}"'.format("Bearer", self.www_authenticate_realm)

def get_credentials(self, request):
credentials = {
"jwt": request.META.get("HTTP_X_BKAPI_JWT"),
"from": request.META.get("HTTP_X_BKAPI_FROM", "esb"),
}
# Return None if some are empty
if all(credentials.values()):
return credentials

return None

def verify_credentials(self, credentials):
public_key = self._get_jwt_public_key(credentials["from"])
# Note: 不从 jwt header 里取 kid 判断是网关还是 ESB 签发的,在不同环境可能不准确
jwt_payload = self._decode_jwt(credentials["jwt"], public_key)
if not jwt_payload:
return False, None

return True, jwt_payload

def _decode_jwt(self, content, public_key):
try:
jwt_header = jwt.get_unverified_header(content)
algorithm = jwt_header.get("alg") or "RS512"
return jwt.decode(content, public_key, algorithms=[algorithm], options={"verify_iss": False})
except Exception: # pylint: disable=broad-except
logger.exception("decode jwt fail, jwt: %s", content)
return None

def _get_username_from_jwt_payload(self, jwt_payload):
"""从 jwt payload 里获取 username"""
user = jwt_payload.get("user", {})
verified = user.get("verified", False)
username = user.get("bk_username", "") or user.get("username", "")
# 如果 user 通过认证,则为实体用户,直接返回
if verified:
return username

# 未通过认证有两种可能,(1)username 不可信任(2)username 为空
# 非空则说明是未认证,不可信任的用户,则统一用不可信任的用户名代替,不使用传递过来的 username
if username:
return BKNonEntityUser.BK__UNVERIFIED_USER.value

# 匿名用户
return BKNonEntityUser.BK__ANONYMOUS_USER.value

def _get_app_code_from_jwt_payload(self, jwt_payload):
"""从 jwt payload 里获取 app_code"""
app = jwt_payload.get("app", {})

if not app.get("verified", False):
raise exceptions.AuthenticationFailed("app is not verified")

# 兼容多版本(企业版/TE版/社区版) 以及兼容 APIGW / ESB
app_code = app.get("bk_app_code", "") or app.get("app_code", "")

# 虽然 app_code 为空对于后续的鉴权一定是不通过的,但鉴权不通过有很多原因,这里提前log便于问题排查
if not app_code:
raise exceptions.AuthenticationFailed("could not get app_code from esb/apigateway jwt payload! it's empty")

return app_code

def _get_or_create_user(self, username):
user_model = get_user_model()
user, _ = user_model.objects.get_or_create(
username=username, defaults={"is_active": True, "is_staff": False, "is_superuser": False}
)
return user

def _get_apigw_public_key(self):
"""
获取 APIGW 的 Public Key
由于配置文件里的 public key 是来自环境变量,且使用 base64 编码,因此需要解码
"""
# 如果 BK_APIGW_PUBLIC_KEY 为空,则直接报错
if not settings.BK_APIGW_PUBLIC_KEY:
logger.error("BK_APIGW_PUBLIC_KEY can not be empty")
return ""

try:
public_key = base64.b64decode(settings.BK_APIGW_PUBLIC_KEY).decode("utf-8")
except Exception: # pylint: disable=broad-except
logger.exception("BK_APIGW_PUBLIC_KEY is not the base64 string, base64.b64decode fail")
return ""

return public_key

@cachedmethod(timeout=None) # 缓存不过期,除非重新部署服务
def _get_jwt_public_key(self, request_from):
# TODO 理论上 open_v2 只接 ESB,open_v3 只接 APIGW,后续新增 open_v3 后可以分离该 Auth 类逻辑
if request_from == "apigw":
return self._get_apigw_public_key()

return esb.get_api_public_key()["public_key"]
22 changes: 22 additions & 0 deletions src/bk-user/bkuser/apis/open_v1/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017 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 functools import cached_property

from bkuser.apps.tenant.models import Tenant


class DefaultTenantMixin:
"""默认租户 Mixin"""

@cached_property
def default_tenant(self) -> Tenant:
return Tenant.objects.filter(is_default=True).first()
26 changes: 26 additions & 0 deletions src/bk-user/bkuser/apis/open_v1/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017 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 BasePermission


class IsAllowedAppCode(BasePermission):
"""
仅允许配置好的 AppCode
需要配合 ESBAuthentication 一起使用
"""

def has_permission(self, request, view):
"""
目前只允许桌面访问
桌面 AppCode: https://github.com/TencentBlueKing/blueking-console/blob/master/backend/components/esb.py#L35
"""
return hasattr(request, "bk_app_code") and request.bk_app_code in ["bk_paas"]
30 changes: 30 additions & 0 deletions src/bk-user/bkuser/apis/open_v1/renderers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017 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 import status
from rest_framework.renderers import JSONRenderer


class BkLegacyApiJSONRenderer(JSONRenderer):
"""蓝鲸历史版本 API Json 响应格式化"""

format = "bk_legacy_json"

def render(self, data, accepted_media_type=None, renderer_context=None):
# Wrap response data on demand
resp = renderer_context["response"]
if status.is_success(resp.status_code):
data = {"result": True, "code": 0, "message": "", "data": data}
elif status.is_client_error(resp.status_code) or status.is_server_error(resp.status_code):
data = {"result": False, "code": -1, "message": data["message"], "data": {}}

# For status codes other than (2xx, 4xx, 5xx), do not wrap data
return super().render(data, accepted_media_type=None, renderer_context=None)
34 changes: 34 additions & 0 deletions src/bk-user/bkuser/apis/open_v1/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017 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 import serializers

from bkuser.common.constants import TIME_ZONE_CHOICES


class ProfileUpdateInputSLZ(serializers.Serializer):
username = serializers.CharField(help_text="用户名")
# Note: 只兼容允许修改 language、time_zone、wx_userid 字段
# display_name = serializers.CharField(help_text="姓名", required=False)
# telephone = serializers.CharField(
# help_text="手机号,仅支持中国大陆",
# required=False,
# min_length=11,
# max_length=11,
# )
# email = serializers.EmailField(help_text="邮箱", required=False)
language = serializers.ChoiceField(help_text="语言", required=False, choices=["zh-cn", "en"])
time_zone = serializers.ChoiceField(help_text="时区", required=False, choices=TIME_ZONE_CHOICES)
wx_userid = serializers.CharField(help_text="绑定的微信消息通知的账号 ID", required=False, allow_blank=True)


class ProfileBatchQueryInputSLZ(serializers.Serializer):
username_list = serializers.ListField(child=serializers.CharField(help_text="用户名"), max_length=100)
19 changes: 19 additions & 0 deletions src/bk-user/bkuser/apis/open_v1/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017 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("login/profile/", views.ProfileUpdateApi.as_view(), name="open_v1.update_profile"),
path("login/profile/query/", views.ProfileBatchQueryApi.as_view(), name="open_v1.query_profile"),
]
118 changes: 118 additions & 0 deletions src/bk-user/bkuser/apis/open_v1/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""
TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available.
Copyright (C) 2017 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 phonenumbers
from django.db.models import Q
from django.http import Http404
from rest_framework import generics, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from bkuser.apps.data_source.constants import DataSourceTypeEnum
from bkuser.apps.tenant.models import TenantUser

from .authentications import ESBAuthentication
from .mixins import DefaultTenantMixin
from .permissions import IsAllowedAppCode
from .renderers import BkLegacyApiJSONRenderer
from .serializers import ProfileBatchQueryInputSLZ, ProfileUpdateInputSLZ


class ProfileUpdateApi(DefaultTenantMixin, generics.GenericAPIView):
authentication_classes = [ESBAuthentication]
permission_classes = [IsAuthenticated, IsAllowedAppCode]
renderer_classes = [BkLegacyApiJSONRenderer]

def post(self, request, *args, **kwargs):
"""
更新用户信息
Note:
1)在 2.x 中,该接口是 upsert, 而 3.x 中只是 update, 不允许新建用户
2)在 2.x 中,调用方是 登录 & 桌面,3.x 调整为仅桌面可调用
"""
slz = ProfileUpdateInputSLZ(data=request.data)
slz.is_valid(raise_exception=True)
data = slz.validated_data

tenant_user = TenantUser.objects.filter(
Q(id=data["username"]),
Q(tenant=self.default_tenant),
Q(data_source__type=DataSourceTypeEnum.REAL)
| Q(data_source__owner_tenant_id=self.default_tenant.id, data_source__type=DataSourceTypeEnum.VIRTUAL),
).first()
if not tenant_user:
raise Http404(f"user username:{data['username']} not found")

# 有传入字段参数则更新
update_fields = []
for field in ["language", "time_zone", "wx_userid"]:
if field in data:
setattr(tenant_user, field, data[field])
update_fields.append(field)

if update_fields:
update_fields.append("updated_at")
tenant_user.save(update_fields=update_fields)

# Note: 由于调用方是判断非 200 即为异常,所以虽然是更新操作,但是兼容接口不可以返回 204,只能是 200
return Response(status=status.HTTP_200_OK)


class ProfileBatchQueryApi(DefaultTenantMixin, generics.GenericAPIView):
authentication_classes = [ESBAuthentication]
permission_classes = [IsAuthenticated, IsAllowedAppCode]
renderer_classes = [BkLegacyApiJSONRenderer]

def post(self, request, *args, **kwargs):
"""
根据 username 列表, 批量查询用户信息
"""
slz = ProfileBatchQueryInputSLZ(data=request.data)
slz.is_valid(raise_exception=True)
data = slz.validated_data

tenant_users = TenantUser.objects.filter(
Q(id__in=data["username_list"]),
Q(tenant=self.default_tenant),
Q(data_source__type=DataSourceTypeEnum.REAL)
| Q(data_source__owner_tenant_id=self.default_tenant.id, data_source__type=DataSourceTypeEnum.VIRTUAL),
).select_related("data_source_user")

user_infos = []
for u in tenant_users:
# 手机号和手机区号
phone, phone_country_code = u.phone_info
user_infos.append(
{
# 租户用户 ID 即为对外的 username / bk_username
"username": u.id,
"chname": u.data_source_user.full_name,
"display_name": u.data_source_user.full_name,
"email": u.email,
"phone": phone,
"iso_code": self._phone_country_code_to_iso_code(phone_country_code),
"time_zone": u.time_zone,
"language": u.language,
"wx_userid": u.wx_userid,
"qq": "",
"role": 0,
}
)

return Response(user_infos)

@staticmethod
def _phone_country_code_to_iso_code(phone_country_code: str) -> str:
"""将 86 等手机国际区号 转换 CN 等 ISO 代码"""
if phone_country_code and phone_country_code.isdigit():
return phonenumbers.region_code_for_country_code(int(phone_country_code))

return ""
Loading

0 comments on commit 28c7565

Please sign in to comment.